Free YouTube Transcribe

Video transcript

Complete GitHub Actions Course - From BEGINNER to PRO

DevOps Directive · 44,787 words · 204 min read

Want to search this transcript, jump the video from any line, or download it as TXT, SRT, or VTT?

Open in the transcript tool

Full transcript

Introduction

0:00Hey team, Sid here with DevOps Directive

0:02and welcome to this complete GitHub

0:04actions course. I spent the past few

0:05months building out what I believe to be

0:07the best resource for learning to use

0:09GitHub actions effectively and I'm

0:10excited to release it to the world. This

0:12course is for software engineers and

0:14DevOps practitioners who want to level

0:15up their ability to leverage GitHub

0:17actions within their teams and

0:18organizations. Over the next three and a

0:20half hours, I'm going to cover

0:21everything you need to know about GitHub

0:23actions. Starting with a solid

0:24foundation of platform capabilities,

0:26establishing the skills required to

0:28author automation workflows to meet the

0:30needs of your teams, and wrapping up by

0:31utilizing all of the skills and

0:33knowledge from the course to build a

0:34robust DevOps system based on GitHub

0:36actions containing workflows for

0:38building, testing, and deploying

0:40applications. While you're probably

0:41watching this video on YouTube, the

0:43video is just one component of the

0:44course. There's also a companion GitHub

0:46repo containing all of the code and

0:48workflows so you can follow along. Each

0:50lesson has a written module that you can

0:52see over at courses.devopsdirective.com

0:54and there's a Discord community where

0:56you can join to interact with other

0:57community members if anything isn't

0:59clear or you get stuck along the way.

1:00Thanks to my sponsor namespace, I'm able

1:03to provide this content to you free of

1:04charge. That being said, if you end up

1:06getting value from the course, you can

1:07help show your support by liking the

1:09video and sharing it on Twitter,

1:11LinkedIn, or within your company.

1:13Throughout the course, I'll go back and

1:14forth between theory sections where I'm

1:16covering all the different features that

1:17you need to know about and explaining

1:19how to use them and then practice where

1:20we'll jump into the code editor, view,

1:22author, and edit workflows and actions

1:24and run them both locally and remotely

1:26on GitHub to solidify your understanding

1:28of the various concepts. As I mentioned,

1:30this course is for any software engineer

1:31or DevOps practitioner who wants to

1:33level up their ability to use GitHub

1:35actions. This could be because your

1:36current CI pipelines are taking too long

1:38and you want to understand how to

1:39effectively use caching, parallelism, or

1:41higher performance runners to speed that

1:43up. It might mean you're starting some

1:44new projects and want to do so in the

1:46right way with automated workflows built

1:47in from the start. Or you may have

1:49inherited a mess of YAML from a previous

1:51engineer and you want to learn how to

1:52understand and refactor it into a

1:54modular maintainable system. Throughout

1:56the course, we'll learn the skills to do

1:57any of these things and how to use

1:59GitHub actions most effectively

2:00throughout your organization. There are

2:02some prerequisites for this course.

2:03First, I'm expecting you to at least

2:04have a base level understanding of Git

2:06and GitHub. Things like how to clone a

2:08repo, how to create branches, commit

2:10those, create PRs from those branches.

2:11I'm also assuming that you have a

2:13general idea of how to interact with a

2:14Linux shell or terminal, as well as

2:16writing and editing YAML files, which is

2:18the format that GitHub actions workflows

2:19are authored in. I'm expecting you to

2:21have at least some coding ability in one

2:23or more languages. This could be

2:24JavaScript, TypeScript, Go, Python, but

2:26the ability to read and write code is

2:28somewhat necessary. And then finally, an

2:30optional prerequisite around Docker and

2:32containers. there is the ability to

2:34create containerized actions and so for

2:36that having an understanding of how

2:37Docker works will be helpful. I also

2:39have a comprehensive course on Docker

2:40and containers that you could check out

2:41if you wanted to brush up on those

2:42skills. Before we dive into the course

2:44content, I do want to pause for a minute

2:46and thank the sponsor of this course,

2:47Namespace. It's only through their

2:49generous support that I'm able to

2:50provide this course to you completely

2:52free of charge. I'm excited about them

2:53as a sponsor because I want everyone

2:55using GitHub actions to know about them.

2:57Their products enhance the GitHub

2:58actions user experience and enable teams

3:00to build, test, and ship software

3:01faster. modifying just a single line in

3:03your configuration files, enables you to

3:05use namespace hosted runners, which can

3:07speed up your workflows while reducing

3:09costs significantly. But that's just the

3:10start. They also offer blazingly fast

3:12caching, remote Docker builders for

3:14speed ups when building container

3:15images, and best-in-class observability

3:17with access to things like job timing

3:19and resource utilization not available

3:21in GitHub directly. While using

3:22Namespace is not required to get the

3:24most out of the course, I will be

3:25showcasing where and how to use

3:27namespace tools to improve your

3:28experience when working with GitHub

3:30actions. If you want to get started with

3:32Namespace, there's a link in the

3:33description that you can follow. Full

3:34disclosure, this link is an affiliate

3:36link, so I will get some payment if you

3:37end up using Namespace. Because of this,

3:39if you do use them, I would appreciate

3:41if you use the link, and let them know

3:42that you learned about them from me. So,

3:44what are we going to cover in the

3:45course? We'll start by providing some

3:47context in terms of what GitHub actions

3:49is, where it falls within the landscape

3:51of continuous integration tools before

3:53covering all of the platform features

3:55that you're going to know about from the

3:56basic core features to the more advanced

3:58features as well as how to utilize the

4:01third party public marketplace action

4:02which is one of the big selling features

4:04of GitHub actions before authoring some

4:06actions of our own to use within our

4:07workflows. We'll then move on to how to

4:09engineer workflows that are going to

4:10meet the needs of your organization by

4:12examining some of the common workflow

4:13types, how to improve the developer

4:15experience when working with GitHub

4:16actions, as well as best practices that

4:18you can apply to your teams and

4:20organizations to make your experience as

4:21good as possible. Finally, we're going

4:23to tie all of this together in an

4:25end-to-end capstone project where we'll

4:27implement six different workflows that

4:28include testing, building, deploying a

4:31microserbased application, all using

4:33GitHub actions. And now with those

4:35logistics out of the way, we can go

4:37ahead and dive right into the course. In

History and Motivation

4:38order to provide historical context on

4:40why a tool like GitHub action exists in

4:43the first place, it's useful to take a

4:44look back through the history of

4:46software development and understand why

4:48continuous integration is a thing and

4:50where GitHub actions fits within the

4:51landscape. At the very early days of

4:53computing, things were done via physical

4:56punch cards. You would encode your

4:57program into a piece of paper with holes

5:00punched in it and then those would be

5:01fed into a machine that would perform

5:03the desired calculations and output some

5:05result. This process took quite a long

5:07time and generally the people writing

5:09and producing the software were the same

5:10ones consuming it. These were executed

5:12on big mainframe systems. There was no

5:14notion of personal computing. So you

5:15weren't really delivering software out

5:17to an end user. Uh you were writing the

5:19software for your own needs, executing

5:20it. And because of the amount of effort

5:22to to author and generate these punch

5:24cards, the time between updates was

5:26generally very very slow. The delivery

5:27mechanism moved from these physical

5:29punch cards onto things like floppy

5:31discs or compact discs or eventually

5:33DVDs. These devices could be written to

5:35digitally and then you could ship

5:36usually via snail mail this device to an

5:39end user who would install it in their

5:41machine. Because this required shipping

5:43a physical device via snail mail, the

5:46time between updates was still quite

5:47slow on the order of months probably.

5:49and the cost to ship an update was quite

5:51high. Continuing forward in time to the

5:542000s and the dotcom boom, uh we started

5:56to see websites and the internet take

5:59hold and software be delivered via the

6:02internet. So rather than needing to

6:04physically ship something to an end

6:06user, you could deliver a website or web

6:08app to the end user immediately over the

6:11internet. And this enabled a much

6:13quicker turnaround for shipping software

6:15updates. Now, in conjunction with this,

6:17because we're now able to ship updates

6:18much more quickly, the importance of the

6:20ability to automatically test updates

6:22before they ship became higher and

6:24higher. Now, we move into the 2010s and

6:27into the 2020s where we are today. And

6:29the highest performing software team are

6:30shipping updates multiple times per day.

6:32Because of this, the tooling for

6:34building out and automating tests has

6:37continued to evolve. And that's where

6:39continuous integration tools like GitHub

6:41actions really start to shine and become

6:43critical for these teams to ship quickly

6:45and safely. For those who are writing

6:47software today, the idea of continuous

6:49integration and automated testing may

6:51seem like table stakes, but before the

6:53year 2000, this was a totally novel

6:55idea. Companies were obviously building

6:58and testing their software, but this was

7:00often performed in a manual fashion on

7:02enduser machines rather than testing

7:05every single change automatically as it

7:06makes its way into version control.

7:08Looking way back to 1997, uh, one of the

7:12first uh, examples of continuous

7:13integration tooling came into play out

7:16of Netscape, eventually into Mosilla,

7:18and that was Tinderbox. Uh and so this

7:20was a tool which would automatically

7:22look at their version control and for

7:24each new commit it would attempt to

7:25build the software for all the different

7:27platforms that they were going to deploy

7:28to and then it would report the status

7:30of those builds back via this web

7:32interface. And this was a pretty

7:33revolutionary idea and it enabled those

7:35teams to see if there was a regression

7:37introduced and one of the builds failed

7:38to identify the bug and then fix that

7:40much more quickly. A few years later in

7:422001, uh, cruise control came onto the

7:45scene that was written in and primarily

7:47used by teams building software using

7:49Java. Similar idea, the ability to build

7:51your software and execute your test

7:53suites against them. Came out of a

7:54company called Thought Works a handful

7:56of years later in 2005. Uh, Hudson was

8:00introduced. Hudson came out of Sun

8:01Microsystems, eventually acquired by

8:03Oracle. uh and it provided kind of a

8:05more user-friendly user interface and a

8:08very large plug-in ecosystem that

8:10enabled you to handle a variety of use

8:12cases via those plugins. Eventually, it

8:14was forked and kind of lived on in the

8:17form of Jenkins uh due to some copyright

8:19issues with Oracle and the community

8:21wanting a more open version of the

8:23software. Some other key players in the

8:25space like Atlassian and Jet Brains

8:27introduced their own continuous

8:28integration tools around 2006 2007. And

8:31the interesting thing about these is

8:33that they had strong integrations with

8:34their existing product suites. So

8:36Atlassian was able to build bamboo and

8:38have tight integrations with their with

8:40their tools like Jira and Bitbucket.

8:42Whereas Team City was able to provide

8:44integration with their suite of idees or

8:46integrated development environments that

8:47they specialize in. If we move forward

8:49to 2010, we start to see some more

8:51familiar names come onto the scene like

8:53TravisCI and CircleCI. Now, with these

8:55new entrance to the market, these were

8:57some of the first software as a service

8:59model continuous integration providers

9:01where the company is generally hosting

9:02your runners and executing those builds

9:04as part of a cloud SAS. And these really

9:06push forward the developer experience

9:08and the user experience of uh making it

9:11easy to integrate a CI tool with your

9:15version control uh and set up automated

9:17builds. Now on the top there, we start

9:19to see entrance from the major version

9:21control platforms come into play. First,

9:24GitLab with their CI/CD product and then

9:26eventually in 2018 with GitHub actions.

9:29I'll talk about some of the reasons you

9:31might choose these different tools

9:33relative to each other. But when a

9:35version control platform uh releases a

9:37tool like this, they're able to have

9:38deep integration hooks with the rest of

9:40their platform, which can make it quite

9:42nice if your code is already hosted

9:43there. 2016 with AWS code build that was

9:46the first sort of entrance from a

9:47hyperscaler cloud. Uh GCP and Azure have

9:50their own CI tooling that was introduced

9:53not long thereafter. It can be useful if

9:55you're building and deploying your

9:57software all within one of those

9:59hyperscalers and you want to keep your

10:00builds and test infrastructure there as

10:03well. This just provides a timeline some

10:05of the key entrance in the history of

10:07continuous integration. As you can see

10:09GitHub actions was kind of a late

10:10entrance. Uh however, because it is tied

10:13to GitHub and that is the de facto

10:15standard where most teams are hosting

10:17their version control, it has a super

10:19low barrier to entry to getting started

10:21with. There's even an allocation of free

10:23build minutes that are provided for open

10:25source repos. So because it is so easy

10:27to get started, they've really ramped up

10:29quickly and become one of the more

10:30dominant players in the space. And now

10:32let's think a little bit about the types

10:33of workflows that people are running

10:36within these continuous integration

10:37tools. There's four main categories that

10:39I think of. Uh the first is sort of

10:41validating things about your code and

10:44the changes to your code. So this is

10:45things like linting to make sure that

10:47your code is all formatted correctly and

10:48there's no type errors etc. This is

10:50things like testing whether it's unit

10:52testing or integration testing or

10:54endto-end testing. Things like static

10:56analysis of which linting is a specific

10:58type but looking at security issues with

11:00your code either in dependencies or in

11:01how it's written or code quality

11:03metrics. So these type of validation

11:05checks are very common. The second

11:07category would be uh building your

11:09software. So for a compiled language

11:11this would be building the executable

11:13itself. Uh for other languages this

11:15might be taking your software

11:18downloading all the dependencies and

11:20bundling those into some deployable

11:22artifact like a container image. The

11:23third category then would be to take

11:25those deployable artifacts either the

11:27binary or the container or some virtual

11:29machine image and deploying those into a

11:32server somewhere that could be in the

11:34cloud that could be on premises. There's

11:35two main categories of deployment

11:37workflows. I would say the first is

11:38pushbased. So from within a workflow

11:40authenticating to that server or that

11:42Kubernetes cluster or whatever the

11:44deployment target is and issuing a

11:46command to to push that deploy from the

11:49workflow. The other one uh that has

11:50become more common recently is a GitOps

11:53model where you have some agent running

11:54inside of your deployment target that is

11:56looking back at the version control and

11:58pulling in updates automatically. I'll

12:00talk more about how these work later on

12:02in the course. And then the fourth is

12:03sort of a broad category of repo

12:05automation. So because GitHub actions

12:08has the ability to respond to lots of

12:10different types of events in GitHub, you

12:11can have things that are outside of

12:14traditional continuous integration to

12:16automate facets of your repositories.

12:19Things like uh automating the release

12:21process based on commit messages. Uh

12:23checking for issues or pull requests

12:25that are stale and closing those

12:27automatically or things like finding

12:29potential dependency updates uh and

12:31executing those or creating PRs for

12:33those. all sorts of different

12:34automations that you can apply to your

12:36repositories. Now that we understand the

Why GitHub Actions?

12:38context of GitHub actions and the

12:40history of continuous integration

12:41systems, let's talk a little bit about

12:43why you might choose GitHub actions over

12:45some of the alternatives. I found two

12:46industry surveys that ask many different

12:48developers which type of CI/CD pipeline

12:51tooling they are using. Uh one was from

12:53the CNCF, the CloudNative Compute

12:56Foundation, their annual survey, and one

12:58from Jet Brains, so the IDE development

13:01company. And both of these asks a very

13:03similar question. What type of CI

13:05tooling or pipeline are you currently

13:07using? As we can see from the CNCF

13:10survey, GitHub actions is the most

13:12popular option. We also see some tooling

13:14like Argo and Flux there that are really

13:16focused on only the GitOps or CD

13:19portion. So we kind of eliminate those.

13:21And so we look at the top few. It's

13:22GitHub actions, Jenkins, GitLab, and

13:25then Azure Pipelines, which is quite

13:26similar to GitHub actions. In fact, the

13:28GitHub actions runner codebase is a fork

13:30of the Azure pipelines runner uh back

13:32when they initially launched. Over on

13:33the JetBrain survey side, we can see

13:35Jenkins is in that top spot followed by

13:37GitHub actions and then GitLab. If we

13:39think about why Jenkins might be in the

13:41top spot there versus in the CNCF one,

13:43CNCF is going to be almost entirely

13:46companies and developers who have

13:47adopted Kubernetes and therefore are

13:49likely to adopt more recent or modern

13:52tooling. And so that's why I think

13:52GitHub action shines there versus if you

13:54take the broader set of software

13:56developers many of them maybe at larger

13:58more enterprise companies with legacy

14:00installs of Jenkins which has been

14:01around for a lot longer is much more

14:03common in some of those large enterprise

14:05settings and is operating projects that

14:06have been around for quite some time

14:08perhaps even longer than GitHub actions

14:10itself. Here I've mapped out a table

14:12kind of comparing these top handful of

14:15tools. So GitHub actions, GitLab, CI/CD

14:17and Jenkins were the three that we saw

14:19at the top of those surveys. And then I

14:20also included CircleCI as a CI provider

14:23not linked to a particular version

14:25control platform but is able to link

14:27into many of those platforms and

14:29provides a bit of contrast to Jenkins

14:30which is usually self-hosted whereas

14:32CircleCI they're generally hosting the

14:34runners for you. As I mentioned earlier,

14:36one of the big reasons that people get

14:37started with GitHub actions is because

14:39their code is already on GitHub. There's

14:41minimal barrier to entry. You just add a

14:42YAML file to your repo and this low

14:44friction means people start there and

14:46often end up staying there. There's also

14:48an expansive public marketplace so

14:50people can publish their own uh actions

14:52which are steps within your your

14:54pipelines that you're going to execute

14:55for many different common continuous

14:57integration tasks. You can find a public

14:59action that performs what you need to do

15:01and avoid having to write and maintain

15:03that code yourself. And then another

15:04reason why people choose GitHub actions

15:06is because they are a market leader. It

15:08leads to improved tooling. There are

15:10many companies building on top of GitHub

15:12actions just like the sponsor of this

15:13course namespace to build faster

15:15runners, better user experiences. Uh,

15:17and so that improved tooling is kind of

15:19a positive feedback loop where it's a

15:21better experience which leads to more

15:22adoption and so on and so forth. Some

15:24reasons you might not choose GitHub

15:26actions is if your code is living

15:27elsewhere if it's in GitLab or Bitbucket

15:29or some other version control system.

15:31GitHub actions has less powerful

15:33expressiveness than some of these other

15:35systems. It's it's a simplistic syntax,

15:37but it does mean that in certain cases

15:39you end up copying and pasting rather

15:41than being able to compose pipelines in

15:43a reusable fashion. We'll look at how to

15:45write and debug actions, but out of the

15:48box, the debugging experience can be

15:50somewhat painful and also the default

15:52observability and analytics for GitHub

15:54actions is quite limited. There's tools

15:56to help improve this and using a

15:59provider like namespace will give you

16:00some better observability, but out of

16:03the box it can be quite limited. GitLab

16:05is an obvious contrast to GitHub

16:07actions. Similarly, it is tied to the

16:09version control platform and so if your

16:12code is on GitLab, you're likely going

16:13to choose GitLab CI. Again, if you are

16:16in that ecosystem, it's a minimal

16:17barrier to entry. Has deep integration

16:19with all the other GitLab features. And

16:21GitLab has an interesting approach of

16:22auto DevOps. Allows you to bootstrap an

16:24end-to-end CI system. If you're in a

16:27common language ecosystem that they have

16:29added this type of capability for

16:31negatives for GitLab is if you are not

16:32using GitLab, you cannot use GitLab

16:34CI/CD. And also their marketplace

16:37features. So their ability to to share

16:38and consume thirdparty capabilities is

16:40significantly smaller uh than GitHub

16:42actions. Whereas there's there's less

16:44than 500 total actions in that

16:46marketplace. Whereas GitHub actions has

16:48thousands and thousands. The quality of

16:50the actions in the GitHub action

16:51marketplace varies wildly. Uh but

16:54because there are so many more, the

16:55likelihood that you'll find a

16:57highquality action that does meet your

16:59need is much higher. for CircleCI. If

17:01your code is not in one of those two

17:02major providers, but you still want a

17:05sort of cloud hosted CI system, CircleCI

17:08might be a really nice option. There is

17:09a feature that they have built in uh

17:12called retry with SSH that when a step

17:14in your pipeline fails, you can actually

17:15retry that workload and get instant

17:17access to the environment where that

17:19pipeline is running, which allows you to

17:20poke around and debug. There are ways to

17:22achieve this on GitHub actions and other

17:24platforms, but having used CircleCI in

17:26the past, that feature is one that I

17:27miss uh when using some of these other

17:29platforms. It does require some

17:31additional initial setup. It's not tied

17:33directly into the version control

17:35platform like these first two. Uh and

17:37also like GitLab, its public marketplace

17:39for orbs is what they call kind of the

17:41reusable unit uh is larger than GitLab,

17:44but it's still significantly smaller

17:46than GitHub actions. Now, the fourth one

17:48here, Jenkins, as I mentioned in the

17:50history section, has been around for

17:52much, much longer than any of these

17:53other tools. There's kind of two

17:55positives for it. I would say the first

17:57reason I can think of that someone would

17:59use Jenkins is that it is already set up

18:01and running and they don't want to

18:02invest to switch off of it. Uh so there

18:05is some switching cost to rewriting

18:07pipelines and moving things elsewhere.

18:09And so often times if you have an

18:12existing setup and it works, uh if it

18:14works, there's no reason to break it.

18:15The plug-in ecosystem is quite vast. So

18:17there's a number of capabilities that

18:18you can achieve within Jenkins by using

18:21those thirdparty plugins. Kind of

18:22similar to the to the GitHub actions

18:24marketplace. There's a lot of

18:26capabilities that have been built up

18:27over the lifetime of that tool. Some

18:29negatives or or why might you not want

18:31to use Jenkins is that it is generally

18:33known for having quite a high

18:35maintenance overhead. Sort of keeping up

18:37with plugins as they are updated or have

18:39security issues can be a significant

18:42piece of work. uh and also the

18:44authorship of the pipelines writing them

18:46in is a language called Groovy which

18:48isn't really used much elsewhere outside

18:50of the Jenkins ecosystem uh and it can

18:52feel kind of quite outdated relative to

18:55the YAML structure that you may be

18:57familiar with if you're using these

18:58other tools. Now, before we start

19:00examining and authoring and running uh

19:02GitHub action workflows, I want to pause

Setting up your Development Environment

19:04and make sure we set up your development

19:06environment appropriately so that you

19:07have all the tooling that you need to

19:09follow along with the steps that I'm

19:10performing and ideally minimize the

19:13number of snags that you hit along the

19:14way. Now, the first step that you're

19:15going to want to do is to fork the repo

19:17and then clone it to your local system,

19:19including the sub modules. Now, the

19:21reason that we fork it here is because

19:23that way you can be the owner of the

19:24repo on GitHub, which will enable you to

19:26execute the workflows as you look at

19:28them, modify them, and then you'll want

19:29to run them within your own forked copy

19:31of the repository. This is the repo for

19:34the course. It's at github.com/palace

19:36devopsdirective GitHub actions course.

19:38To do that, you'll navigate to the

19:39repository. You'll go over here to the

19:41fork button. And while you're here, you

19:42may as well give it a star, but you'll

19:44click the fork button. You'll give it a

19:46name, choose which account or

19:48organization you should make it a part

19:49of, and then you'll click create fork.

19:51Once you've done that, you can scroll

19:52down in the readme and find this get

19:54clone command and just modify this to

19:56reference your forked copy rather than

19:58the repo under my name. Because we've

20:00included recurs subm modules, we'll get

20:02the capstone repo as well. Eventually,

20:04when we start working on that, you may

20:05want to fork it as well, but for now,

20:07pulling in my copy should be just fine.

20:09Next, we'll install a tool called

20:10Devbox, which gives us a reproducible

20:13shell environment with all the tooling

20:15that I use throughout the course pinned

20:16to the exact version that I'm using. So,

20:18you shouldn't hit any version dependency

20:19issues. The best way to do this is to go

20:21to their documentation, choose the

20:23system that you are running on. So, in

20:25my case, I'm on Mac OS and I can run the

20:27script to install it. Hey, Sid from the

20:30future here. Uh, as I was editing this,

20:31I realized I didn't mention this, but if

20:33you are on Windows, you should be using

20:35WSL such that you're executing these

20:37from within Linux. Uh I haven't tested

20:39any of this on Windows directly. Uh and

20:42so you'll have a much better time if you

20:44work within a Linux environment under

20:45WSL. Mac OS is the system that I use to

20:48develop and test all this. And then

20:49because we're using Devox which

20:51leverages Nyx under the hood package

20:52manager, hopefully things will work

20:54seamlessly across Mac OS, WSL on Windows

20:56or Linux directly. All right, back to

20:58the video. Once it's installed, uh there

21:00is a devbox.json at the top level of the

21:03repo as well as a devbox.lock file. Uh

21:07these are what tells Devbox which tools

21:09to install. You can see all these tools

21:10that I'm using. I'm specifying latest

21:12here. That's I'm using latest at the

21:14time of recording. But by using this

21:16lock file, you will get the exact

21:17version that I have installed on your

21:20system. The nice thing about Devbox is

21:21it gives you this environment isolated

21:23from anything else on your laptop such

21:25that when you're working on the course,

21:27you can have these versions pinned to my

21:28exact versions. But if you're working on

21:30something else, it won't pollute those

21:31installs elsewhere. Once you have Devbox

21:34installed, you can just run Devbox shell

21:36and it will look at your current

21:37environment, download and install any

21:40tooling that is not there and start a

21:42shell session with all these tools

21:44installed and configured accordingly.

21:45Then we'll install Docker Desktop which

21:47is useful as we get into some of the

21:48containerized aspects uh of building

21:51containerbased actions andor using uh

21:54the tool ACT which allows us to run

21:56GitHub actions workflows locally within

21:58containers. So having docker installed

21:59is useful for that. for Docker Desktop.

22:01Follow the latest uh install

22:02instructions from their documentation.

22:04Choose the platform that you're on and

22:06follow the instructions there. And then

22:08finally, I'm going to be using VS Code

22:09throughout the course. You could use

22:10other editors, but these are two

22:13extensions within VS Code that I found

22:14helpful for working with GitHub actions

22:17specifically. The first one is YAML.

22:18This will give you YAML syntax

22:20highlighting as well as linting

22:21capabilities. And the second one is the

22:23GitHub actions uh VS Code extension. So

22:26if we go into VS code and you go under

22:28code settings extensions now you can

22:31search here for GitHub actions you can

22:34install that there. Uh this is the

22:36official one. There's this other one

22:37here as well. I'm using the official

22:39one. It does provide a few nice things

22:41around actually viewing the actions that

22:43have run in your repositories from

22:45within VS Code. I don't use them that

22:47much but it's it's nice to have

22:48installed. And then the second one is

22:50the YAML extension here from Red Hat. I

22:52go ahead and install that. One thing I

22:54did notice though uh when I set both of

22:56these up, the GitHub actions extension

22:59actually sort of takes ownership of any

23:01YAML file in those specific

23:03subdirectories such that the YAML

23:05extension was no longer recognizing them

23:07as the YAML type. And so I added this to

23:10my settings files.associations such that

23:13any any YAML file in

23:14that.github/workflows

23:16uh directory will be still associated

23:19with the YAML extension. And then

23:21similarly, uh, task files were not being

23:23recognized as YAML. And task is a tool

23:25we use throughout the course for storing

23:27and running specific little shell

23:29commands and and and scripts. And so by

23:32adding it to this settings.json, now the

23:36YAML linting and auto formatting worked.

23:38Again, you can set these from within the

23:40GUI if you don't want to use the

23:41settings.json file by going under code

23:44settings uh settings and then searching

23:48association.

23:50And you can see here uh for my user I

23:52have files matching those two patterns

23:55associated with the YAML file type such

23:57that the extension can do its job

23:58appropriately. Okay, the time has come

Core Features

24:00for us to actually take a look at and

24:03write and run some GitHub action

24:05workflows. This next module is going to

24:07focus on some of the core features and

24:08then the following module we'll look at

24:09some of the more advanced features. This

24:11will get you up and running with the

24:13basics of what you'll need to run within

24:14GitHub actions. And then throughout the

24:16rest of the course, we'll start to learn

24:17how to use these tools more effectively

24:19and design workflows that are really

24:21going to help your team execute at a

24:22high velocity. Before we actually look

24:24at the YAML configurations though, let's

24:27talk about some of the terminology so

24:28that we're all on the same page in terms

24:30of the different concepts you need to

24:31know and how we refer to them. On the

24:33lefth hand side are GitHub events. So

24:35these are things like pushing to a

24:37branch in GitHub, creating a pull

24:39request. Any event that happens via the

24:41GitHub API or that you could have access

24:43via the GitHub API generally you can see

24:46and respond to from GitHub actions.

24:48These are going to trigger what is

24:50called a workflow. And so workflow a

24:52workflow is what they call pipelines in

24:54GitHub actions. So the thing that you

24:56are authoring your file represents a

24:58workflow. Within that workflow there

25:01will be one or more jobs. Each of those

25:03jobs can have one or more steps within

25:05them and they could be ordered in a

25:07specific manner as you can see here from

25:09this graph. And finally, the runner is

25:12the actual server environment that is

25:14going to execute those jobs. So between

25:17steps, you're in the same comput

25:18environment. Across jobs, you're in a

25:20different compute environment. So you

25:21need to think about that in terms of

25:22passing data or sharing environment

25:24variables or state between these

25:26different elements. Here's essentially

25:28the most simple workflow you can author.

25:30uh it's only 10 lines including a couple

25:32of blank lines. We can see those

25:34concepts from the previous slide mapped

25:36onto it. So in the on section there on

25:38line three, we've got a GitHub event. Uh

25:41in this case, we're using the workflow

25:42dispatch event. That just means it's a

25:44manual trigger. So we can go into the

25:45GitHub UI and click trigger. We'll talk

25:47more about how to use the different

25:49events. For example, we could have a

25:50push event for when it pushes to a

25:52specific branch or a pull request event

25:54for when we create a pull request. For

25:56now, we're just going to use that

25:57workflow dispatch in many cases so that

25:58we can manually trigger and see these

26:00things executing. Based on the outlined

26:02colors of those boxes, you can see the

26:04workflow is the entire thing. Jobs is a

26:06list within that workflow. And then

26:08steps is a list within that specific

26:10job. In this case, we have a single

26:12workflow containing one job and one

26:13step. And then there on line 8, the runs

26:16on key specifies what type of execution

26:19environment you want to have. U24.04

26:22is a runner type hosted by GitHub

26:24itself. If you wanted to run on Mac OS

26:26or Windows, you would specify that here.

26:28Or if you wanted to run on a thirdparty

26:30service like namespace, you would

26:32specify one of their runner types in

26:33this field. We'll go more into that

26:35later in the advanced section, but it's

26:36necessary to understand specifying

26:38Ubuntu 24.04. There means that we're

26:40going to get a runner hosted by GitHub

26:42running Ubuntu on that version. Let's

26:45jump over to GitHub in the browser and

26:48see this workflow in action. So if you

26:50go to the repo under the actions tab,

26:52you'll see all the different workflows

26:53that you have within your repo here. If

26:55I go to the hello world workflow, I can

26:57then click run workflow. And this button

26:59is only available because I have that

27:01workflow dispatch trigger in my

27:02workflow. I can run it from a particular

27:04branch. In this case, I'll run it from

27:05main. I'll click run workflow. Now if I

27:09refresh the page, we can see a run of

27:11that workflow has shown up. It's in a

27:13cued state. So it's waiting for a GitHub

27:15action runner. If I click into it, we

27:18can see it has now executed and it has

27:20run the command that I had in my step.

27:23Hello from an inline bash script in a

27:24GitHub action workflow. So that's the

27:26most simple workflow you can have. Now

27:28what are the different types of steps

27:30you can specify in a different workflow.

27:32Let's expand our hello world workflow to

27:34have three jobs each containing one step

27:36and this will highlight the different

27:38types of steps that you can have. And so

27:40what have we actually changed here in

27:41this uh in this update? You can see in

27:45the first step, it's almost identical to

27:47before. We're executing an inline bash

27:49script. That's the default shell on the

27:51runner. In the second job, we've

27:53actually modified that to use the Python

27:55shell. So now instead of writing bash,

27:57we can write Python. And then in the

27:59third one, we're using a third party

28:01open source action. This is the hello

28:03world JavaScript action. Uh and I've

28:05shown a screenshot here of that repo.

28:07And so we're able to leverage this

28:10external action to execute as a step

28:13within our job. And now let's jump over

28:15to our code editor and look at this

28:16workflow there. So as you can see, I

28:19made a few tweaks. I made this a little

28:21bit more verbose. So we know this is

28:22running from the bash script. This is

28:24running from the inline Python script.

28:25Uh and here we're specifying that third

28:27party action still, but rather than

28:29specify a version, we're actually going

28:31to tag the specific commit hash of the

28:33version we want to run. We're going to

28:34talk more about why that is from a

28:36security perspective and a stability

28:37perspective. Uh, but just know for now,

28:39you can specify either as a specific git

28:42tag or as a commit hash, but the commit

28:44hash is better practice. These longer

28:47strings didn't fit on the slide, that's

28:48why I didn't include them. Now, we can

28:50jump into the GitHub user interface and

28:52run a copy of this. So, I called this

28:54workflow step types. So, we can navigate

28:56to it here. Run it via the workflow

28:58dispatch.

29:00see that it's executing.

29:03Each of those three jobs is running in

29:04parallel.

29:07We can see they all succeeded and

29:09executed the commands that we expected.

29:12The Python executed accordingly

29:18and that third party action

29:22printed the output we would expect.

29:25Those are the three primary types of

29:26steps that you can run within your

29:27GitHub action workflow. either bash,

29:29Python, or a GitHub action as a step.

29:33However, there's one thing we haven't

29:35touched on here. You saw those three

29:36jobs ran in parallel. We can also tell

29:39GitHub actions to structure these jobs

29:41in a specific order by adding

29:44dependencies in the form of needs within

29:46the job specification. So, workflows

29:49form what's called a directed as graph

29:51or a DAG. And we can use that needs key

29:53to say which jobs depend on which other

29:55jobs. This will produce a structure like

29:58shown in that screenshot and allow us to

30:00take outputs from one job and pass them

30:02to another. So here I'm showing in the

30:04first job we can run a sequence of

30:06steps. The second job we can run an

30:08independent step which is not connected

30:10to job one in any way. But then by using

30:12needs jobs one and two for job three and

30:15needs jobs two and three for job four.

30:17We're going to ensure that jobs one and

30:19two execute first in parallel then job

30:21three and then job four. Let's jump over

30:23to the browser and run a copy of this.

30:27I called this workflow jobs and steps.

30:30We can run workflow run.

30:34Clicking into it, we see that structure

30:36as specified within our YAML. Jobs one

30:38and two have completed allowing job

30:40three to be picked up. And only after

30:43job three completes will we see job 4

30:46execute.

30:48It is an asyclic graph. So you cannot

30:50have a loop. You need to have a flow in

30:52one direction. That's how you specify

30:54the structure of your jobs within a

30:55workflow.

30:57So far, we've been using the workflow

30:59dispatch as a manual trigger that we can

31:01utilize from within the GitHub

31:03interface. That one's actually less

31:05common, but it's useful for an

31:06educational setting like this. The types

31:08of events that I see used more

31:10frequently are highlighted here within

31:12this list of all the events. Uh the

31:14first one we've been seeing that trigger

31:16manually with the workflow dispatch. The

31:17second one pushed to a branch. So you if

31:20you you can set it to trigger a workflow

31:22on a push to any branch or you can

31:25filter to specific branches. For

31:26example, you might want to filter to

31:28your default or main branch. Uh you can

31:30also as I mentioned trigger workflows

31:33based on a pull request event. So that

31:35could be the creation or update of a

31:37pull request. You could filter that so

31:39it only issues on pull request against a

31:41certain branch. There's lots of control

31:43here within how you structure these. And

31:45then the final one that I see most

31:47frequently is running on some timed cron

31:50schedule. Maybe you have something that

31:51runs nightly or weekly and you're able

31:54to use this schedule type of a

31:56triggering event to specify that. Now

31:58I've written a simple workflow that

32:00leverages these different types of

32:02triggers. You could see as I mentioned

32:04the push workflow could be triggered

32:06only on a specific branch pattern. So

32:08we've got example branch and then a wild

32:09card. So you can use glob patterns in

32:11those fields. pull request. The default

32:14if you don't specify a type of pull

32:16request event, it's going to be whenever

32:17one is opened, synchronized, or

32:18reopened. Uh and then you can also

32:20specify within a pull request event

32:22which paths you want to trigger on. And

32:25so this enables you to, as you'll see

32:27later on, filter down so you're only

32:29executing workflows relevant to a

32:31specific subpath within your repo. This

32:33is critical if you're using a monor repo

32:35that's going to have multiple services

32:36defined within it to not have wasteful

32:38execution of your workflows. You can see

32:41how this how the cron schedules are

32:43defined here with a cron string. So by

32:45specifying the string 00 star star star

32:48that's going to run at midnight UTC. So

32:50the zeroth minute of the zeroth hour

32:52every single day. That's what that

32:54means. And then we've seen the workflow

32:56dispatch already. That's just a manual

32:57trigger that you can execute either via

33:00the GitHub CLI or the web interface. In

33:03this example workflow, all I'm doing is

33:05taking the event that triggered the

33:06workflow and dumping that out to the

33:07terminal so that we can see all the

33:09different metadata that is associated

33:10with it that you can leverage from

33:12within the workflow as you perform your

33:14work. Now, specifically, let's take a

33:16look at the pull request and the path

33:18filtering to see how that would work.

33:20So, within my repo under the module 3

33:23subdirectory, I have this filters

33:25directory and within there we have an

33:27excluded file and an included file. My

33:30my paths filters should include markdown

33:32files but exclude text file. If I create

33:35a pull request that modifies the

33:36markdown file, it will execute this

33:39workflow. If I create a pull request

33:41that modifies only the text file, it

33:43will not execute it. So I've actually

33:44done that ahead of time. I've got these

33:46two pull requests here. This one we've

33:48modified the markdown file to include

33:51the paths filter. So I'm going to copy

33:53this branch.

33:59I'm going to push to get an update to

34:01that PR.

34:04And then if we go back here, this is

34:06workflow was triggered just a few

34:07seconds ago. We can go in and look at

34:10the event that triggered it. It's a big

34:12old JSON structure uh containing lots of

34:15information about the event that

34:16triggered it. So this is going to be

34:18things like the pull request number, uh

34:20the files that were modified, etc., etc.

34:22And so this is super useful as we go

34:24down the path of using these data to

34:26decide how our actions going to behave.

34:28Let's do the opposite and modify the

34:30file in that excluded filter. So if I go

34:33here and check out this one,

34:42let's just make an empty commit

34:46and push.

34:48Looking at the files changed, we've

34:50modified this text file on our branch.

34:54We've pushed that empty commit.

34:56However,

34:58no additional actions were triggered. If

35:00we go back here to the actions tab,

35:03no action was triggered. Now, why did

35:06this happen? We have prefix that

35:07exclamation point on line 15. When we

35:10modify a file that matches that pattern,

35:11we don't need to trigger this workflow.

35:13So that's how you can start to use these

35:14paths to understand how you can trigger

35:17and control when workflows are triggered

35:18for specific updates. As I showed, each

35:21of these events contains tons of

35:22metadata associated with it and that and

35:25you can tap into those data within your

35:26workflow itself. Just like with the pull

35:28request filter path filters, I can go

35:31ahead and make a branch that matches my

35:32example branch and say get checkoutb

35:38this will run workflow

35:44get push.

35:50We see that it triggered a run of my

35:52workflow based on that branch name.

35:56However, if I make a different branch

35:57name

36:04and I push that,

36:09we will not see an additional run

36:11because it didn't match any of the

36:12branch patterns to specified in that

36:13list. When we get to the capstone module

36:16of the course, you'll see how these are

36:18leveraged in practice across a variety

36:21of different use cases. The next core

36:22feature I want to touch on here are

36:24environment variables. These are

36:26variables within the execution

36:27environment and these can be scoped to a

36:29variety of different levels. Each of

36:30those different concepts that we talked

36:32about, we can have an environment

36:33variable that is shared across the

36:34entire workflow. We can have one that is

36:36shared only within a job or only within

36:38a step. And so you can see here I've

36:40specified one of each of those such that

36:43we can look at which are defined in

36:44which context. When we execute this that

36:46workflow var will be available in any of

36:48our different steps. Uh the job var will

36:51be available only in job number one. And

36:53the step var will be only available in

36:55job one step one. So let's go ahead and

36:57run this. That workflow is named

36:59environment variables. Run workflow.

37:05We'll click in here. We see jobs one and

37:07two. Because I didn't specify a needs

37:09key. These can run in parallel. Within

37:11job one,

37:14we can see all three of these are

37:17visible and print appropriately. If we

37:19look at job one, step two, we would

37:21expect the workflow and the job var to

37:23be defined. However, the step var is

37:25unset here.

37:27And then if we look into job two, now

37:31only the workflow scoped environment

37:34variable is going to be set. And the job

37:35and step variables which were scoped to

37:38job one are not set. This is how you can

37:41control environment variables set within

37:43your workflow itself. We'll look at in a

37:45little bit how to store environment

37:47variables from one step and persist

37:48those uh throughout the life of a job.

37:51This is within your job configuration

37:52itself.

37:54Now those environment variables were

37:55defined within our YAML file. We also

37:58can define things at runtime uh and pass

38:00data between steps and between jobs. Uh

38:03and so we can do this via outputs and

38:06inputs. Uh we also can do this via

38:08environment variables within a job

38:10itself. And so there's three kind of

38:12main things to showcase here. The first

38:14one is how to take some string and store

38:17it to an output and then how to consume

38:18that from within another step. And the

38:20way that we do that is we write to this

38:22file at the path specified by GitHub

38:24output. GitHub's going to populate that

38:26environment variable for us. We're going

38:28to echo fu equals bar. In this case,

38:32that double greater than sign is going

38:34to append to the end of that file. And

38:37now we can reference that in other

38:38steps. And so on line 26 we can say from

38:41the step with the id generate fu look at

38:43the outputs and use the fu output. So

38:46line 26 is going to output fu bar. For

38:50environment variables it's very similar

38:51but instead of writing to the github

38:52output file we write to the github end

38:55file within a job. We can then reference

38:58that as a normal shell variable. So like

39:00on line 25 we've got dollar sign fu. And

39:03then to pass things across jobs. So

39:05inter job we first need to put it as an

39:07output of the step like before. We then

39:10need to reference the job as an output

39:12of the job. So line 10 does that.

39:15And then in the consuming job we need to

39:18specify the needs key with the upstream

39:20job in it. And then finally we can

39:21reference it from that list of upstream

39:24jobs based on the name, the outputs and

39:27the key with which it's referenced. So

39:29if we go and run this, we'll see on

39:31lines 25 and 26, those two will both be

39:33set. But then on lines 34 and 35, only

39:36line 34 will be set. Whereas line 35

39:38with the environment variable, that does

39:40not persist across jobs. This workflow

39:43is named passing variables between jobs.

39:45We'll run it.

39:47As we expect, our producer is upstream

39:49of the consumer because we specified

39:51that needs key.

39:54Here we're generating those data. We're

39:55setting fu equal to bar

39:58and setting both our output and our

40:00environment variable. Now when we

40:01inspect the values, we can see they're

40:03both properly set to bar. In our

40:06consumer job, if we look at it, only the

40:09value which was passed from output to

40:11input persisted. That's because

40:14environment variables do not persist

40:15across jobs because they run in

40:17different compute environments. So you

40:18need to be explicit about which data

40:20you're passing from one job to another.

40:23Now the last feature that I want to

40:24touch on in this core features module

40:26are secrets and variables. So these are

40:28very similar to the environment

40:29variables that we saw before. However,

40:31these can be stored and managed at a few

40:33different levels. So you can store them

40:35either at the GitHub organization level,

40:37a repository within that organization or

40:39even a specific environment within a

40:40repository. So think about staging

40:42versus production might have different

40:43credentials for the deployment

40:45environment that you're going to deploy

40:46your application to. This allows you to

40:48do that. For sensitive values, database

40:50password or credential, those would go

40:52as secrets. And those you can only set

40:54within the UI, you can't actually read

40:55them back except within the context of a

40:56GitHub action. Uh for nonsensitive

40:59values that you want to be able to view

41:00and edit, those can be set as variables

41:03from within the UI. You can see those,

41:05update them, and consume them from

41:06within your actions. To set these, you

41:08go under the settings tab, and then

41:10under actions and secrets and variables,

41:12you can then populate them either for an

41:14environment or for the repository. You

41:16could do the same at the organization

41:18level to share secrets across different

41:20repos. And then for the the

41:22non-sensitive variables input, you click

41:25onto that variables tab and then you're

41:26able to specify them there. Within a

41:28workflow, you use that dollar sign

41:31double curly braces syntax. For secrets,

41:33it's going to be secrets dot and then

41:34the name of the secret. For variables,

41:36it's going to be varss dot and then the

41:37name of the variable. So within my repo

41:39under secrets and variables actions, you

41:41can see I've specified a staging and

41:43production secret. So these are two

41:46different values stored at the same key

41:47for different environments. I then also

41:49have a repository secret as well as some

41:52AWS secrets that we'll use later on.

41:55Finally in the variable side we have a

41:58staging and production environment

42:01variable nonsensitive and you can see

42:03the value specified here. And then we

42:05also have one at the repository level

42:07that will be shared across any

42:08environment. So if we go to our actions,

42:11scroll down to secrets and environments,

42:14click run,

42:17you can see I have two different jobs

42:18here. If we jump back to the definition

42:21on lines 8 and lines 26, I'm specifying

42:24the environment in which this is

42:25running. So the top job is going to use

42:27that staging environment uh secret and

42:29variable and the bottom job is going to

42:31use the uh production environment secret

42:33and variable.

42:35Let's click into the staging one and

42:38inspect the values of the job.

42:46Now you'll notice when we try to print

42:47out a secret value, GitHub automatically

42:50masks it using these asterisks. That's

42:53to prevent us from accidentally leaking

42:55our credentials or sensitive values into

42:56the logs of our jobs. Uh we also have

42:59seen we also see that it output the

43:01repository v variable and the staging

43:04variable. So if we go to the production

43:06job we'll see hopefully this repo

43:09variable will be identical but this

43:11should say this is the production

43:12environment variable value

43:15and that's exactly what we see. The repo

43:17variable was the same across these two

43:19different environment runs. However,

43:20within the job that specified the

43:22environment as production, we got the

43:24production specific value of that

43:25environment variable and that secret.

43:27Before we wrap up this module, I do want

43:29to call out a feature that we've already

43:30used a couple times, but I wanted to

43:32address it a little bit more explicitly,

43:34and that is contexts. So, within GitHub

43:36actions, there's all these different

43:37contexts that you have access to at

43:38runtime, and you access them using that

43:40dollar sign double curly braces syntax.

43:42So far, we've seen the needs and the

43:44steps context. We use those when passing

43:46data from one step to another or from

43:47one job to another. And then we just saw

43:49how to use the secrets and varss context

43:51to load those data into our runner. I've

43:53listed here at the bottom a number of

43:54additional contexts including the GitHub

43:57context that'll have things about the

43:58run and the repo state. The environment

44:00context will have all the environment

44:02variables set within the workflow job or

44:03step level. The matrix context which

44:05we'll use in the future to execute

44:07multiple copies of jobs. The job context

44:09which will have our current job status

44:10as well as some information about

44:12containers and services. The runner

44:13context which has info about the runner

44:15itself. And finally, the inputs context

44:17where you can provide inputs to your

44:18workflow and then use those at runtime.

Advanced Features

44:20Now, we can move from those sort of

44:22basic core features of GitHub actions

44:24into some of what I would consider the

44:26more advanced features. Now, not all

44:28these are going to be difficult to use,

44:29but these are things that extend your

44:31ability to utilize the platform versus

44:33needing to use them right from the

44:35start. Like in module 3, these are still

44:37just a distilled version of each of

44:39these features, trying to capture their

44:41essence without getting bogged down too

44:43much in the weeds. We'll see how to use

44:45them for real world examples as we move

44:47on later in the course and into the

44:48capstone. The first feature that I want

44:50to highlight here are the runner types.

44:52Now, when I was describing the most

44:54basic hello world workflow, we still

44:56utilize the runs on field to specify

44:58what type of runner, but now we can look

45:00at the different types that are

45:01available. There's GitHub hosted

45:02runners. These are things like Ubuntu

45:0424.04, uh, Windows and Mac OS runners

45:07from GitHub. There's third party hosted

45:09runners. Now, these are going to be

45:11companies like namespace where you can

45:12utilize their infrastructure to execute

45:14your builds. And why would you want to

45:16do something like this? Oftentimes,

45:17they're able to offer significantly

45:19faster compute and storage at a reduced

45:22price relative to the hosted runners. It

45:23also opens up the possibility to utilize

45:26their infrastructure for things like

45:27faster caching, better observability,

45:29and improved multi-architecture

45:31container build experiences. The third

45:33category is self-hosted. So the actual

45:35runner agent is a open- source project

45:37which you can install into a server

45:39yourself or run in something like

45:40Kubernetes with the actions runner

45:42controller or arc project. There's also

45:44a really cool project called runs on

45:46which deploys a set of infrastructure

45:48into your own AWS account which can spin

45:50up ondemand EC2 instances for each

45:52runner job which can reduce the costs of

45:54execution even further while also

45:56keeping the data within your own VPC. By

45:58specifying a runner type, you're

45:59controlling which virtual machine image,

46:01the operating system, plus the

46:03dependencies which are going to be

46:04included in that environment, as well as

46:05the CPU and memory that are going to be

46:07available. You can see a number of

46:08examples here on the right, including

46:10the iuntu, Windows, and Mac OS runners

46:12from GitHub itself. There's also an

46:14option to execute your jobs within a

46:16container image. So, by specifying both

46:18a runs on and a container image, you're

46:20able to provide a container image with

46:22additional dependencies which might not

46:23be available in that base image. And

46:25then you can also specify a group or

46:27labels which you can apply to different

46:29runner types which allow you to control

46:30things like how many CPU cores or how

46:32much memory a particular runner might

46:34have. In the thirdparty hosted runner

46:35section, you can see a few examples from

46:37namespace. When you first create a

46:38namespace account, you'll have something

46:40like namespace profile default

46:41available. You can also append the ARM

46:4364 string to the end to get an ARM

46:46instance or you can provide your own

46:48custom image where you can install

46:50dependencies ahead of time to provide

46:52exactly the set of dependencies your

46:53build needs without the need to install

46:55or modify them with each run. Now,

46:57finally, for a self-hosted runner, you

46:59can name those runners anything you

47:00want. And so here, as an example, I've

47:02just named it my self-hosted runner

47:03name. I'm not going to do a deep dive on

47:05self-hosted runners. That could be a

47:06video in and of itself, and perhaps I'll

47:08do that. But within this course, you see

47:10me use a lot of GitHub hosted runners.

47:11And moving forward, you'll see me use

47:13those namespace runners quite a bit.

47:14Let's go ahead and jump over to GitHub

47:16and execute this workflow. I named this

47:18workflow runner types. We can click run.

47:21I have these three jobs at the top which

47:24are executing on GitHub infrastructure.

47:25I've got this one which is running on a

47:27GitHub virtual machine but inside of an

47:29Alpine container. And then finally, I've

47:31got this namespace VM. I'll show you. So

47:32if I go into my namespace account by

47:34logging in and go to profiles, this

47:37default one is provided out of the box

47:39as well as this ARM 64 image. Now these

47:42were two that I created custom with

47:43various dependencies pre-installed or

47:45with particular cache configurations

47:47enabled. To use this default runner, I

47:49just specify namespace profile default

47:52as my runs on field. If we click into

47:54one of these, we can validate that it is

47:55running what we expect. You can see

47:57hello from Linux x64, hello from Mac OS

48:00ARM 64, and this one since it's running

48:02inside that container is hello from

48:03Linux x64, but it shows us the container

48:06name of Alpine Linux. And finally,

48:09clicking into our namespace job, we can

48:10see it's also an x64 Linux runner.

48:14However, it's specifying the runner type

48:15as NSC for namespace cloud runner and

48:18then that identifier string. Now, if we

48:20look at the different runner types

48:21available, I mentioned that one of the

48:23reasons you might choose a thirdparty

48:24option is because they provide less

48:26expensive, more capable runners. As we

48:28can see here, this is a benchmark

48:30performed by runs on across many of the

48:32different providers, including GitHub,

48:34third party or self-hosted via runs on.

48:37As you can see, for these x64 runners,

48:39the namespace runners are at the very

48:41top in terms of performance and are also

48:43only half the cost of the hosted GitHub

48:45ones. I also added a circle for good

48:47developer experience because namespace

48:48provides some really nice features

48:49around developer experience just for

48:51using their runners. If we click into my

48:53namespace profile and I go to jobs, I

48:56can see all the jobs that have run. But

48:58if I go to insights now, I can start to

49:00see the execution timing for my job over

49:02time. I can filter down to a particular

49:04job and start to see how that

49:06performance is trending. If I click into

49:07a specific job, I can see CPU, memory,

49:10network, and storage performance within

49:12that execution as well as logs from that

49:14runner as it executed. And so I

49:15encourage you to check them out. There's

49:16a link in the description that will take

49:18you there. Definitely let them know that

49:19you found them through this video and

49:21say thank you for enabling me to create

49:22and distribute this content for free.

49:24Another feature that we haven't touched

49:25on yet is persisting data outside the

49:27context of their ephemeral runner. So by

49:29default, jobs are run in these ephemeral

49:31environments that are created and

49:33destroyed with the life cycle of the job

49:34itself. Artifacts give us a mechanism to

49:37persist data beyond that life cycle.

49:39There are two official actions provided

49:40by GitHub that we can use to both store

49:42and then consume artifacts. To upload

49:45artifacts, we use action/upload

49:47artifact. And to consume an artifact, we

49:49use the download artifact action. As you

49:51can see in my workflow here on the

49:53right, I'm going to create a text file.

49:55I then upload it as an artifact. And

49:58then in a downstream job, I'm able to

50:00download that text file and cat it out

50:01to the terminal. Artifacts also show up

50:03in the GitHub UI. So they can be useful

50:05for storing things like test results or

50:07other files that you might want to keep

50:09a record of across runs. We can jump

50:11over to GitHub and execute this. The

50:12workflow name is artifact.

50:15We've got our producing job and then our

50:17consuming job. So we took this string,

50:19stored it in an artifact.txt file,

50:23specified that is the file we wanted to

50:25upload. Then in our consumer, we

50:28specified which artifact we wanted to

50:30pull and then we catted that file to the

50:32terminal. If we go back to this workflow

50:34run, we can see in the UI, this is an

50:38artifact that was produced at runtime.

50:40We could download it. It's a zip file.

50:42If I open it up,

50:44here are the contents that we stored in

50:45that artifact. And this will persist

50:48such that if we needed to refer back to

50:49it for some reason, we could find the

50:51specific artifact associated with this

50:53workflow run in the future. Now, a

50:55similar but slightly different need to

50:57persist data across jobs is for caching.

50:59And so, again, jobs are ephemeral

51:01environments. So if we need to install

51:03or modify something within that job, we

51:05either need to do it every single time

51:07the job runs or we can use a cache to

51:09persist those data elsewhere and then

51:11restore them to avoid doing that

51:12repetitive work over and over. GitHub

51:14provides an official caching action

51:16action/cache. When you use that, data

51:19that you specify will be uploaded into a

51:21remote object storage that's going to be

51:22in Azure by default. And within GitHub,

51:25their cache is going to be capped at 10

51:26gigabytes. If you go over that 10

51:27gigabytes, it will evict stale data such

51:29that you and you will sort of have this

51:31rolling 10 GB window of your cache

51:33usage. As you can see from the workflow

51:35definition, we're going to start by

51:36attempting to restore the cache. If

51:38you've never run your workflow before,

51:40that would be a cash miss to start. Then

51:41on line 20, we can check if it was a hit

51:43or a miss. If it was a hit, then we can

51:45skip this step. If it was a miss, we

51:48will generate a file to then cache for

51:49future runs. We then have a step that

51:51says if it was a miss, we do want to

51:53populate that cache. And then finally,

51:55we're just outputting whether or not it

51:56was a hit and cadding that file to the

51:58Now, the types of things that you

51:59probably want to cache here, either

52:01things like your language runtime or

52:02tool chain that you'll need to build and

52:04execute your application or things like

52:06dependencies that you're downloading and

52:08including in your application builds.

52:09Let's go ahead and jump over to GitHub

52:11and see this workflow run. The workflow

52:12is named caching. We'll run it. Now, as

52:15you can see, this is the first time I've

52:17executed this workflow in a long time.

52:19So, any results that had been cached

52:20have been since evicted.

52:23It says cache not found.

52:26So we then created the file and stored

52:28it. Now if we go back and run it again,

52:31we should see a hit for that same key.

52:34Under the restore cache step, we see

52:36that it found our cache successfully.

52:38And then it was able to skip these two

52:39steps which which were conditional to

52:41only run if it was a cache miss. This

52:43showed how to cache specific paths or

52:45files within your runner. However, for

52:47many common languages and language tool

52:49chains, your cache configuration will

52:51depend on how those languages store

52:53dependencies. For example, when you've

52:55run the official setup node action, you

52:57can configure it to cache your npm

52:59dependencies as follows. For this

53:01example, I have just a super minimal

53:03Node.js project, which I can show in a

53:04second. We're running setup node with a

53:06specific version, and then we're

53:08configuring the cache of type npm and

53:10specifically telling it where within our

53:12repository the package.lock JSON file

53:15lives. This configures things so that we

53:16when we later run our npm install, we

53:18will automatically cache those node

53:20modules that get installed such that

53:22across runs as long as those

53:23dependencies don't change, we should see

53:25a significant speed up and avoid needing

53:27to redownload those with every

53:28execution. So here is the minimal node

53:30project within my package.json I just

53:32have a single dependency of leftpad and

53:35then my lock file similarly has that

53:36single dependency included. As you saw

53:38in the workflow from the slide, we're

53:40checking out the code. We're running

53:42setup node with our specific caching

53:43configuration. We're telling it where

53:45the lock file lives. That's how it

53:47generates an appropriate cache key

53:48before installing our dependencies and

53:50listing out the modules that have been

53:51installed. If we go back and look at

53:52those runs that we executed before, in

53:54the first run,

53:58there was no npm cache, but in the

54:01second run, we had a cache hit and we

54:05were able to download that dependency

54:07from our cache rather than needing to

54:08pull it from npm directly. So that's a

54:10node example. On the left you can see

54:12the documentation for how this would

54:14look with Go and each language is going

54:15to be slightly different in terms of

54:16where it stores its dependencies and how

54:18you should configure an appropriate

54:19cache key. Now caching is another thing

54:21that namespace does a bit differently.

54:23Uh so so far we've been using those

54:26object stored based caches where you're

54:28telling it a specific set of files and a

54:30specific cache key and it's a

54:32deterministic thing where you're going

54:33to upload those files and if you have a

54:35cache key hit you will pull them down at

54:36runtime. However, namespace takes a

54:38little bit different of an approach. You

54:39can configure what's called a cache

54:41volume and rather than upload and

54:43download explicitly from a object store.

54:46It's going to snapshot your volume after

54:48you've completed and then you can mount

54:50in a copy of that volume to each of your

54:52jobs to avoid that extra time of

54:54uploading and downloading these cached

54:56artifacts. It's going to be a bit less

54:57deterministic. However, because of the

54:59types of things that you're caching

55:00where you're often installing a set of

55:02dependencies over and over, usually that

55:04turns out fine. I created a simple

55:06example to sort of benchmark this where

55:09I have one job which creates a single

55:12very large file and uploads that to the

55:14GitHub actions cache as well as uses a

55:16namespace cache volume and then I have

55:18another job which generates thousands

55:21and thousands of very small files and

55:23does the same thing. As you can see,

55:25when you get a cache hit across them,

55:27the namespace cloud cache performs

55:29significantly better than the GitHub

55:30actions cache because you don't have to

55:32spend that time uploading and

55:34downloading those data. Because you're

55:35not specifying an explicit cache key,

55:38you could have a cache miss

55:39occasionally. But the penalty there is

55:41just a little bit of extra runtime.

55:42Whereas the benefit would be a

55:43significant speed up on nearly every

55:45single run. Let's take a look at how

55:46this workflow is defined. Here I'm using

55:48a set of tags that will allow me to get

55:51a namespace runner with the appropriate

55:53image type using four CPU and 16 GB of

55:56memory. And then I'm specifying the

55:58cache volume characteristics that I

56:01want. Here it's 20 GB. And I'm using

56:03this as sort of a unique volume type to

56:05separate my large test from my many

56:08test. I run the provided cache action

56:10step from namespace to set up my cache

56:13volume. I then check if the file exists

56:15that I'm looking for. If it doesn't

56:17exist, I generate a single large file

56:20and then I validate that the file exists

56:22afterwards. So if it was a cache hit,

56:25this should return true. If it was not a

56:26cache hit, it will get generated above.

56:28Now for the many test, it's similar. The

56:31only difference is that I use this small

56:33count environment variable specified

56:34above. So here I'm generating 100,000

56:36files, each of size 4,96 bytes, and then

56:41validating that those exist. The GitHub

56:43actions versions are pretty much

56:45identical. The only difference being

56:46that rather than using the namespace

56:48caching, I'm using the official GitHub

56:50actions caching. We can look at my first

56:52run of this workflow here where all of

56:54these caches miss. And you can see the

56:57single large file takes about 10 and 20

56:59seconds respectively on namespace and

57:01GitHub actions. And then the version

57:04where we generated 100,000 files takes a

57:06minute 43 and 2 minutes and 11. A lot of

57:08that time is spent generating those

57:10files and then uploading them to uh that

57:12object store cache. In this case, when

57:14we look at the next execution of this

57:16workflow, in this case, we had a cache

57:19hit for all of them, which is great. But

57:20we see the namespace instances took 3

57:23and 4 seconds respectively, whereas the

57:25GitHub actions cache took 28 and 12

57:27seconds respectively. And so, as you

57:29think about caching, this is a really

57:30unique approach that namespace has taken

57:32here. It does require adding a step to

57:33your workflow to set up that cache.

57:35However, it can pay huge dividends in

57:37terms of not performing repeated work

57:39and avoiding that upload download time

57:42that you need to use an object

57:44storebased cache. Moving on from

57:45caching, another critical feature that

57:47GitHub provides is the ability to scope

57:50what permissions your workflow has to

57:52the GitHub API. And so, by default,

57:54you're going to have contents and

57:56packages readonly permissions. The

57:58contents permissions will allow you to

58:00pull the code itself, which is going to

58:01be key for doing anything with your

58:03application. And then the packages is

58:05for the GitHub package registry. Uh

58:07you'll be able to read from there. There

58:09are additional permissions associated

58:11with all the different types of entities

58:12shown here on the left. And you can have

58:14either read, write or none. And the

58:16default is to have none for all of these

58:18except for contents and packages. I've

58:19written a workflow on the right hand

58:21side to just showcase how this works. In

58:23our first job, we have specified that we

58:25only have read permissions for pull

58:27requests and nothing else. I then design

58:29my job to both list the PRs in the repo

58:32as well as attempt to make an edit to

58:34one of those PRs by adding a label. That

58:37first run on line 16 will succeed. The

58:40second command on line 23 will fail

58:42because we don't have that right access.

58:44Now in the second job of specifying that

58:45we do have right access for the pull

58:47requests. Running that same command,

58:49we're able to successfully run the ad

58:51label command which will apply a

58:52documentation label to that specific PR.

58:54And just as a detail, the GitHub CLI

58:57uses the environment variable GH token

59:00for its authentication. That's why I'm

59:01taking that GitHub token provided by our

59:04runner and storing it in that

59:05environment variable for those different

59:06steps. Let's go ahead and see this run.

59:08Now I'll start by removing this label.

59:14Now we can go into actions and

59:16permissions,

59:17run the workflow. We can see the

59:20readonly PR job failed as expected. We

59:22were able to successfully list the PRs,

59:25but when we attempted to add that label,

59:26we got an error that the resource was

59:28not accessible via the integration. If

59:30we go to the readr PR that had the

59:32appropriate permissions, now when we add

59:33that label, this command succeeded. If

59:36we go to our pull request, that

59:37documentation label has been added back.

59:39So this gives us fine grain control over

59:41what within GitHub our workflows will be

59:43able to execute. In general, you should

59:45always scope the permissions to the

59:46minimum set that are required. Most

59:48workflows are going to need contents

59:49read access. Some are going to need

59:51packages read access and only if you're

59:53taking these other actions against these

59:55other entities would you want to specify

59:56additional permissions. So if that

59:58feature allows us to control access to

1:00:00GitHub systems, how do we control access

1:00:02to thirdparty systems? There's two

1:00:04primary mechanisms where we can

1:00:05authenticate to a third party system.

1:00:07The first of which is a static

1:00:09credential. Think of this as like an API

1:00:10key. This would be a longive credential

1:00:12that you would store in one of those

1:00:13GitHub action secrets we talked about in

1:00:15the previous module. It is less secure

1:00:17because it's a long live credential and

1:00:19so if that were to leak, someone might

1:00:20be able to use it. However, it is

1:00:22compatible with pretty much every

1:00:23platform. Any software platform you're

1:00:25accessing over the web is likely going

1:00:26to have a way to provide an API key or a

1:00:29static credential. Now, a better, more

1:00:31secure approach is what's known as an

1:00:32OIDC token. OIDC stands for Open ID

1:00:35Connect, and it is a short-lived

1:00:37credential which you will retrieve at

1:00:38runtime. Because of the fact that it's

1:00:40shortlived, it's more secure. If it were

1:00:42to leak after the fact, it would no

1:00:43longer be active and wouldn't matter.

1:00:45However, it does require that thirdparty

1:00:46service to support OIDC in the first

1:00:48place. I'm going to use AWS as an

1:00:51example of how we can use both of these

1:00:53mechanisms to authenticate. Uh the first

1:00:55one is going to use a static credential.

1:00:56While this course isn't about AWS, I can

1:00:58show you how I could generate a static

1:00:59key for AWS quickly within the console.

1:01:02And then the second approach will use

1:01:03that OIDC approach, which is a little

1:01:04bit more complex to set up, but not too

1:01:06bad. And it's really the way you should

1:01:08be doing this in production systems. So

1:01:10here I'm logged into an AWS account um

1:01:12and I have a user called the GitHub

1:01:15actions user. This user doesn't have any

1:01:17permissions really. So I just want to be

1:01:18able to authenticate as them and verify

1:01:20that I am authenticated in that way.

1:01:22However, under security credentials, you

1:01:24can go here to access keys. You can do

1:01:26create access key. Uh you can specify

1:01:29what type it is etc.

1:01:32and say GitHub action static key. Create

1:01:37access key. You can then copy these and

1:01:40paste them into the secrets interface.

1:01:42So if we go here under settings,

1:01:45secrets and variables actions and then

1:01:47we have AWS access key and secret key.

1:01:50So you would take those values from here

1:01:51and here uh and paste them in.

1:01:54Then within our workflow, we can use the

1:01:57official AWS actions configure AS

1:01:59credentials action and pass it the

1:02:01access key and secret access key as well

1:02:03as the region we want to authenticate

1:02:04to. And this will authenticate against

1:02:06that user using that static key. I show

1:02:08you this for completeness but as I

1:02:10mentioned really if it is available you

1:02:12should be using the OIDC approach. Here

1:02:14are the documentations on setting up

1:02:15that open ID connect with AWS. So

1:02:18specifically you have to add the OIDC

1:02:20provider within the AWS IM

1:02:22configuration. We can click into these

1:02:23docs and follow these steps. The way

1:02:25that you do that is under IM identity

1:02:28providers. Click add provider

1:02:31open ID connect. It then gives you the

1:02:33provider URL and audience.

1:02:37This is the provider URL. This is the

1:02:39audience.

1:02:41We then need to specify an IM role that

1:02:43our GitHub action will be able to

1:02:44assume. So I can go back over here. I've

1:02:47already done this which is why I didn't

1:02:48create that. But you can see my provider

1:02:49here

1:02:51with my audience. Now under roles

1:02:55I have this GitHub actions role that

1:02:56I've created. And in order to assume

1:02:59this role, we need to add a trust

1:03:01relationship

1:03:03which says we are federating access to

1:03:06this role based on the OIDC provider.

1:03:08This is pointing to that provider that

1:03:10we just specified. I'm going to give it

1:03:11the ability to assume my role with the

1:03:14audience that I specified for that

1:03:15provider. And then finally, this is how

1:03:17I can lock down access to a specific

1:03:19repo or user. So in this case, only

1:03:21repos that I own will be able to access

1:03:24this role. And so with those things set

1:03:25up, the OIDC identity provider as well

1:03:27as this role with the appropriate trust

1:03:29policy, I can now run the same configure

1:03:31advis credentials action, but instead of

1:03:34needing to pass it that secret access

1:03:36key, I can just specify the role that I

1:03:38want to assume. There is one important

1:03:40note here and that is this 21. The ID

1:03:42token permission must be set to write in

1:03:45order for us to successfully request a

1:03:47JWT and use it for that OIDC

1:03:49authentication. And so it's not that

1:03:51much more complex, but it's much more

1:03:52secure. So if you're going to

1:03:54authenticate to a system which does have

1:03:56this support you should use the OIDC

1:03:57approach if not you can always fall back

1:03:59to a static credential as shown above.

1:04:01Let's go ahead and run this workflow

1:04:03just to showcase that it works. It's

1:04:04called third party off run workflow run.

1:04:10We successfully authenticated here and

1:04:13we successfully authenticated against

1:04:15our role here.

1:04:18Now the final feature set that I want to

1:04:20highlight in this section is matrix and

1:04:22conditionals. So uh matrix provides us

1:04:25the ability to run multiple copies of a

1:04:27job with different input configurations.

1:04:29And then conditionals allow us to

1:04:31specify an if statement that allows

1:04:33steps to either run or not run or jobs

1:04:35to run or not run based on the value of

1:04:38variables within our workflow. Now

1:04:39finally concurrency controls how

1:04:42multiple runs of the same workflow

1:04:44should be handled. And so if we're

1:04:45triggering the same workflow uh let's

1:04:46say based on multiple commits to our

1:04:48repo allows us to specify which of those

1:04:51runs should be grouped together and then

1:04:52within a group should you cancel those

1:04:54runs or allow them to continue

1:04:55executing. This can avoid wasted compute

1:04:58for runs which are meaningless if

1:04:59they've been superseded more recent

1:05:01code. To highlight these I created a

1:05:03simple workflow that has two jobs. The

1:05:05first of which is a matrix job. And so

1:05:07under that strategy matrix key we can

1:05:10provide a set of inputs. Here we have

1:05:12number one and two as well as letter

1:05:13ABC. And then GitHub actions will take

1:05:15all of those combinations. So we'll have

1:05:171 A, 1 B, 1 C, 2 A, 2 B, 2 C. And then

1:05:20finally we have this exclude key which

1:05:23says run all six of those combinations

1:05:24except don't run 1 C. So we should see

1:05:27five different jobs running in parallel

1:05:29executing those different combinations.

1:05:31To highlight the conditional aspect,

1:05:33also within step two, I've added this if

1:05:35statement saying if matrix number equals

1:05:372 and matrix letter equals C, skip that

1:05:40job. So we'll see five jobs running in

1:05:42parallel and within one of those jobs

1:05:44we'll see this step specifically skipped

1:05:46to highlight our concurrency setting

1:05:48I've grouped by GitHub workflow. So

1:05:50that's the workflow name as well as the

1:05:52GitHub ref. So that's going to be like

1:05:54my branch or my tag such that if we push

1:05:56to the same branch multiple times while

1:05:58the old job is running it will cancel

1:06:00that older job. This is why I've

1:06:02included that sleep job at the bottom so

1:06:03the execution takes long enough to give

1:06:05us time to come back issue a new commit

1:06:08and force this to cancel the old run. So

1:06:10let's go ahead and see this in action.

1:06:11We'll go to matrix and conditionals.

1:06:13We'll start by triggering one here. That

1:06:16first one is

1:06:18we see those five jobs executed 1 A, 1

1:06:20B. We skipped 1 C, remember? Now we have

1:06:232 A, 2 B, and 2 C. But if we click into

1:06:252C based on that conditional I had

1:06:27specified there, we're going to skip

1:06:29that specific step. Now let's take a

1:06:31look at how the concurrency works. So

1:06:33this one is still running because that

1:06:35sleep job is specified to sleep for 100

1:06:37seconds. We'll trigger another run of

1:06:38this workflow.

1:06:41And because that concurrency group is

1:06:43going to match because it's the same

1:06:44workflow and it's off of the main

1:06:46branch, it's waiting for GitHub to then

1:06:48cancel this older run. We see status

1:06:50canceled.

1:06:52And now the newer run will take over and

1:06:54execute. I kind of sped through these

1:06:56features, but these are really powerful

1:06:58features that allow us to control the

1:06:59execution of the workflow at runtime.

1:07:01And we're going to leverage these

1:07:02heavily as we move into the capstone

1:07:04project and start to do things within

1:07:06our monor repo to fan out jobs across

1:07:08the different services and only execute

1:07:09the jobs and steps which need to be run

1:07:11for each of them. Now, so far we've

Marketplace Actions

1:07:13mostly focused on our workflows and all

1:07:15the features that GitHub actions has for

1:07:17authoring those. Now, we're going to

1:07:18spend the next few modules looking at

1:07:19individual actions. So, if you think

1:07:21back to module 3 where I showcased the

1:07:23different types of steps you can write,

1:07:24a step can either be an inline bash

1:07:26script, an inline Python script, or it

1:07:29can be an action. And so an action is

1:07:30going to be a separate application which

1:07:33runs as a step within one of your

1:07:34workflows. One of the key features that

1:07:36differentiates GitHub actions from some

1:07:38of the other competitors is the huge

1:07:40public marketplace. And so in this

1:07:41module, we'll take a look at that public

1:07:42marketplace and how to utilize actions

1:07:44that exist there. If you go to

1:07:46github.com/marketplace

1:07:47and then search for type actions, you'll

1:07:50see a listing of all the public actions

1:07:51that are available there. You can then

1:07:53sort them by various features and search

1:07:55for the type of action that you want.

1:07:57I'll go to my browser and navigate there

1:07:58now. So, here I am on the GitHub actions

1:08:00marketplace. As you can see, there's

1:08:02lots of different actions available. If

1:08:03I scroll to the bottom, there's over 500

1:08:04pages of them. Uh, we've already used

1:08:06some of these. For example, the checkout

1:08:08action is the official action from

1:08:09GitHub to check out your code from your

1:08:11repository. To use any action within

1:08:13your step definition, you'll have the

1:08:15uses key and then a path to the repo

1:08:17where it lives. In this case, this is

1:08:19the actions organization and the

1:08:21checkout repo. So, while we can see it

1:08:23on the marketplace, we can also see the

1:08:25git repo associated with this. If we go

1:08:26down here and click view source code,

1:08:30you'll notice that because that was an

1:08:32official action from GitHub, it has this

1:08:34blue verified check mark. Let's imagine

1:08:36our project was using Cypress for

1:08:38endtoend tests. Let's search for

1:08:39Cypress. There's this action from the

1:08:41Cypress organization itself, as well as

1:08:43a number of other actions for doing

1:08:44various things with Cypress. Let's click

1:08:46in here. We can see they're going to set

1:08:48up things like installing our

1:08:49dependencies, handling caching. So

1:08:51rather than need to author all this

1:08:52ourselves, we could likely tap into this

1:08:54action, add it to our workflow with just

1:08:57a couple of lines, and get our Cypress

1:08:59test running within GitHub. Just that

1:09:01easy. Now, as I look at these third

1:09:03party actions, there's a number of

1:09:05signals I'm using to decide if I want to

1:09:07use it or not. In general, I'm going to

1:09:09assess it like any open source

1:09:10dependency that I might pull into my

1:09:12application. The number one criteria is,

1:09:14does this action do what I want it to

1:09:15do? Is it providing value to me? and is

1:09:18that value worthwhile enough for me to

1:09:20want to use it instead of authoring that

1:09:21capability myself. In terms of the

1:09:23quality and trustworthiness, there's a

1:09:24number of things you can look for.

1:09:25There's that blue check mark which means

1:09:27that GitHub has verified the identity of

1:09:29the organization or user who has

1:09:31authored that action. That can be one

1:09:33signal of a a more trustworthy action.

1:09:35Now, not every action that you want to

1:09:36use is going to be verified in this way,

1:09:38but that's just one signal you can use.

1:09:40The number of stars, just like an open

1:09:41source project, shows how popular a

1:09:43project is. The higher the number of

1:09:44stars, the more likely it is that lots

1:09:46of people are using and verifying the

1:09:48capabilities of that action as

1:09:50described. And then finally, is this an

1:09:51active project? If you look into the

1:09:53repo, as I showed you, as you click into

1:09:55that repo and look at the commit

1:09:57history, are there commits in the last

1:09:59week or few weeks or has it been

1:10:01stagnant for many years? I'm much more

1:10:02likely to use an action which has an

1:10:04active history and it seems that the

1:10:06authors are continuing to update and

1:10:08address potential issues as they may

1:10:09arise. Early in the course when I used

1:10:12thirdparty actions, you saw that I

1:10:13tagged it with the specific commit hash

1:10:16rather than a version. Now any of these

1:10:18four syntax will work. On line two, it

1:10:20would just use the latest version

1:10:21available. On line three, it would use

1:10:23the latest major version 4. Line four is

1:10:26going to use that specific git tag of

1:10:284.2.2. But then line five, I get a

1:10:30specific commit within that repo. This

1:10:33is important because these are third

1:10:34party dependencies. The only way within

1:10:37GitHub actions to ensure that you're

1:10:38running the exact same version is to pin

1:10:40to the commit hash. It may seem like

1:10:42pinning to a specific git tag is

1:10:43sufficient. However, there have been

1:10:45incidents where a malicious actor took

1:10:47over a repo where a GitHub action lived,

1:10:49wiped all of those git tags, and pushed

1:10:51a malicious commit, and then rettagged

1:10:54every single historical tag to that

1:10:55malicious commit to exfiltrate secrets.

1:10:58That's why I always recommend that you

1:10:59should pin to the specific commit hash

1:11:00because that can't be modified after the

1:11:02fact and so it guarantees you will keep

1:11:04that same version and you wouldn't be

1:11:05impacted by something like that. I also

1:11:07add a comment with the tag that that

1:11:10commit points to just to make it clear

1:11:11to future readers which version or

1:11:13release of that action is running. Now

1:11:15I'll quickly highlight a handful of

1:11:17popular actions that I have seen used

1:11:19broadly across the projects that I've

1:11:20worked on. As I mentioned, GitHub itself

1:11:22provides a number of official actions.

1:11:24These are things that most every repo is

1:11:26going to leverage. things like checking

1:11:27out your code, uh caching as we saw in

1:11:30the previous module, using that artifact

1:11:32upload and download to store artifacts

1:11:34from within a workflow run. And then

1:11:35this last one, GitHub script, provides

1:11:37an interface to very easily make calls

1:11:39to the GitHub API via action that takes

1:11:42as an input an an asynchronous

1:11:44JavaScript function. So if you need to

1:11:45do something uh to interact with some

1:11:47GitHub entity, like maybe add a comment

1:11:49to a PR based on the results of your

1:11:51workflow, the GitHub script action is

1:11:53one easy way to do that. Another

1:11:55category of actions that are very

1:11:57popular and are used in almost every

1:11:58project that I've worked on are actions

1:12:00to install the runtimes and dependencies

1:12:02that you may need for your continuous

1:12:04integration workflows. These are three

1:12:06examples which all come from the

1:12:08official GitHub organization. The first

1:12:10one is to set up Node.js. The second one

1:12:11to set up Go and the third one to set up

1:12:13the Java development kit. These are

1:12:15going to contain utilities for things

1:12:16like configuring caching appropriately,

1:12:19making sure that you have the right

1:12:20version of your language, and including

1:12:22the common software that you would need

1:12:23in that language tool chain. Another

1:12:24popular project is the super llinter.

1:12:26This is an application that has linting

1:12:28capabilities across a huge variety of

1:12:31different languages. So, you can use

1:12:32this single action to lint across any of

1:12:35the languages that you're using within

1:12:36your project, which can be quite

1:12:37convenient. And then just a few more

1:12:39here that I see used all the time are

1:12:40building and pushing your container

1:12:42images. There's an official action as

1:12:43well as one from Docker. This will

1:12:45include things like authenticating to

1:12:46your registry, passing it the

1:12:48appropriate tags and telling it where

1:12:51your build configurations live to then

1:12:53go off and build and push your container

1:12:55images. These also often times contain

1:12:57utilities for handling

1:12:59multi-architecture builds. And then

1:13:00finally, for authenticating third party

1:13:02systems, many organizations will provide

1:13:04an action that makes that much easier. I

1:13:06showcased this with AWS in the previous

1:13:08module, but Azure and GCP also provide

1:13:11their own authentication actions. And so

1:13:13rather than needing to download the

1:13:15appropriate CLI and manually pass in the

1:13:17credentials, these are configured to

1:13:19make that much easier to streamline your

1:13:21usage of those platforms from within a

1:13:23GitHub actions workflow. Throughout the

1:13:25rest of the course, you'll see me

1:13:26actually use a number of these actions

1:13:28as we build out the various workflows

1:13:29for our capstone project. In the

Authoring Actions

1:13:31previous module, we saw how to consume

1:13:33these marketplace actions. Now, let's

1:13:34take a look at how to author our own

1:13:36firstparty actions. There's a handful of

1:13:38mechanisms within GitHub actions that we

1:13:40can use to write and reuse our logic

1:13:42across workflows. The four that I'm

1:13:44going to be covering here are composite

1:13:45actions, reusable workflows, JavaScript

1:13:48and TypeScript actions, and then

1:13:50container actions, which actually allow

1:13:51you to write your actions in any

1:13:52language that you'd like. A composite

1:13:54action is the easiest way to bundle up

1:13:56logic that you want to reuse across

1:13:58multiple workflows or across different

1:13:59jobs within the same workflow. All you

1:14:01need to do to create a composite action

1:14:03is create an action.yaml file. This file

1:14:05is going to look very similar to the

1:14:07workflow files that you're used to,

1:14:08except it's going to optionally include

1:14:10a set of inputs, a set of outputs and

1:14:12then a list of all the steps that you

1:14:14want to execute within it. Once you have

1:14:15that composite action defined to consume

1:14:17it, you use the uses syntax and pass a

1:14:19relative path to that composite action

1:14:21within the repo. In this example, I have

1:14:23a very simple hello world composite

1:14:25action. It's going to do two things.

1:14:27It's going to take an input string of

1:14:28who to greet and print that out to the

1:14:30console. It's then also going to

1:14:32generate a random number and provide

1:14:33that as an output to the action. To

1:14:35showcase utilizing this, I've written a

1:14:37simple workflow on the right hand side

1:14:38which takes this composite action and

1:14:40consumes it in two separate jobs with

1:14:42slightly different input strings. And

1:14:43then we also echo out that random number

1:14:45just to showcase that they're going to

1:14:47be different. It's important to note

1:14:48that we do have the actions checkout

1:14:49step there on lines 10 and lines 23.

1:14:52This is what's going to pull down the

1:14:53source code where that action.l ammo

1:14:54lives and allows us to use it within our

1:14:56workflow. Let's go to GitHub and run

1:14:58this workflow to see it in action. I

1:14:59called my workflow composite action

1:15:00demo. Run workflow.

1:15:03We've got our two jobs. In our first

1:15:05job, we can see that we input who to

1:15:07greet as from composite action in hello

1:15:10world 1. And we got that echoed out to

1:15:11our terminal. And then we can see that

1:15:13the random number passed as an output

1:15:14from our composite action was 13829.

1:15:17If we go to the second job, we ran our

1:15:19composite action, but we passed a

1:15:21different input string with this hello

1:15:22world 2 that was echoed out to the

1:15:23terminal and our random number is

1:15:26different with 10,129.

1:15:29Now, in the repo for the course, I

1:15:30stored the action in the directory

1:15:32associated with this module. Normally

1:15:33though, I would store actions like this

1:15:35under the.github/actions

1:15:37directory, which is a nice convention so

1:15:38that you can go there and find all the

1:15:40actions associated with a particular

1:15:41repo. Now, the next mechanism for

1:15:43reusing logic across workflows is what's

1:15:45known as a reusable workflow. Now

1:15:47technically this isn't a GitHub action.

1:15:48It is a workflow and it runs as a job

1:15:50within another workflow rather than as a

1:15:52step. However, the reason you use a

1:15:54reusable workflow to take logic that you

1:15:56want to share across different

1:15:57executions of a workflow is quite

1:15:58similar in nature to these others and

1:16:00which is why I included it in this

1:16:01section. On the right hand side, you can

1:16:02see I've defined the source for this

1:16:04reusable workflow. This is going to use

1:16:05the same approach as all the other

1:16:06workflows we've seen thus far. However,

1:16:08the key distinction here is on line four

1:16:10and it uses that workflow call trigger

1:16:12which is what indicates that it is a

1:16:13reusable workflow. is going to be called

1:16:16from another workflow. Like our

1:16:17composite action, we're going to

1:16:18optionally have a set of inputs and then

1:16:20there on line 13, we can define which

1:16:22secrets need to be passed into our

1:16:23workflow. It is important to note you

1:16:24can only pass repository or or level

1:16:27secrets in this fashion. If you want to

1:16:29access an environment secret on the

1:16:30consumer side, you need to specify that

1:16:32environment and then also indicate that

1:16:34you want to inherit those secrets. An

1:16:36example of this is in our caller

1:16:38workflow there on the left. So this

1:16:40caller workflow is going to execute two

1:16:42copies of this source workflow. The

1:16:44first one we're running with that

1:16:45staging environment and we're telling it

1:16:47that we want to inherit the secrets from

1:16:49our environment. And in the second one,

1:16:51because we haven't specified that we'd

1:16:52like to inherit, we will not pick up any

1:16:54secrets from the production environment

1:16:56and we'll only have access to that

1:16:57explicit example repository secret that

1:16:59we're passing directly. There's also two

1:17:01ways to reference these. On line 9, this

1:17:03is similar to with the composite action

1:17:05where we have a relative path to the

1:17:06workflow in our repo. This will follow

1:17:08along. We'll use the current commit

1:17:10within a repository as the source

1:17:12definition. Whereas on line 17, we're

1:17:14passing it an explicit git reference to

1:17:16the repo and the commit hash, which

1:17:19allows us to pin it to this particular

1:17:20version and have more control over when

1:17:22it upgrades. Let's go to GitHub and run

1:17:24a copy of this caller workflow. Even

1:17:26though both of these are workflows, we

1:17:27wouldn't call that source workflow

1:17:29directly. Instead, we want to run the

1:17:30caller workflow, which will run our

1:17:32source workflow as jobs within it. So,

1:17:35I'll navigate to reusable workflow

1:17:36caller. Run workflow.

1:17:39We get the two copies of our job.

1:17:42I'll click into the staging one and

1:17:44remember in this case I told it to

1:17:45inherit the secrets that are available

1:17:47and so this example environment secret

1:17:49is defined although it's masked if we

1:17:51click into our production environment

1:17:53job in that one I did not tell it to

1:17:55inherit the secrets and so by default it

1:17:57will not inherit my environment secrets

1:17:59the repository secret is available there

1:18:01because I passed it explicitly so when

1:18:03would I want to use just a normal

1:18:05standard workflow versus a composite

1:18:07action versus a reusable workflow I made

1:18:10this little flowchart to showcase how I

1:18:12think about these things. The first

1:18:14question I would ask is, do you need to

1:18:16reuse this logic across multiple repos

1:18:18and or workflows? If the answer is no,

1:18:19you can just use a standard workflow.

1:18:21There's no benefit to moving this logic

1:18:23into one of these other approaches. If

1:18:25the answer is yes, then you need to

1:18:26further ask yourself, do you need to

1:18:28enforce standardization across the

1:18:30entire workflow itself? If the answer is

1:18:32yes, you want to go with a reusable

1:18:33workflow where you define that entire

1:18:35workflow and the only modifications are

1:18:37going to be the input values. If you

1:18:39don't need to enforce that level of

1:18:40standardization, a composite action is a

1:18:43slightly lighter weight and more

1:18:44flexible approach where you can bundle

1:18:45up just a subset of the logic while

1:18:48still avoiding copy pasting across a

1:18:49bunch of different places. As you think

1:18:51about your organization's needs and

1:18:53specific continuous integration

1:18:54workflows, hopefully this helps you

1:18:55decide what logic should live where.

1:18:57Moving on to the third type of action,

1:18:59we have JavaScript and TypeScript

1:19:00actions. Now, this is the type that

1:19:01GitHub action supports most natively and

1:19:04many of the actions that you encounter

1:19:05on the marketplace or otherwise will be

1:19:07of this type. GitHub provides an npm

1:19:09package called actions core that you

1:19:11will install as a dependency into a

1:19:12JavaScript or TypeScript project. You

1:19:14can then import that and that will

1:19:15contain many different utilities for

1:19:17interacting with GitHub and GitHub

1:19:19actions. Just like with the composite

1:19:20action, you'll include an action.yml

1:19:22file that defines the metadata for your

1:19:24action including optional inputs and

1:19:26outputs. And then you'll notice at the

1:19:27bottom there in the under the runs key,

1:19:29you'll specify which runtime to utilize.

1:19:32In this case, it will be a Node.js

1:19:33runtime. And then you need to specify

1:19:35where your main function lives. Now,

1:19:36regardless of whether you're authoring

1:19:37your action in JavaScript or TypeScript,

1:19:39you must package it as JavaScript. This

1:19:41means that if you're writing as

1:19:42TypeScript, you're going to have a build

1:19:43step where you turn that TypeScript and

1:19:45bundle it as JavaScript for consumption.

1:19:47GitHub provides a template repo for both

1:19:49the JavaScript action as well as a

1:19:51TypeScript action. I've taken both of

1:19:52these and added them as subm modules to

1:19:54the course repo to showcase how they

1:19:56work. There's a third option of writing

1:19:57a JavaScript action with no build step.

1:20:00You check in all your dependencies as

1:20:01node modules, but I wouldn't suggest

1:20:02that. I would generally recommend going

1:20:04with a TypeScript action which is going

1:20:05to have that build step which is going

1:20:07to transpile your TypeScript into

1:20:08JavaScript and bundle all those

1:20:10dependencies alongside your action code.

1:20:13This will be more reliable and allows

1:20:14you to utilize types to make it easier

1:20:16to maintain and validate your action as

1:20:18you develop it. I'll jump to my code

1:20:19editor and show you what this looks like

1:20:21under module 6 JavaScript actions

1:20:23TypeScript action with build. I've added

1:20:25a copy of that template repo that I was

1:20:27just showing as a subm module to the

1:20:28course repo. We can take a look at the

1:20:30action.yaml YAML which is just like I

1:20:31showed on the slide. Here we're running

1:20:33NodeJS20 and my main function lives in

1:20:35this distindex.js.

1:20:37Now you'll notice I have my source files

1:20:39here written in TypeScript. The majority

1:20:40of my logic lives in this main.ts which

1:20:43is exported and run from within my

1:20:44index.ts and my build step is defined

1:20:48within my package.json. So under

1:20:50scripts, these are all scripts that were

1:20:51provided in that template repo. The

1:20:53build step is defined here in this

1:20:54package command which is going to use a

1:20:56tool called rollup to take my TypeScript

1:20:58and all the dependencies associated with

1:20:59my project and build them according to

1:21:01my configuration defined here. It's

1:21:04taking as my primary input that index.ts

1:21:06file and then providing as my output in

1:21:09the disk directory an index.js file with

1:21:11a source map using ES modules. Let's

1:21:13take a look at this main function and

1:21:15maybe make a slight tweak to show you

1:21:16what the process. As I noted, we're

1:21:17importing this actions core npm package

1:21:20from GitHub with methods like

1:21:21core.debug. So this is a debug log and

1:21:23core setoutput where we can set the

1:21:25output of our action step. Let's add

1:21:27another log here.

1:21:29It says hello this is another info level

1:21:31log. I'll save it. However, this only

1:21:33exists in the TypeScript file currently.

1:21:35Let's go ahead and bundle our code so

1:21:37that will get propagated into the

1:21:39necessary JavaScript. For that I can do

1:21:40npm run bundle.

1:21:43If I look at the git diff, here's the

1:21:45change I just made to the TypeScript

1:21:46file. But then because we bundled it, we

1:21:48now have a modification to our generated

1:21:50JavaScript file and to the source map. I

1:21:52can then commit these to the repo.

1:21:55Then update the subm module reference to

1:21:57be for that latest commit.

1:22:00And now let's go ahead and run that. Now

1:22:02this JavaScript actions workflow

1:22:04contains three jobs. One using a no

1:22:05build JavaScript action which exists in

1:22:07the repo. You can check it out, but as I

1:22:08mentioned, I wouldn't use that approach.

1:22:10Another one using a JavaScript action

1:22:12that does have a build step. And then

1:22:13thirdly, the TypeScript action which is

1:22:15referencing the project which I just

1:22:16modified. And so this is the one that

1:22:17we're going to want to look at. We go to

1:22:18actions, JavaScript actions, run

1:22:21workflow.

1:22:23That info level log line that we added

1:22:25now shows up here. Now, the last type of

1:22:26action that I'll cover here is a

1:22:28containerbased action. Because a

1:22:29container can leverage whatever runtime

1:22:31or language we'd like. So if our team is

1:22:33a bunch of engineers who are experts in

1:22:35Python, but don't really write

1:22:36TypeScript ever, we may want to author

1:22:38our actions accordingly. There also

1:22:39could be dependencies or packages that

1:22:41we want to pull in to perform some type

1:22:43of work that have a much stronger

1:22:44ecosystem within a particular language.

1:22:46Here within the action.yamel you can see

1:22:48on line 12 rather than using the Node.js

1:22:49runtime we're specifying that it's using

1:22:51Docker. And then for the image you can

1:22:53either specify Docker file which will

1:22:54build the Docker file each run or you

1:22:56can specify container image at a

1:22:58particular tag. Option A is great for

1:22:59testing and validating because it will

1:23:01dynamically build the current state of

1:23:02the repo. However, needing to rebuild

1:23:04the action image with each build is a

1:23:06bunch of wasted effort and time for

1:23:09production workflow that you care about

1:23:10speed and performance. You'll likely

1:23:12want to have a process by which you

1:23:13build that container image and push it

1:23:15to your registry and reference it

1:23:16directly. The process by which we

1:23:18extract things like inputs into a

1:23:19container image is a little bit clunky.

1:23:21As you see here, this action has the

1:23:22input who greet which is going to be a

1:23:24string. And within Python, I either need

1:23:27to load it from this badic environment

1:23:28variable that GitHub is populating,

1:23:30which would be input underscore and then

1:23:31the name of the input in all caps snake

1:23:33case or I can pull it out of the args

1:23:35list using something like cis.orgv.

1:23:37Also, there's no official GitHub package

1:23:40for languages other than JavaScript. So,

1:23:42in order to interact with GitHub

1:23:44actions, I end up having to print these

1:23:46magic strings to console using things

1:23:48like colon. Notice they're on line 18

1:23:50rather than being able to use a specific

1:23:52method from that core package that we

1:23:54had in JavaScript. To write to an output

1:23:56is shown here on line 21 where I need to

1:23:58open the file that is at that GitHub

1:24:00output path and then append any

1:24:02information to it similar to how we did

1:24:04before when writing to it in bash. Now,

1:24:06finally, I need to bundle this up in a

1:24:07Docker file, which will vary based on

1:24:09the language that I'm writing my action

1:24:10in. In this case, because I don't have

1:24:12any dependencies, I'm simply starting

1:24:14from the Python base image, copying in

1:24:16my entry point Python script, and then

1:24:18setting the entry point that will run

1:24:20when the container is executed. If I did

1:24:21have additional dependencies, this

1:24:23Docker file would need to account for

1:24:24that by doing things like copying in a

1:24:25requirements file, running a pip install

1:24:27or whatever other package manager I

1:24:29might be using like poetry, etc. Now

1:24:31within the repo under module 6 container

1:24:34actions I have three container actions

1:24:36showed here. One is based on the

1:24:38template that that GitHub provides for a

1:24:40container action. Now this action is

1:24:42just using a bash script bundled up in a

1:24:44docker file. It's a little bit

1:24:45contrived. If you were just going to use

1:24:47bash, you would likely put that directly

1:24:49in your workflow. However, it shows how

1:24:50you can wrap things up inside a

1:24:52container image. I then also have the

1:24:53Python container that I was showing on

1:24:54the slide. In order to showcase these, I

1:24:56wrote a simple workflow with three jobs.

1:24:58The first one is calling that shellbased

1:25:00container action that is a subm module

1:25:01of this repo. You can see here I'm

1:25:03referencing that path. The second one is

1:25:05running a nearly identical copy of that

1:25:07shellbased container action. However,

1:25:08it's referencing a pre-built image. So

1:25:10if we look at the make file here, rather

1:25:12than image docker file, it's referencing

1:25:15this public container image sid palace

1:25:17shell container action add a specific

1:25:19version which will avoid the need to

1:25:20build that image with each execution. In

1:25:22order to build and push that container

1:25:24image, I just have this simple make

1:25:25file. I'm just running a docker build

1:25:27and a docker push to DockerHub. Now the

1:25:29third job is executing that Python based

1:25:31container image also using the Docker

1:25:33file approach. So it's going to build it

1:25:35dynamically. But we should see all three

1:25:37of these run and despite their different

1:25:38configurations basically the same way.

1:25:40So I can go to actions container actions

1:25:43run workflow.

1:25:47The instance of our shell container

1:25:48where we use that public container image

1:25:50is done after 5 seconds. Whereas the one

1:25:52where we had to build the container

1:25:53image first took nearly double that. If

1:25:55I click in here, we can see the culprit

1:25:57and that's the step where it's having to

1:25:58build that container image rather than

1:25:59just pulling the pre-built one. The

1:26:01behavior other than that though is

1:26:03identical. I mentioned the shortcoming

1:26:05of needing to use these magic strings to

1:26:07interact with GitHub and do things like

1:26:09print out that notice message. The name

1:26:11for this within GitHub actions is

1:26:13workflow command. There's specific

1:26:14formatting of these magic strings that

1:26:16enable you to communicate with the

1:26:17GitHub action runner. In general, a

1:26:20string starting with double colon and

1:26:21then a specific word has meaning

1:26:23according to this listing on the right.

1:26:25Within a JavaScript or TypeScript

1:26:26action, we leverage that core npm

1:26:28package to utilize these. So core error

1:26:31missing semicolon is equivalent to

1:26:34echoing colon error space and then a

1:26:37bunch of commaepparated key value pairs

1:26:39will behave the same. But as you can

1:26:41imagine, the JavaScript version is a bit

1:26:43easier to read and maintain. These will

1:26:44both result in that annotation shown

1:26:46down below where you get an error

1:26:48specifically for the file and line

1:26:49specified. As you can see on the right,

1:26:51there's a number of different commands

1:26:52you can utilize. Some of which use those

1:26:54magic strings, other of which like

1:26:56setting the output you have to use via

1:26:58that GitHub output environment. As you

1:27:00author an action, the ability to control

1:27:01the workflow's behavior in this way can

1:27:03be very important to do things like

1:27:05raise errors or modify the runner state

1:27:07as the workflow progresses. This module

1:27:08speed ran a ton of different ways for

1:27:10which you can write and configure the

1:27:12logic within the steps of your GitHub

1:27:14actions workflows. I wanted to provide a

1:27:16little bit of a framework for how I

1:27:17think about when to use each of these

1:27:19different tools. I start with the

1:27:20question, was I able to find a public

1:27:22action, a reputable public action in

1:27:25that marketplace that does exactly what

1:27:27I want? If the answer is yes, I'm going

1:27:28to go with that. If the answer is no,

1:27:30now I have to think about how to handle

1:27:31that logic myself. If it's simple

1:27:33enough, maybe a single Unix command or

1:27:35or a couple strung together with some

1:27:36pipes, then I maybe just put it in the

1:27:38workflow as an inline shell script. If

1:27:40the answer's no, and I start to need a

1:27:42little bit more logic, then I start to

1:27:43think about is bash still the right

1:27:45language to handle this, or do I need

1:27:47more expressivity with a different more

1:27:49general purpose programming language? If

1:27:51I don't need that additional

1:27:52expressivity and bash is the right tool,

1:27:54I'll usually pull that logic out of the

1:27:56YAML file into something like a make

1:27:58file or a task file, essentially a task

1:28:00runner where I can store snippets of

1:28:02logic that I want to reuse across

1:28:04different workflows. This makes it much

1:28:06easier to work with and debug in the

1:28:08developer experience section. I'll show

1:28:09you how how I do this. But if I conclude

1:28:11that bash is not the right tool for the

1:28:13job, I start to have nested for loops

1:28:15and lots of conditionals. Then I move

1:28:17down that next branch over to the left

1:28:19and I start to think about does my does

1:28:22the task which I'm trying to accomplish

1:28:24uh require external dependencies. So

1:28:25third party packages that I'm going to

1:28:27pull in that are not part of the

1:28:29standard library of a particular

1:28:30language. If I don't need additional

1:28:32dependencies, maybe I just put an inreo

1:28:34Python script that I call out to from my

1:28:37workflow. However, if I do need

1:28:39additional dependencies, now I'm

1:28:40starting to think about, oh, I need to

1:28:42have a packaging step where I can pull

1:28:43those dependencies in and get them into

1:28:45the runtime environment. Now it becomes

1:28:46a choice of whether I want to use a

1:28:48JavaScript TypeScript action or a

1:28:50containerbased action. As I showed the

1:28:51JavaScript and TypeScript actions have a

1:28:53little bit better support I would say on

1:28:55the GitHub action side. You get that

1:28:56official npm package for executing the

1:28:58workflow commands. There's going to be

1:29:00many more examples out there in the wild

1:29:01of people utilizing this approach. But

1:29:03if you really have a strong conviction

1:29:04that you want to use a particular

1:29:06language that's going to drive you down

1:29:07that path of using a containerbased

1:29:08action. I often find that most of the

1:29:10time you can get quite far by using a

1:29:13combination of those marketplace actions

1:29:15for things where they exist and then

1:29:16keeping most of your logic in bash but

1:29:18outside of the workflow files. So in

1:29:20bash but maybe in a task file or

1:29:22whatever build tool of your choice is

1:29:24eventually you may reach a point where a

1:29:25particular use case requires much more

1:29:27complex interactions. That's where you

1:29:29finally move into a a fully featured

1:29:31action that you're going to build and

1:29:32maintain as a dedicated piece of

1:29:34software. Final pieces to cover about

1:29:36actions or how we publish these to the

1:29:38marketplace and how we can consume

1:29:40actions without publish publishing

1:29:41actions is super simple. Effectively, if

1:29:43you have a public repo containing an

1:29:45action.yaml in at the top level, then

1:29:47when you go to the GitHub release UI,

1:29:49there's going to be an option to publish

1:29:50it to the marketplace. So, for example,

1:29:52if I go to my TypeScript action repo

1:29:54here, I get a popup saying I can publish

1:29:56this action to GitHub action

1:29:57marketplace. If I draft a release, it's

1:29:59going to give me a checklist of do I

1:30:01have all the metadata that is required

1:30:03within my action. Do I have a readme?

1:30:05What category would I want to put this

1:30:06in, etc. So, this is just like making a

1:30:08normal GitHub release, but I get this

1:30:10additional information here because it

1:30:12has detected my action.yamel at the root

1:30:14of the repository. The flip side of that

1:30:15coin is if you are within an

1:30:16organization that builds closed source

1:30:18software, you likely won't want to

1:30:20publish your action, but you may want to

1:30:21consume it from a different repo. If

1:30:23your action source code is within the

1:30:25same repo as the one where your workflow

1:30:27lives, you can use it without any

1:30:28additional configuration. You just need

1:30:29to make sure you run the checkout action

1:30:31first. However, for a private repo, you

1:30:33must navigate to the place where your

1:30:34source repos is defined under settings,

1:30:36actions, access, and explicitly allow

1:30:39other repos in the same org to utilize

1:30:42it. It's important to note this only

1:30:43works if the source and the consumer are

1:30:45both private. You can't consume an

1:30:47action defined in a private repo from a

1:30:48repo which is public. Once you've set up

1:30:50access in this way, you can reference

1:30:52the private repo in the same way that

1:30:54you would a public one as shown on the

1:30:56right there in the middle where I have a

1:30:57repo named private repo with GitHub

1:30:59action source. the path to the action

1:31:00within that repo and then my git commit

1:31:02hash. To test this, I created these two

1:31:04private repos. This first one contains

1:31:06the source for my action. It's just a

1:31:08composite action that echoes hello

1:31:10world. And then on my consumer side,

1:31:11within the settings for this repo, if I

1:31:13go under actions, general access here at

1:31:17the bottom, I toggled this to be

1:31:19accessible from other reposed by me.

1:31:21Then within my consumer, I've defined my

1:31:23workflow which references that private

1:31:25repo. I can go to actions, consume

1:31:27action from private repo, run workflow,

1:31:30and trigger it. It was able to run

1:31:32successfully despite my source repo

1:31:34being private. Now that we have an

Common Workflows

1:31:36understanding of all the different

1:31:37features that GitHubs action provides,

1:31:39let's go through some common workflows

1:31:41that you might implement for a project

1:31:42you're working on. Now, I showed the

1:31:43same list earlier, I'm going to walk

1:31:45through each of these different use

1:31:46cases and talk through what a basic

1:31:48implementation might look like, which

1:31:50would provide a starting point for you

1:31:51to then go off and implement. If we

1:31:53start with something like linting, a

1:31:54naive approach might be to have one step

1:31:56which checks out our code, one step

1:31:58which installs the linting tool of

1:31:59choice, and then a third step which

1:32:01executes that llinter. How can we

1:32:02improve this workflow? We can start to

1:32:04optimize it by by adding a step to

1:32:06determine which files are changed and

1:32:08then only running the llinter on those

1:32:10files. We also could improve this by

1:32:12reporting the status back on the

1:32:13workflow itself or if we were running

1:32:15this against a PR, maybe as a status or

1:32:17comment associated with that pull

1:32:18request. Now, we could go off and

1:32:20implement all this ourselves. However,

1:32:22we can actually leverage a marketplace

1:32:24action to handle almost all of this for

1:32:26us. I mentioned this at one point

1:32:27earlier in the course, but there's a

1:32:28tool called the superlinter and an

1:32:29associated GitHub action which handles

1:32:32all of these last four steps, allowing

1:32:34us to achieve linting across a variety

1:32:35of languages in an optimized fashion

1:32:37with just a few lines of code. Here's

1:32:39the marketplace page for that super

1:32:41action. If we scroll down, we can see

1:32:43there's lots of configuration that you

1:32:44can do. However, in its most basic form,

1:32:47you simply check out the code and

1:32:48because it's going to perform that

1:32:49optimization step of deciding which

1:32:51files have changed, we use fetch step

1:32:53zero to ensure the full history of

1:32:54commits is checked out. And then we can

1:32:56run the superlinter action, passing it a

1:32:58GitHub token to enable it to set those

1:33:01GitHub action status checks that will

1:33:02show up on the PR. And just like that,

1:33:04with those four lines of code, we've now

1:33:06added linting across a variety of

1:33:07languages to our project. Now, what

1:33:09about testing? A naive approach to this

1:33:11might be to check out the code, install

1:33:13whatever language tool chain we're

1:33:14using, download third party dependencies

1:33:16for our application, build the

1:33:18application, and then run our tests.

1:33:20Seems pretty straightforward. However,

1:33:22we can optimize this quite a bit. We

1:33:24want to set up caching and restore from

1:33:26that cache if we have a hit. We may want

1:33:29to upload our test results as an

1:33:30artifact to refer back to in the future.

1:33:33And we'd like to report our status back

1:33:34associated with that workflow. Again, we

1:33:37could implement all this ourselves or we

1:33:39can leverage public marketplace actions

1:33:41to do a lot of this for us. For example,

1:33:43the official setup node action is going

1:33:45to handle installing the language tool

1:33:47chain as well as setting up and storing

1:33:49our dependencies from the cache. Now,

1:33:51for the remainder of these workflows,

1:33:52I'm not going to show the optimization

1:33:54for each one. I'll leave that as an

1:33:55exercise for the viewer. However, I'll

1:33:57talk through what I would think is a a

1:33:59baseline naive implementation that then

1:34:01you can think about how to extend and

1:34:02improve upon moving forward. Like with

1:34:05linting, for any type of static

1:34:06analysis, generally we're going to need

1:34:07the code. So we'll check it out. We'll

1:34:09need to install whatever tool we're

1:34:10going to run. And then we'd like to run

1:34:11that analysis tool. For building our

1:34:13codebase, it looks similar to testing

1:34:15where we need to check out the code,

1:34:17install our language tool chain,

1:34:18download any third party dependencies,

1:34:20build the application, and then publish

1:34:22that built executable to whatever

1:34:23registry we plan to use. For container

1:34:25image, rather than installing our

1:34:27language tool chain, we're actually

1:34:28going to configure Docker or container

1:34:30builder of our choice to then build that

1:34:32container image and push it to some

1:34:34registry. If we think about deploying

1:34:35our code, this is generally going to

1:34:37start from the end of that build step.

1:34:39So we've already built our application

1:34:41either as some binary or maybe a

1:34:43container image. We then want to trigger

1:34:44a workflow to deploy our code. One

1:34:46option is to use a pushbased deploy from

1:34:48a workflow. In this case, we're going to

1:34:50check out our code to get whatever

1:34:52deployment configurations we need that

1:34:53live there. We'll install some tooling,

1:34:55perhaps cubectl or Helm if we're

1:34:58deploying into Kubernetes or perhaps the

1:34:59AWS CLI if we're deploying directly into

1:35:01an AWS service. We'll need to

1:35:03authenticate to the deployment

1:35:05environment. If we're deploying into

1:35:06AWS, we can use that OIDC type workflow

1:35:08or we may need to use a static

1:35:09credential. And finally, we'll want to

1:35:11validate the deployment is behaving as

1:35:13expected. The other type of deployment

1:35:16that is quite common these days is to

1:35:17use a GitOps approach. And so in this

1:35:19case, rather than authenticating and

1:35:20deploying directly into some

1:35:21environment, instead we're going to

1:35:23modify some configuration that lives in

1:35:25git and allow an agent running in the

1:35:27cluster to handle the deploy step.

1:35:29Again, this type of workflow would be

1:35:30kicked off at the end of some sort of

1:35:32build step. We want to check out the

1:35:34necessary configurations, update the

1:35:36version references in the git repo where

1:35:38the deployment manifests live, commit

1:35:40those changes to the repo, and then from

1:35:42there, we'll allow the GitOps agent

1:35:44living in our deployment environment to

1:35:46take over and pull those changes in.

1:35:48Moving on to the repo automations

1:35:49category. If we think about how we might

1:35:51want to automate releases within our

1:35:54repository, there's a variety of things

1:35:56we'll need to do. First, we need to

1:35:57check out the code. Then, we need to

1:35:59determine what changed between this

1:36:00commit and the last. Then we need to

1:36:02determine what type of update it is,

1:36:04whether it's a major, minor, or patch

1:36:06version. Oftentimes I'll use a git

1:36:08commit convention called conventional

1:36:10commits where you prefix your commit

1:36:12messages with either chore or bug or fix

1:36:14or feat. And you can use that to then

1:36:16determine whether any particular code

1:36:18change represents a major, minor, or

1:36:21patch version when releasing. Once

1:36:24you've determined the new versions,

1:36:25you'll want to update any references

1:36:26within your codebase. So for example,

1:36:28within your package.json, JSON, you

1:36:29might store a copy of your version or

1:36:31you might need to update the change log.

1:36:33And then finally, you'll want to commit

1:36:34those changes back to the repo. In the

1:36:37Capstone project, I'm going to use a

1:36:38tool and an associated action called

1:36:40release please to do exactly this.

1:36:42Another common repo automation is to

1:36:45determine if there are any stale issues

1:36:47or PRs on the repo. And so for this, you

1:36:49would want to retrieve all the issues

1:36:51and PRs, check if any are stale, if they

1:36:53are, mark them as such, and then finally

1:36:56close any that have been stale for too

1:36:57long. This is another one where there's

1:36:59a public marketplace action that can

1:37:00handle almost all of this very quickly

1:37:02and will do so in the capstone project.

1:37:04The last one that I'll highlight here is

1:37:06upgrading dependencies. So there's a

1:37:08variety of reasons you may update

1:37:09dependencies. One, there's new features

1:37:11released that you want to take advantage

1:37:12of, but often times you'll need to

1:37:14update in order to handle security

1:37:16vulnerabilities that have been released

1:37:18and then patched within these code

1:37:20bases. To achieve this, generally you

1:37:22would need to check out your code, check

1:37:24against your dependency file. So maybe

1:37:25the package.lock lock file to see if the

1:37:28package manager of choice has new

1:37:30versions available. You would then

1:37:31update the necessary references, commit

1:37:33those changes to a branch, and then

1:37:35create a PR. At that point, your test

1:37:38workflow is likely going to kick off and

1:37:40validate that those version upgrades

1:37:42don't break anything across your test

1:37:43suite. Hopefully, this quick run through

1:37:45of these types of workflows gives you a

1:37:48starting point for the types of things

1:37:49that you'll want to build and how to

1:37:51approach them and break them down into a

1:37:53GitHub action workflow. It's often

1:37:54useful to sort of start with a basic

1:37:56naive approach and then think about hey

1:37:58how can I optimize this either for

1:37:59performance user experience or by

1:38:02leveraging some marketplace action to

1:38:03handle a lot of the functionality for

1:38:05you. It's also useful to show here that

1:38:07under the actions tab in GitHub if you

1:38:08click new workflow there's a number of

1:38:10baseline workflows that GitHub provides

1:38:14and even suggest potentially relevant

1:38:16ones for your repository based on the

1:38:18code that it finds to use as a starting

1:38:19point or as inspiration for your own

1:38:21workflows. For example, let's take a

1:38:23look at this Docker image workflow

1:38:24provided from GitHub. Here they're

1:38:26building it whenever you push to the

1:38:28main branch or you create a pull request

1:38:30against that main branch. And so while

1:38:32this is quite minimal, it is a starting

1:38:33point if you need to build a container

1:38:34image. What if we look at one of these

1:38:36deployment workflows? For example, if we

1:38:38were deploying to Elastic Container

1:38:40Service on AWS, what might we need to

1:38:42do? It provides us a few steps that

1:38:44would need to be performed before this

1:38:45workflow can run. For example, we'd need

1:38:47a an ECR repository to store those

1:38:49container images. We would need an ECS

1:38:51task definition that defines how our

1:38:52application is going to be deployed.

1:38:54That would need to be stored within our

1:38:55repository. And we would need to set up

1:38:57the necessary IM permissions to be able

1:38:59to perform this action. So here we're

1:39:01configuring some metadata about about

1:39:03our AWS deployment configuration. And

1:39:06then what steps is it actually going to

1:39:07take? We're checking out our code. We're

1:39:09configuring our credentials. Here

1:39:11they're using a static access key. As I

1:39:12showed in module 4, it's much better to

1:39:14use the OIDC approach with a temporary

1:39:16token. So I would swap that out if I was

1:39:18going to use this. We're then logging

1:39:19into Elastic Container Registry,

1:39:21building and tagging our image, pushing

1:39:23it to ECR, and then storing that as an

1:39:25output. At this point, we've built our

1:39:27container image, and we shift into

1:39:29starting to deploy it. We're providing

1:39:31the specific information about this

1:39:32particular deploy, which is going to

1:39:34render out an appropriate task

1:39:35definition that we can then use to

1:39:38actually deploy. So, in this case, we're

1:39:39leveraging a number of actions that AWS

1:39:41provides to enable a streamlined

1:39:43workflow of deploying into Elastic

1:39:44Container Service. So while there are

1:39:46some tweaks that I would make like

1:39:47changing how we're authenticating to AWS

1:39:49and I might tweak how this is being

1:39:51built perhaps using namespaces remote

1:39:53builders but overall this gives us a

1:39:55strong starting point for a workflow

1:39:57where we're building and deploying into

1:39:59ECS. Here's a workflow handling stale

1:40:02issues and PRs. Like I mentioned in this

1:40:04case we're using that cron schedule. So

1:40:05we're going to run it daily and the only

1:40:07thing required is a single step using

1:40:09that stale action passing it the

1:40:11necessary credentials and configuring

1:40:13the various labels and messages that we

1:40:16want it to use. We could then customize

1:40:17these to meet our own needs and then we

1:40:19would have this automation which could

1:40:20run daily and help keep our repo tidy.

1:40:23So my goal with this module was to give

1:40:24you the ability to take a particular

1:40:26task that you need to achieve and break

1:40:28it down into its requisite steps and see

1:40:30how that would map onto a GitHub action

1:40:32workflow and then how you might iterate

1:40:33and improve on those workflows to

1:40:35optimize them. It's useful to be able to

1:40:36do that both from scratch as well as

1:40:38search from the template workflows that

1:40:40GitHubs provides to use as a starting

1:40:42point. One of the biggest complaints

Developer Experience

1:40:43that I have and that I hear other

1:40:45developers have with GitHub actions is

1:40:46the developer experience for iterating

1:40:48on workflows and actions. In this

1:40:50module, I'm going to show you how I

1:40:51approach this and some tools and

1:40:53techniques to make this a little less

1:40:54painful. Everyone who's worked with

1:40:55GitHub actions or pretty much any CI

1:40:57system has experienced this pain where a

1:40:59workflow breaks, you try to make a

1:41:01change to fix it, you push that change

1:41:02to validate it, and you get in the cycle

1:41:04of making a small tweak, pushing it,

1:41:06seeing it fail. Each of those iteration

1:41:07cycles is frustrating and takes a

1:41:09significant amount of time. Using the

1:41:11techniques that I'm going to show you

1:41:12here, we can help speed up that

1:41:13iteration process and avoid the number

1:41:15of failed attempts where we actually

1:41:16need to push to GitHub. There's a few

1:41:18different categories of developer

1:41:20experience that I'll touch on here. The

1:41:21first one is going to be iterating on

1:41:23actions. So earlier in the course, we

1:41:24looked at how to build our own custom

1:41:26actions. There's some tooling that

1:41:27GitHub provides to run that action

1:41:29locally. And then also we can write unit

1:41:31tests just like for any other software

1:41:32project. This allows us to validate

1:41:34behaviors of the action as well as

1:41:35execute it in a similar environment to

1:41:37what will be used within a workflow

1:41:39rather than needing to push changes

1:41:40every time to check if they work or not.

1:41:42On the workflow side, and this is where

1:41:43I think most of us are going to spend a

1:41:45majority of the time when working with

1:41:46GitHub actions, we want to do a number

1:41:48of things. First, we want to make sure

1:41:49that our development environment has the

1:41:50appropriate tooling. If you were

1:41:51following along early in the course,

1:41:52we've already installed the GitHub

1:41:54actions extension and the YAML

1:41:55extension. There's one more extension

1:41:57here that I use that applies colors

1:41:58based on the indentation level to make

1:42:00it easier to see how your YAML is

1:42:01structured that I'll suggest and show

1:42:03you. But then I also like to do things

1:42:04like pull logic out of inline bash

1:42:06scripts. So rather than define all of my

1:42:08logic in those YAML files, I'll pull it

1:42:11out into a task file and call that from

1:42:13within my workflow. This allows me to

1:42:14iterate on those pieces of logic

1:42:16independently of GitHub actions. Beyond

1:42:19that, there's a tool called ACT which

1:42:20allows you to run your entire workflow

1:42:22locally within containers. And once you

1:42:24have that set up, it's quite easy to

1:42:26make a change and test it. If you're

1:42:27modifying the git state of the repo

1:42:28itself, those can be hard to test

1:42:30locally. Uh but for most things, for

1:42:32many types of workflows, it's not too

1:42:33hard to configure act to run them

1:42:35locally. Then rather than needing to

1:42:37push a commit and wait for a runner to

1:42:38pick it up to execute it, you can make a

1:42:41change and run it locally immediately.

1:42:42There's a couple of other debugging

1:42:44steps that if you do encounter failures

1:42:45and want to understand them, you can

1:42:47perform. There's mechanisms you can use

1:42:49to get an SSH session into the runner

1:42:51itself that that will allow you to check

1:42:52the state of the file system, see

1:42:54whether some executable that you're

1:42:55trying to use is installed or configured

1:42:56improperly, and generally figure out

1:42:58what you need to do to fix things. Now,

1:43:00there's also two additional

1:43:01configurations that GitHub actions

1:43:03provides that allow us to get additional

1:43:04debug information out. One, we can turn

1:43:06on step debugging by setting that

1:43:08variable or secret within the repo to

1:43:10true. This will make any debug logs

1:43:12defined within the action appear within

1:43:14the logs of the run. Or we can turn on

1:43:16runner debugging where we'll get a lot

1:43:17of debug information from the runner

1:43:19itself that normally wouldn't be shown.

1:43:21I'd say five is generally more useful

1:43:22than six. And depending whether the

1:43:24author of the action has provided

1:43:25additional debug info, it can provide

1:43:27insights into what actually went wrong.

1:43:28I haven't encountered too many cases

1:43:30where something at the runner level is

1:43:31causing issues. But if you did need to

1:43:33debug something at that level, setting

1:43:35that variable would be one way to do it.

1:43:36Now, the third category of developer

1:43:38experience that I'll touch on is around

1:43:40optimizing performance of our workflows

1:43:41such that workflow execution doesn't

1:43:43become a bottleneck in your team's

1:43:45process. We want to be able to look at

1:43:47the timing and resource usage data. So

1:43:49things like memory and CPU and I'll show

1:43:51a couple of different ways that we can

1:43:52do that. Let's jump to my code editor

1:43:53and I'll walk through some of these

1:43:54techniques and how you can apply them.

1:43:56So under module 8, I have two

1:43:58subdirectories. There's an action

1:43:59subdirectory and a workflow

1:44:00subdirectory. First, we're going to take

1:44:02a look at iterating on an action. My

1:44:04task file here contains two tasks. The

1:44:05first one is to use that GitHub local

1:44:08action tool to run my action locally.

1:44:10And my second one is to execute the

1:44:11tests within my action as defined. You

1:44:13can see I'm pointing it to the

1:44:15TypeScript action that I showed earlier.

1:44:16And so let's click into that and see

1:44:17what's going on. Navigating to that

1:44:19action module 6 JavaScript actions

1:44:21TypeScript with build. If we look within

1:44:23the package JSON for this project,

1:44:25there's this local action script which

1:44:27is going to use npx to call the GitHub

1:44:29local action package from the root of

1:44:30this action directory using our main.ts

1:44:33file as the entry point and passing that

1:44:35end file which is where we can provide

1:44:36things like our inputs. If I click into

1:44:38my end file here, I have input

1:44:41milliseconds. In this case, I'm taking

1:44:42the name of my input which is lowercase

1:44:44milliseconds prefixing it with that all

1:44:46caps input underscore and then

1:44:47capitalizing my input name. So in this

1:44:49case 2400 milliseconds will be my input.

1:44:51Now I can go ahead and run this by doing

1:44:53task typescript action run local action.

1:44:56Under the hood it's running that npm run

1:44:58local action script. We can see that we

1:45:00should have milliseconds as an input and

1:45:03time as an output. On this line it shows

1:45:05that it did pick up that 2400

1:45:06milliseconds rather than using the

1:45:08default of 1,00. It ran successfully and

1:45:10then set the output time which we get

1:45:12logged at the end. So this provides a

1:45:13mechanism for me to test my action with

1:45:15various inputs and see its behavior in a

1:45:17GitHub actions like environment without

1:45:19needing to push and have it execute

1:45:20remotely. The other thing that you could

1:45:21be doing when developing and iterating

1:45:23on actions is writing unit tests. This

1:45:25action is a Node.js project. It's using

1:45:27justest for its testing framework.

1:45:28Within this double test double

1:45:30underscore directory, there's two test

1:45:31files defined. We have a main test

1:45:33that's going to validate that in the

1:45:34normal case a time output is going to be

1:45:36set and that in the case where you

1:45:38provide an invalid input, it will reject

1:45:39it. We also have a wait test which

1:45:42checks that the weight duration is a

1:45:43valid number and that the action does

1:45:45indeed wait for at least as long as the

1:45:47specified amount. To run these, I can

1:45:48run task typescript action test. Under

1:45:51the hood, it's going to run the npm run

1:45:53test script. We can see it identified my

1:45:55two test files, ran all four tests, and

1:45:57showed that they all passed. If there's

1:45:58behavior that you want to verify is

1:46:00working within your action. You can do

1:46:02so via testing in this fashion. Now,

1:46:04let's move on to iterating on workflows.

1:46:06I'll navigate to the workflows directory

1:46:08where I have another task file defined.

1:46:09The first step is going to be to set up

1:46:11your code editor with the appropriate

1:46:13configurations. In module 3, we set up

1:46:15the official GitHub actions extension as

1:46:17well as the Red Hat YAML extension,

1:46:20which helps us with auto formatting. One

1:46:21additional extension that I like is the

1:46:23rainbow indent extension. As you can see

1:46:24here, each indentation level shows a

1:46:26different color. It makes it much easier

1:46:27to tell if my indentation is correct or

1:46:29off as I'm working on YAML files. To

1:46:31install that, you can just search

1:46:32rainbow indent and the one that I have

1:46:34installed is this top one. The first

1:46:36technique that I like to use is to pull

1:46:38logic out of the workflow file into a

1:46:40separate location within my repo. This

1:46:42way rather than needing to push a change

1:46:43and execute it, I can iterate much more

1:46:45quickly on my local system. So this

1:46:47first job shows what not to do. So here

1:46:49I have an inline bash script with a

1:46:50hello world. For something trivial like

1:46:52an echo command, it's fine to have that

1:46:54as inline bash, I would say. But as soon

1:46:55as you move into something a little bit

1:46:56more complex where you're less sure that

1:46:58a change is going to behave the way you

1:47:00want it to, I prefer to move that out.

1:47:02So rather than have a command within my

1:47:03YAML, instead I'm going to pull that out

1:47:06into, for example, a task file. So here

1:47:08I have a task file. It's doing pretty

1:47:10much the same thing. It just says hello

1:47:12from a task file called from a GitHub

1:47:13action workflow. And now within my

1:47:14workflow, I'm going to call out to this

1:47:16task. So in this case, I check out the

1:47:18code so it's available. I install task

1:47:20because it's not available by default on

1:47:21our runners. And then I'm able to

1:47:24execute task hello world task for

1:47:26something like echoing hello world. This

1:47:28may seem like additional unnecessary

1:47:29work. Being able to come here and just

1:47:30say task hello world task. Uh, okay.

1:47:33What if I wanted to say something

1:47:34different called from GitHub actions

1:47:40and now I was just able to make a change

1:47:41and validate that change within a couple

1:47:43of seconds rather than needing to make a

1:47:45commit, push to GitHub, wait a few

1:47:47seconds for that job to get picked up

1:47:48and see that it worked correctly. So,

1:47:50tightening that iteration loop is really

1:47:51what we're trying to do here. Also,

1:47:53steps like this installing task, I'm

1:47:55often times going to pull those into a

1:47:56composite action that gets shared across

1:47:58all of my different workflows because

1:48:00it's such a common thing that I use

1:48:01everywhere. I won't end up with this

1:48:03snippet bloating all my workflows. I'll

1:48:05have a single snippet called install

1:48:06dependencies which will handle this for

1:48:08me. This approach works great for

1:48:09testing some small distinct chunk of

1:48:12logic. However, what if you want to test

1:48:14your workflow as a whole? That's where

1:48:15the act project comes in. So act project

1:48:17comes from the nectos organization and

1:48:19it allows you to take your actions and

1:48:21run them in a series of containers using

1:48:23a local container runtime like docker

1:48:25such that we can in many cases

1:48:26approximate in a remote runner with

1:48:29enough fidelity that we can validate

1:48:30workflows locally. So how's this going

1:48:32to work? If you're using devox you

1:48:33should already have act installed. So

1:48:35I'll create a devbox shell and you can

1:48:37see I have version 0279 installed. Now I

1:48:40need to write a command to reference my

1:48:43specific workflow. That's what I have

1:48:45here. So I'm going to use act and then

1:48:46you use the name of the trigger. So in

1:48:48this case, the workflow that I want to

1:48:49utilize is workflow dispatch. I'll use

1:48:51this a bunch during the capstone

1:48:53project. I can even go as far as as

1:48:54creating a JSON file containing all of

1:48:56the metadata I want associated with this

1:48:58trigger event to handle all the

1:49:00different conditions which may occur

1:49:01when triggering a real workflow. In this

1:49:02case, I'm not passing additional inputs

1:49:04or anything. So just using the workflow

1:49:05dispatch type is enough. Because I'm on

1:49:07a MacBook running Apple silicon, I'm

1:49:09telling it to use the AMD 64 variant of

1:49:11any containers that it pulls. The -p

1:49:13option allows us to specify which

1:49:15container image we want to use as the

1:49:17runner. And this will swap out the

1:49:18Ubuntu 24.04 that I have defined in my

1:49:21workflow file with the container image

1:49:22and tag defined here. Now, in this case,

1:49:24the NodeJS is a sufficient replacement.

1:49:27But depending on what your workflow is

1:49:28doing, you may need a container image

1:49:29which is more representative of the one

1:49:31that GitHub uses. If we look in the act

1:49:33documentation, you can see they've

1:49:34provided here a micro, medium, and large

1:49:37image that approximates each of these

1:49:39runner types. If your workflow doesn't

1:49:41depend on many things that are installed

1:49:42or configured on that runner, the micro

1:49:44image is likely sufficient. If you do

1:49:46end up relying on a particular

1:49:48configuration in that runner, you may

1:49:49need to move to the medium or maybe the

1:49:51large Docker image that much more

1:49:52closely matches the set of installed

1:49:54dependencies. However, these images are

1:49:56quite large, multi- gigabytes, and so

1:49:58sticking with one of the smaller images

1:49:59can avoid needing to pull these large

1:50:01images before you can run. Here, I'm

1:50:02telling it which directory to use as my

1:50:04starting working directory. I generally

1:50:06set this to be the root of my project.

1:50:07This way, relative paths within the

1:50:09workflow will behave the same as from

1:50:11when you execute in the remote runners.

1:50:13And then finally, I'm passing it

1:50:15specifically which file I want to

1:50:16execute. If I omitted line 12, it would

1:50:18trigger all of the workflows using a

1:50:20workflow dispatch. So this allows me to

1:50:21limit it to the specific one that I'm

1:50:23testing right now. Now running this,

1:50:30it identified my Docker runtime, you can

1:50:32see the two different jobs within my

1:50:33workflow are prefixed with their name

1:50:35and have different colors. This is

1:50:36executing both the say hello inline bash

1:50:39job as well as the say hello external

1:50:42task job. You see lots of setup. It does

1:50:44end up being a bit noisy with all of

1:50:46these logs interplaced. However, if we

1:50:48scroll down, we see the output expected

1:50:50from the inline bash script. And then

1:50:52similarly, we see the output expected

1:50:53from the task file task. Unless

1:50:55otherwise specified, it does use the

1:50:57local copy of your codebase. When

1:50:59performing the checkout action, it

1:51:00mounts that into the container rather

1:51:02than checking out from GitHub. And so it

1:51:04will pick up the latest changes. For

1:51:05example, if I modify this to append in

1:51:10act to the end and I run it again, now

1:51:12we got that new message populated from

1:51:14within my workflow run. Again, the goal

1:51:17here is to shorten the iteration time

1:51:18for any change such that we can make

1:51:20changes and test them as quickly as

1:51:22possible. So, step one, pull the logic

1:51:23out of your workflow file itself and

1:51:25test individual chunks. Step two, run

1:51:28the whole workflow, but run it locally.

1:51:29Once we're confident that things are

1:51:30working, now we can make a commit and

1:51:32push that to GitHub and avoid that

1:51:33dreaded string of failures in our

1:51:35actions tab. Now, what if things still

1:51:37aren't working and you can't figure out

1:51:38why locally? One step that you can take

1:51:40is to set up access to that runner

1:51:42environment via SSH using something like

1:51:44this breakpoint action from namespace.

1:51:46So, here I have a simple workflow which

1:51:48is going to do three things. It's going

1:51:49to check out my codebase. It's going to

1:51:50purposefully fail. So, it's going to run

1:51:52this echo and then exit one. And then I

1:51:54have this step which is conditional on

1:51:56failure. So, it's only going to run in

1:51:57the case that an upstream step failed

1:51:59and it's going to run this breakpoint

1:52:00action from namespace. I'm providing it

1:52:02a duration for how long it should keep

1:52:04this step open and then also a set of

1:52:06authorized users that are going to be

1:52:08able to utilize it. Let me go ahead and

1:52:09run this. I've named this workflow

1:52:12breakpoint with SSH. I'll trigger it.

1:52:14Okay, our first step ran. It checked out

1:52:16the code. This step purposefully failed.

1:52:18And because of that failure, it ran this

1:52:19conditional step which is going to give

1:52:21us a connection string that we can copy.

1:52:23We have to accept the fingerprint. And

1:52:25now we have a shell session directly

1:52:26within that runner environment. So we

1:52:28can see the code that's been checked

1:52:29out. Let's go into developer experience

1:52:33under workflows. We've got our latest

1:52:35task file. And let's say I was

1:52:37attempting to execute this from within

1:52:39the runner. I can say task

1:52:42hello world task. And look, it's saying

1:52:44oh task is not installed. That's because

1:52:45in this workflow I haven't installed it.

1:52:47I can then go back and rework my

1:52:48workflow to handle the bug that I just

1:52:50found. You'll also notice that when we

1:52:52first connected it gave me two options.

1:52:54I could either extend the duration. So

1:52:56right now I had it set for 10 minutes,

1:52:57but if I needed additional time, I could

1:52:59do breakpoint extend or if I was all

1:53:01done, I can issue the breakpoint resume.

1:53:03That'll terminate my conditional step

1:53:05and allow the job to go ahead and

1:53:06finish. This approach is nice because

1:53:08this only runs if you have a failure. It

1:53:10works across GitHub hosted runners as

1:53:12well as third party runners like

1:53:13namespace runners seamlessly. There's a

1:53:15couple of other debugging features that

1:53:17GitHub action provides and we can take a

1:53:18look at those with this simple workflow.

1:53:21It's just a single job with a single

1:53:22step that echoes something at the debug

1:53:24level, something at the info level, and

1:53:26something at the warning level. First,

1:53:28I'll just run this. So, this is going to

1:53:29be the default behavior. Now, we

1:53:31shouldn't see that debug log, but we

1:53:32should see the others. So, here we've

1:53:34got info and warning.

1:53:36But let's say something was going wrong

1:53:37and we wanted to see the debug logs from

1:53:39the author of whatever action we were

1:53:41running. In order to do that, we can set

1:53:43an environment variable here. We can go

1:53:44to secrets and variables actions and set

1:53:47the

1:53:49action step debug variable to true. We

1:53:52could also set this as a secret. And I

1:53:54believe that the secret would override

1:53:55the variable if both were set. Let's go

1:53:57ahead and rerun our action. I'll go

1:53:59ahead and just rerun all jobs. Now,

1:54:02because I've added that action step

1:54:03debug, I'm getting these additional

1:54:05debug logs, including the debug that I

1:54:07specified here. And then similarly, I

1:54:09can add the actions runner debug

1:54:11variable. So, I'm actually going to

1:54:12delete this one and add

1:54:16this one.

1:54:18Rerun one more time.

1:54:21You'll also notice that when rerunning a

1:54:23job, you can trigger this without

1:54:24needing to modify those variables

1:54:26directly. Now that we've enabled that as

1:54:27true, we can click here, click download

1:54:30log archive,

1:54:34opening up the zip file that was

1:54:35provided.

1:54:37These are the step logs, but we also get

1:54:39some runner diagnostic logs.

1:54:47tons of info from the runner itself.

1:54:49Now, like I said, generally I haven't

1:54:51encountered too many issues where you're

1:54:52where something this deep in the runner

1:54:54layer is impacting my workflow behavior,

1:54:55but it's useful to be aware that you can

1:54:57get access to these if you need to. Now,

1:54:59the final piece of the developer

1:55:00experience puzzle that I wanted to touch

1:55:01on is understanding workflow execution

1:55:04timing and performance. The default user

1:55:06experience here is somewhat poor. you

1:55:08can filter down to a particular workflow

1:55:09and then sort of try to eyeball the

1:55:11different timings to say, "Okay, it

1:55:12looks like over time this is trending

1:55:14upwards or trending downwards." That's

1:55:15not a great experience. There's a couple

1:55:17of ways you can get these data out. In

1:55:18fact, in the past, I've written a custom

1:55:20action that would run daily and it would

1:55:21pull timing data. There's two approaches

1:55:23that I use that I think are more useful

1:55:24than that. The first one is many of

1:55:26these thirdparty runner hosting

1:55:27companies provide some sort of insights

1:55:29or analytics out of the box. So, I go to

1:55:31my namespace account under insights. I

1:55:33can now filter down to a particular job

1:55:35and showcase let's say the last 60 days

1:55:38all the different timings for that job.

1:55:40You can also see the CPU usage within

1:55:42that job or the memory usage within that

1:55:44job and this will help you understand

1:55:46whether resource constraints are causing

1:55:48your workflow to run slower than

1:55:49expected. This is great at the job

1:55:51level. However, it doesn't give you

1:55:52insights at the step level. For that, I

1:55:54generally export the timing data from my

1:55:56workflows into Honeycomb. Honeycomb is

1:55:58an observability platform that is

1:56:00generally used for doing things like

1:56:02tracing of your application code.

1:56:03However, I found this marketplace action

1:56:06which is able to query the GitHub API

1:56:08and turn the timing data for workflows

1:56:10into the same format that would be used

1:56:12for normal application tracing. It then

1:56:14ships those off to Honeycomb at which

1:56:16point I'm able to view timing data at

1:56:18not just the job level but the step

1:56:19level and set up dashboards etc to track

1:56:22those over time. This is from the

1:56:23capstone project. I'm saying for these

1:56:25workflows whenever they finish go ahead

1:56:27and export those data to Honeycomb. For

1:56:29my usage, this fits well within the

1:56:31honeycomb free tier. Let's go take a

1:56:32look at what happens. I can filter by a

1:56:34particular workflow.

1:56:36And now this, because this is a demo

1:56:38project for the course and doesn't have

1:56:39a team of developers making changes

1:56:40every single day, the history here is a

1:56:42bit less useful, but you could imagine

1:56:44something like this over time where

1:56:46you're seeing how long each of these and

1:56:48this a span maps onto a step within a

1:56:50GitHub action workflow. I can start to

1:56:52see trends over time, but I can also

1:56:53click into an individual workflow run

1:56:56and see each portion of it broken down.

1:56:57So, this total workflow took a minute

1:56:59and 4 seconds. You can see we spent 7

1:57:01seconds queuing uh 5 seconds within my

1:57:03filtering job. So, this is sort of

1:57:05determining which services need to be

1:57:06built. And then for each of my services,

1:57:09I can see how long each one took to

1:57:10build. So, in this case, there were two

1:57:11container images built. The first one

1:57:13contains my migration scripts. That one

1:57:14took 20 seconds to build. Uh the second

1:57:15one took 47 seconds. And I can see down

1:57:17at the very granular level where this

1:57:19time is being spent. And so it's hugely

1:57:21valuable to take those timing data and

1:57:23get them out of GitHub actions into a

1:57:25platform where you can where you can

1:57:26track over time and really dive deep to

1:57:28investigate where potential

1:57:29inefficiencies may lie. At this point,

Best Practices

1:57:32we've seen how to author GitHub action

1:57:33workflows, how to write GitHub actions,

1:57:36how to improve the developer experience.

1:57:37Now, I want to talk about some best

1:57:39practices for using GitHub actions

1:57:40within your projects, and then we're

1:57:42going to try to apply a number of these

1:57:43best practices as we move on to module

1:57:4510 and build out the Capstone project

1:57:46for the course. There's a few different

1:57:48categories of best practices. The first

1:57:49one that I'll touch on is performance.

1:57:51There's a number of performance related

1:57:52aspects that we can optimize within our

1:57:54GitHub action workflows. First thing we

1:57:56want to do before anything else is to

1:57:57measure the performance. It doesn't make

1:57:59any sense to try and optimize something

1:58:00if there's no way for you to tell if

1:58:01that optimization actually improved the

1:58:02state of things. A good way to measure

1:58:04this is to export those timing data to

1:58:06some thirdparty platform that you can

1:58:07use to analyze the data and see what is

1:58:09taking the most time which will inform

1:58:10your decisions about where to invest

1:58:12your efforts. The next thing I would

1:58:13suggest is to figure out how to spend

1:58:14less time waiting. And so this could be

1:58:16in runner queuing. So waiting for a job

1:58:18to be picked up by a runner or ensuring

1:58:20that the types of tests that you're

1:58:21executing which may frequently fail can

1:58:24do so quickly and inform developers that

1:58:26you can fix it and move on much more

1:58:27quickly rather than running for a

1:58:29significant amount of time before

1:58:30eventually failing right at the end. So

1:58:31ordering the tests that you do within

1:58:33your workflows in this way can avoid

1:58:35wasted time. The third approach is to

1:58:36try and do less. And so by this I mean

1:58:38don't execute things unless they are

1:58:39actually necessary. You can use

1:58:40conditional filters to only run

1:58:42workflows, jobs or steps when the

1:58:44relevant code changes within the

1:58:45repository. And you can use caching to

1:58:47avoid repeating work that was done in a

1:58:49previous execution. Fourth category is

1:58:51to improve our resource utilization.

1:58:53This could mean splitting work across

1:58:54jobs so that they can run in parallel,

1:58:56splitting work within a job so it can

1:58:58run across the multiple cores that your

1:58:59runner may have and also avoiding

1:59:01emulation where possible. QMU is an

1:59:03emulation technology that allows you to

1:59:05build applications for a different

1:59:06architecture than you're running on. So

1:59:08I can be on an ARM 64 system and build

1:59:10an AMD 64 executable or vice versa.

1:59:13However, it does incur a large

1:59:14performance hit and so where possible,

1:59:16building for the same architecture that

1:59:17you're running on will be much more

1:59:19efficient. This is often how many of the

1:59:20remote container building services

1:59:22achieve such dramatic speedups is by

1:59:24skipping this emulation step and instead

1:59:26splitting the work for the builds across

1:59:27multiple different runners, one using

1:59:29each architecture and then stitching

1:59:30those images back together into a single

1:59:32multiarchchitecture image. The fifth

1:59:33category is once you've done this and

1:59:34you've started to optimize your

1:59:35workflows, make sure to keep track of

1:59:37those timings as development progresses

1:59:39to avoid the inevitable slowdown as you

1:59:41add more code, more tests, etc. You want

1:59:43to keep an eye on these things and flag

1:59:45them before they become a bottleneck for

1:59:46the team so that you can keep

1:59:47development velocity as high as

1:59:49possible. Diving specifically into

1:59:50caching as a performance improving

1:59:51mechanism, there's a number of different

1:59:53types of things that you may want to

1:59:54cache depending on your project. One is

1:59:56git checkout. If your repo is quite

1:59:57large, it can be helpful to have a cache

1:59:59with a reasonably recent copy of your

2:00:01repo so that each new workflow run only

2:00:03needs to pull in the delta when it

2:00:04performs the checkout action. It can

2:00:06also be useful to cache the language

2:00:07tool chain that you're using. So the

2:00:09runner has many tool chains installed

2:00:11out of the box, but if you're using a

2:00:12different version than the one that is

2:00:13installed, you may need to install and

2:00:15configure it yourself and caching that

2:00:16across runs can avoid needing to

2:00:18download and configure it each time.

2:00:19Dependency downloads are another key

2:00:21category where caching is a big win.

2:00:22Rather than pulling from your package

2:00:24manager with each build, you're able to

2:00:26store copy of those dependencies in a

2:00:28cache. Can be accessed much more quickly

2:00:29than needing to pull over the public

2:00:31internet. Build and test artifact.

2:00:32Another thing that you may want to look

2:00:33into caching depending on their size and

2:00:35scale. And then five, within the

2:00:37category of containers, you can cache

2:00:38images. So if there's a base image that

2:00:40you're using over and over, it's great

2:00:41to have that available in a hot cache.

2:00:43You can cache individual layers such

2:00:45that if nothing about that layer has

2:00:46changed, you're able to reuse that in

2:00:47future builds. And then you can also use

2:00:49what are known as cache mounts which

2:00:50allow you to take advantage of something

2:00:52like number three where you're able to

2:00:53leverage let's say an npm cache from

2:00:55within a docker build context by melting

2:00:57in a local volume into the container at

2:00:58build time. The next category of best

2:01:00practices that I'll touch on is

2:01:02maintainability. Not only do we want our

2:01:03workflows to execute quickly and

2:01:05efficiently, we also want them to be

2:01:06maintainable. Depending on whether you

2:01:08have a mono or a multi-reo for your

2:01:10codebase, there's going to be a variety

2:01:11of different considerations that you

2:01:12want to take into account. I'll talk

2:01:14about those in a little bit. But one of

2:01:15the most powerful approaches I've seen

2:01:16is to define a standard continuous

2:01:18integration API or a standard service

2:01:20API for every single service that you're

2:01:22building. Pick a single tool that you're

2:01:24going to use as the entry point for each

2:01:25service. In my case, I'm using task.

2:01:27That could be something like basil. It

2:01:29could be a make file. I want you to pick

2:01:31a standard approach. And for each of

2:01:32those services, have a standard set of

2:01:34commands such as install, test, build,

2:01:37dev, such that whenever a developer

2:01:38comes to a new project, they know

2:01:39exactly what's going to be available.

2:01:41And they know that running make dev will

2:01:43get them up and running right away. The

2:01:44benefit from a GitHub actions

2:01:45perspective is now we can have a

2:01:46standardized approach within our

2:01:47workflow to perform our testing, to

2:01:49perform our build, etc. As we talked

2:01:51about in the module focused on building

2:01:53our own actions, we can use composite

2:01:54actions and reusable workflows to avoid

2:01:56duplicating logic. And then, as we saw

2:01:58in the developer experience module,

2:01:59you'll want to make sure you set up the

2:02:01tooling appropriately on your local

2:02:02system to ensure that you can iterate

2:02:04quickly and test changes without needing

2:02:06to push them to GitHub and run remotely.

2:02:07I showed both of these flowcharts

2:02:08earlier, but I think they're relevant as

2:02:10we think about maintainability. The

2:02:11lefth hand side focuses on reusability

2:02:13of code and whether we should use a

2:02:15standard workflow, a reusable workflow

2:02:16or composite action depending on your

2:02:18needs there. These can be great tools to

2:02:20take code that would be otherwise

2:02:21copypasted across workflows and move

2:02:23them to a centralized location making

2:02:25them much easier to maintain. And then

2:02:26on the right hand side, this focuses on

2:02:28how to perform a particular piece of

2:02:29logic within our workflow. Whether we

2:02:31can leverage a public marketplace

2:02:32action, whether it's simple enough to

2:02:33live as inline bash within our YAML

2:02:35file, whether it should move out into a

2:02:36separate task runner or eventually even

2:02:38into its own custom action. These

2:02:40diagrams give a snapshot of how I

2:02:41approach these various situations.

2:02:43People have very strong opinions about

2:02:44monor repos and multi-reos. I'm not

2:02:46going to make a definitive claim about

2:02:47which of them you should be using.

2:02:49However, I want to sketch out the

2:02:50different considerations you have for

2:02:52each of these two different approaches

2:02:53when it comes to GitHub actions. In

2:02:55terms of reusing logic in a monor repo,

2:02:57that is super easy. All of the logic

2:02:58lives within that repo and it's simple

2:03:00to reference across different workflows.

2:03:02In a multi-reo, it requires some

2:03:04additional setup. You'll likely want to

2:03:05have a separate DevOps utility repo or

2:03:07maybe a GitHub actions repo where all of

2:03:09the shared logic can live and then you

2:03:12will reference that from your other

2:03:13repos. From a discoverability

2:03:14perspective, having things within one

2:03:16repo makes it very easy to search across

2:03:18the entire codebase and see what is

2:03:19available. If you do have a multi-reo

2:03:21setup, you'll need to be a bit more

2:03:22intentional about establishing the types

2:03:24of patterns and documentation such that

2:03:26people can find the available

2:03:28functionality and not end up

2:03:29re-implementing it over and over. From a

2:03:31performance perspective of only running

2:03:33things when they need to be run, this is

2:03:35much harder in a monor repo. Only a

2:03:37small percentage of the PRs or the

2:03:38commits are going to be relevant per

2:03:40particular service. So you need to be

2:03:41very careful with the types of path

2:03:43filtering or change detection that

2:03:44you're performing to ensure that you're

2:03:46not running a bunch of wasted unneeded

2:03:48workflows. In a multi-reo this becomes

2:03:50much easier because the repository

2:03:52boundaries serve as isolation to prevent

2:03:54this. From a caching perspective, again,

2:03:56monor repos are are much trickier

2:03:57because you need to scope all of your

2:03:59keys to avoid collisions and ensure that

2:04:01each cache is appropriate for the

2:04:02particular artifact that you're trying

2:04:03to store and retrieve. In a multi-reo

2:04:05setup, you kind of get this out of the

2:04:06box because each cache is going to be

2:04:08isolated based on the repo that it's

2:04:09associated with. From an artifacts

2:04:10perspective, within a given repo, this

2:04:12is quite simple. In terms of handing off

2:04:14between different workflows or storing

2:04:16artifacts within a single repo, this is

2:04:17quite easy. But if you have a multi-reo

2:04:19setup, you'll often need an external

2:04:20registry, either a packages registry or

2:04:23maybe an object store like S3 or use

2:04:25GitHub releases themselves to perform

2:04:26that type of handoff for managing

2:04:28permissions and secrets. A monor repo is

2:04:30easier to work with but harder to give

2:04:31you the level of granular control that

2:04:33you might want across different

2:04:34permission levels and environments. In

2:04:36terms of setting up required checks or

2:04:37policies, these are generally going to

2:04:39be applied at the branch level on let's

2:04:41say a pull request. It be much harder to

2:04:42get the level of granularity that you

2:04:44might want. In the multi-reo setting,

2:04:46it's easier to configure, but it

2:04:47requires configuring each repo

2:04:48independently and has the possibility

2:04:50for drift where if you want to make a

2:04:51change to one of these checks, you need

2:04:52to then go off and apply it to all the

2:04:54different repos. In terms of the scale

2:04:55of these things within a monor repo, a

2:04:57large pull request may fan out tons and

2:04:59tons of workflows. And so there could be

2:05:01contention for available runners if

2:05:03you're not careful. Dependency updates

2:05:05can be quite nice from a for a monor

2:05:07repo because you can atomically update

2:05:09all the services and pipelines in one

2:05:10single PR. across a multi-reo it can be

2:05:12quite challenging where you need to find

2:05:14and apply these updates across lots of

2:05:15different PRs and so you'll likely want

2:05:17some additional tooling to handle that

2:05:19orchestration and then from an auditing

2:05:20and ownership perspective a monor repo

2:05:22is easier to audit but you need to

2:05:24really put the effort into defeat owners

2:05:26file to specify which services belong to

2:05:28which teams within a multi-reo it's

2:05:30often clear who that service belongs to

2:05:31however all those configurations are

2:05:33scattered across many different places

2:05:34and then as we think about best

2:05:35practices from a security perspective

2:05:37I've mentioned some of these throughout

2:05:38the course but we want to grant the

2:05:39minimum set of permissions require

2:05:40required to do the work that we need to.

2:05:42We want to try to avoid long live

2:05:43credentials and use shortlived tokens

2:05:45wherever possible. In terms of consuming

2:05:47public marketplace action, setting up an

2:05:49allow list for specific approved actions

2:05:51is a powerful technique. As we've done

2:05:53throughout the course, you'll want to

2:05:54pin the action versions from the

2:05:55marketplace using the commit hash rather

2:05:57than using a tag as that's the only way

2:05:59to guarantee you're going to run the

2:06:00same version each time. It's important

2:06:01to not allow self-hosted runners to

2:06:03execute fork PRs. This helps prevent a

2:06:05malicious actor to excfiltrate

2:06:07information about the self-hosted runner

2:06:09by modifying the repo and actions within

2:06:11it. And then finally, using the

2:06:13environments feature, GitHub has the

2:06:14ability to require approval for running

2:06:16workflows within specific environments.

2:06:17So maybe most environments run

2:06:19automatically, but you have to have an

2:06:20approval from a specified user before

2:06:22you execute workflows against the

2:06:24production environment. Many of these

2:06:25concepts I brought up throughout the

2:06:26course, and we'll start to see them come

2:06:28into play as we build out the workflows

2:06:30for our capstone project in the next

2:06:31module. Congratulations. We've reached

Capstone Project

2:06:33the final module of the course. This one

2:06:35will be a big one. We're going to try to

2:06:36take everything that we've learned

2:06:37throughout the entire course and apply

2:06:39it into a realistic project containing

2:06:41multiple services and build out the

2:06:42types of GitHub action workflows that

2:06:44you would want as a team working within

2:06:45this codebase. If you've taken my Docker

2:06:47course or my Kubernetes course, this

2:06:49application will look quite familiar.

2:06:50It's a minimal three- tier web

2:06:52application. It has a React front end.

2:06:54There's two different API

2:06:55implementations that that front end is

2:06:56calling into. One is written in Node,

2:06:58another is written in Go. There's a

2:06:59Python load generator that just that

2:07:01just repeatedly makes calls to one or

2:07:03the other APIs and then it's backed by a

2:07:04Postgress database which tracks each

2:07:06page visit. You can see a screenshot on

2:07:08the right of what the application looks

2:07:09like on the front end and I've

2:07:10structured this as a monor repo

2:07:12containing the application source code

2:07:13Kubernetes manifest for us to deploy

2:07:15into a Kubernetes cluster and we'll be

2:07:17putting the GitHub action automations

2:07:18there as well. Now I've sketched out the

2:07:20types of workflows that I want to

2:07:21implement here. I have six workflows

2:07:23that I'll be implementing. The first one

2:07:24at the top there is a test workflow

2:07:25that's going to run the set of unit

2:07:27tests for each application. The second

2:07:29one has to do with automating the

2:07:31release process based on commits to the

2:07:33repo. The third one is for building and

2:07:35pushing our container images to

2:07:36registries. The fourth one in the middle

2:07:38is identifying and closing stale issues

2:07:40and PRs in the repo. The fifth one is to

2:07:42export timing data from our GitHub

2:07:43actions into honeycomb. And then the

2:07:45last one is to update our GitOps

2:07:46manifests automatically when we build

2:07:48new images such that we can deploy those

2:07:50updates into the different Kubernetes

2:07:52clusters for each environment. Let's

2:07:53jump to the code editor and start

2:07:54building these out. The capstone project

2:07:56is its own git repository. It is also a

2:07:58git subm module of the course

2:07:59repository. I'll caveat with the fact

2:08:01that I did implement this ahead of time

2:08:02such that I would know everything would

2:08:04work as expected. However, I've wiped

2:08:05out all the GitHub action related code

2:08:07on this branch so that I can start from

2:08:09scratch as we walk along and build this

2:08:11out. Looking within the repo, you can

2:08:12see I have YAML files for each of these,

2:08:14but they currently have no logic and are

2:08:15just a hello world for each of them.

2:08:17Over the next hour or so, we'll be

2:08:19building out these workflows, referring

2:08:20back to everything we've learned from

2:08:21the course to end up with a robust

2:08:23DevOps system ready for a team who is

2:08:25working on a codebase like this to

2:08:26utilize. Let's quickly take a look at

2:08:28the application just to familiarize you

2:08:29with all the different components. All

2:08:31of the application code lives under the

2:08:32services subdirectory which are further

2:08:34split by the language in which they're

2:08:36written. So under go we have our go API.

2:08:38Under node we have our node API. Under

2:08:40Python we have the load generator. Under

2:08:41react we have our front end and under

2:08:43other we have our database migrations to

2:08:45establish the schema within that

2:08:46postgres database. Now all of these will

2:08:48use that devbox setup. So I'll do dev

2:08:50box shell

2:08:53and the first thing I'll do is start a

2:08:55copy of our database. So I'll go into

2:08:57services other

2:09:00migrator within here I have a task file.

2:09:03The task that I want to run is run

2:09:04postgres. I'll run that run postgres

2:09:07task which is going to start a container

2:09:08running the appropriate version of

2:09:10postgres. Next up I want to run our

2:09:11initial migrations. Once again I'll do

2:09:13devbox shell

2:09:16and now I want to run my postgres init

2:09:19script.

2:09:22It ran the SQL script which created my

2:09:25request table. In that request table we

2:09:27have a created at field as well as an

2:09:28API name which will specify whether the

2:09:30node API or the Golang API sent the

2:09:32request. We can now navigate to one of

2:09:33those API layers.

2:09:38So under services node API node

2:09:44we have a task called install that I can

2:09:45use to install my npm dependencies. And

2:09:47then we have a task called run that I

2:09:49can use to run the API. So I'll do t

2:09:50install

2:09:56followed by t run.

2:09:59And now this API should be listening on

2:10:01localhost port 3000.

2:10:04Now the node API is running and each

2:10:06time I request that number should go up.

2:10:08Once again we'll start a devbox shell in

2:10:10a new terminal.

2:10:16We'll navigate to our Golang API. I'll

2:10:18first install my dependencies

2:10:21which calls go tidy under the hood and

2:10:23then I'll run. As you can see I've got

2:10:25this standard interface for each of

2:10:27these services where I run task install

2:10:29followed by task run and now I'm up and

2:10:30running. This should now be listening on

2:10:32localhost port 8000.

2:10:35And we can see here's our go API. You'll

2:10:37notice the request count is a little

2:10:38high. This is because I am storing the

2:10:40data from that Postgres database in a

2:10:41volume. So those data were not deleted

2:10:43the last time I ran this application.

2:10:45With each new request, that count goes

2:10:46up by one. And we get these logs to the

2:10:48terminal. We'll create a devbox shell.

2:10:59Our Python load generator has the same

2:11:01interface. We'll do task install

2:11:05followed by task run. And now we're

2:11:08making lots of requests to localhost

2:11:108000. And we can see the Golang API is

2:11:13receiving those requests. If we go back

2:11:14to our browser and refresh here, we'll

2:11:16see that number is now climbing much

2:11:17more rapidly as the load generator

2:11:19continues to make requests in the

2:11:20background. Now, finally, let's spin up

2:11:22our React front end.

2:11:28Navigate to the React client react

2:11:30subdirectory and devbox shell. Run task

2:11:33install

2:11:35followed by task run.

2:11:38And now we can open up in the browser

2:11:39our application. With each refresh,

2:11:41we're going to make a request to each

2:11:42one. But as you'll notice, the Golang

2:11:44API is what the Python load generator is

2:11:46hitting. So that's going to climb much

2:11:47more quickly. So we refresh once. We get

2:11:49one additional request on the node API.

2:11:51However, a bunch of requests have been

2:11:52made in the background on the Go API. I

2:11:55won't look at the source code for each

2:11:56of these applications. You're welcome to

2:11:57do so. They're quite minimal, but I

2:11:59wanted to show you how all the pieces

2:12:00fit together and demonstrate that shared

2:12:02API across all the different services.

2:12:04That's going to become important as we

2:12:05try to design workflows that can be

2:12:07reused across the entire repo. You may

2:12:09also be wondering why I structured it in

2:12:10this way with each service living under

2:12:12a subdirectory of its language. It's

2:12:13because in my GitHub action workflows,

2:12:15I'm going to make sure that the

2:12:16appropriate language tool chain for each

2:12:17project is set up based on where it

2:12:19lives in the repo. And so by doing some

2:12:21careful planning ahead of time about how

2:12:22I structured this, it will greatly

2:12:24simplify the logic required to ensure

2:12:26that the appropriate tools are

2:12:27installed. I'm going to go ahead and

2:12:28stop this application

2:12:31and we can talk through how we're going

2:12:33to go about implementing some of these

2:12:34things. As you saw, this project and all

2:12:37the services live within a single repo.

2:12:38So you'll want to think back to some of

2:12:40those considerations I mentioned before

2:12:42about monor repo versus multireo. In

2:12:44this case, we'll want to be very careful

2:12:45about how we decide what to execute

2:12:47within our CI pipelines because I may be

2:12:49making a PR against one of these

2:12:50services and don't necessarily want to

2:12:52run or impact tests and code across all

2:12:54the others. That's what this filter step

2:12:56here in the test and build push

2:12:57workflows represents. I'm going to check

2:12:59which services are relevant for any

2:13:01particular code change and use that to

2:13:03dynamically generate the set of tests

2:13:04and builds that are run. Then I'm going

2:13:06to leverage the matrix strategy that we

2:13:08saw before to take that set of tests or

2:13:10builds and spawn a set of jobs that run

2:13:12in parallel to execute them. Now let's

2:13:15start with that test workflow. Back in

Capstone: Test Workflow

2:13:16our common workflows module, we sketched

2:13:18out this test workflow. So let's follow

2:13:20something like this. We're going to

2:13:21check out the code. We want to make sure

2:13:22we have the correct version of our

2:13:24language tool chain installed. We'll

2:13:25download any application dependencies,

2:13:27build our application, and then run the

2:13:29tests. As shown at the bottom there,

2:13:30we're going to leverage public

2:13:32marketplace actions to handle things

2:13:33like tool chain setup and caching. For

2:13:35now, let's have a single job within our

2:13:36workflow called test.

2:13:39We'll run on the latest ibuntu. And

2:13:41we'll start by checking out our code.

2:13:44Let's find the latest version available.

2:13:47Here's the official get checkout action.

2:13:49The latest release was version 5.0.

2:13:54Actions checkout at that

2:13:585.0.0.

2:14:01Once we have the code checked out, we

2:14:02want to install our language tool chain

2:14:04as a starting point. Let's design this

2:14:06workflow for just one of our services.

2:14:08Let's use the node API. For this, we can

2:14:10use the official NodeJS action

2:14:16on version 5.0.0. But let's go ahead and

2:14:18find the associated commit. Go here to

2:14:21view source code.

2:14:25Find that release.

2:14:27copy our git hash.

2:14:34I'll add a comment to ensure that we set

2:14:35up the correct version and caching.

2:14:39But we can continue on. Now, just like

2:14:40we had our install and run tasks, we

2:14:44also have a test task. So, I go here

2:14:47under my task file.

2:14:50We can see that test is going to run npm

2:14:52run test for the node project. For go,

2:14:54it's going to run go test, etc. For each

2:14:56of our services, we have the

2:14:57standardized test task that we'll call

2:14:59into from our workflow. However, task is

2:15:01not installed out of the box on these

2:15:02runners. So, we're going to need to

2:15:04install that as well. If we look at the

2:15:06marketplace and search for task, there

2:15:08are a couple of different options here

2:15:09in the public marketplace that you could

2:15:11use to install task and configure the

2:15:13GitHub path accordingly. The way that I

2:15:15like to do this is using an action

2:15:16called setup binary, which can be used

2:15:18for any executable binary, but you

2:15:20provide it a path to where that release

2:15:21lives, the name of the binary, and the

2:15:23version. This will follow all the

2:15:24standard GitHub action conventions for

2:15:26installing things within the tool tane

2:15:27cache. Now I need to give it a path to

2:15:29the release that we care about. To find

2:15:31that I can go to the task GitHub,

2:15:39find the latest release,

2:15:42look for the Linux AMD 64 version,

2:15:47copy that address.

2:15:52This will be named task

2:15:54and the version here is v

2:15:583.44.1.

2:16:01With our code checked out, NodeJS

2:16:03installed and task installed, we can now

2:16:05run our we can first run our install

2:16:07task and then our test task.

2:16:13Within our task file, this was the

2:16:15install command we ran locally. However,

2:16:17in CI, you generally want to run the

2:16:18npmci command. So, we have a separate

2:16:21task that we'll use from within our CI

2:16:23workflows. Now, we also need to specify

2:16:24the working directory for this step. And

2:16:26that's going to be from the root of repo

2:16:29services node API node. And lastly,

2:16:32we'll want to actually run same working

2:16:36directory. And here, this will be just

2:16:38task task. Now, I could push this to

2:16:40GitHub and run it there, but as I

2:16:43mentioned, we want to set things up

2:16:44properly locally so that we can iterate

2:16:46as quickly as possible. The general idea

2:16:47here is we're going to use ACT and

2:16:49configured command to run this workflow

2:16:51locally to allow us to validate it

2:16:53before we push anything to GitHub. I'm

2:16:55going to create a subdirectory here

2:16:57called test matching the name of the

2:16:59YAML file. Within that, I'll have a task

2:17:02file

2:17:05using version three of the task file

2:17:06syntax. My set of tasks will include

2:17:09trigger workflow. This is going to

2:17:11trigger my workflow using act. The

2:17:13command I want to run is going to be act

2:17:16workflow

2:17:18dispatch.

2:17:20I'm going to specify my container

2:17:22architecture

2:17:24as Linux

2:17:26AMD 64. By default, it would use the

2:17:28architecture of my MacBook. In this

2:17:30case, I want to match the architecture

2:17:31of my runners. I'll specify which

2:17:34container image I should use instead of

2:17:35that Ubuntu 24.04 image. Now in its

2:17:39current setup, the slim node image

2:17:40probably would work, but I know

2:17:41eventually I'm going to be using some

2:17:43git operations. And so having git

2:17:45available in the runner is going to be

2:17:47necessary. So I'm going to go ahead and

2:17:48use this larger act image now because I

2:17:51know it has all the dependencies that

2:17:52are going to be needed down the road.

2:17:53I'm going to specify my directory where

2:17:54this should be executed from. And

2:17:56because I'm I'm within GitHub workflow

2:17:58test, I'm specifying three steps up to

2:18:01run this from the Rud repo. And then

2:18:02finally I'll pass it the path to the

2:18:04workflow that I actually want to

2:18:04execute. Now let's try and run this. So

2:18:07I can navigate to GitHub

2:18:11workflows

2:18:13test. I'll run task trigger workflow

2:18:18first. It's going to pull that image

2:18:20specified to use.

2:18:25And so this is interesting within the

2:18:26setup node action. Uh the latest version

2:18:28is using node 24. I believe that was

2:18:31just updated recently.

2:18:33So if I go to this repo view source

2:18:36code, the action.yamel file, I can see

2:18:40it's using node 24. Let's look at when

2:18:41that was updated. Yeah, just last week.

2:18:44And so they've just updated the runtime.

2:18:46However, ACT is validating the runtimes

2:18:48that it thinks are available and the

2:18:49version of ACT that I'm running hasn't

2:18:51updated to include node 24 in that list.

2:18:54While I think this has been updated in

2:18:55the act repository, because I'm using

2:18:57devox, I would need the latest version

2:18:59available via Nyx to also be updated.

2:19:01Rather than try and fix all that, I'm

2:19:02going to use one version earlier of the

2:19:04setup node action, which will be running

2:19:05on node 20 and should be allowable.

2:19:07That'll be version 4.4.0.

2:19:11So, I'll modify this to reference that.

2:19:14And now, let's try running it again.

2:19:22Looks like we also hit the same issue

2:19:23with the setup binary action which was

2:19:25just updated.

2:19:29Let's find one prior release. So we'll

2:19:32downgrade it to the v4.0.1.

2:19:38Run again.

2:19:42Right. It looks like we were able to run

2:19:43successfully through the entire

2:19:44workflow. However, we did get these

2:19:46issues about an unsupported engine.

2:19:48That's because it's running the default

2:19:49version of node on the runner rather

2:19:51than the version specified in my

2:19:53package.json. So if I go here to my

2:19:55package.json, I've specified my npm and

2:19:58my node engines. In this case, I'm

2:20:00saying I want a node version greater

2:20:02than 20 but less than 21. And in the

2:20:05setup node action, I can actually pass

2:20:06it a path to this and it will look up

2:20:08the desired engine to utilize. You can

2:20:11pass it a version directly. However,

2:20:13because I already have the the engine

2:20:15defined within my package JSON, I'd

2:20:17rather reference that file via this

2:20:19input.

2:20:21So, I can add

2:20:23with node version file here. It's going

2:20:27to be

2:20:30services node API node package.json.

2:20:34And similarly, we can set up caching

2:20:39by passing it a path to my package.lock

2:20:42file.

2:20:43So here I'll do cache dependency path

2:20:50but instead of my package.json this will

2:20:52be my package lock.json. And then I also

2:20:55need to tell it that this project is

2:20:56using npm by specifying cache npm. So

2:21:01with these three inputs we're giving it

2:21:02the appropriate version to download and

2:21:04install. We're telling it which type of

2:21:06cache to use as well as which file to

2:21:08use to determine an appropriate cache

2:21:10key. And now when we run it again,

2:21:14we see that it installed an appropriate

2:21:15version of node. We no longer get that

2:21:17warning about the incorrect engine. We

2:21:20configure our cache. We install our

2:21:23dependencies. You can ignore these for

2:21:25now. As you can see, I haven't updated

2:21:26my dependencies in a while. Maybe I

2:21:28should do that. And then we ran our

2:21:30tests. Now, my test suite here is just a

2:21:32single example test. But the point is

2:21:34that I'm doing all the steps that would

2:21:36be required for a much more

2:21:37sophisticated testing setup. I've got my

2:21:39runtime. I've installed my dependencies

2:21:41and I'm executing all the tests that are

2:21:42found. Okay, this is great, but it only

2:21:44works for one of my services. How can I

2:21:46apply this across more different

2:21:47services? Let's use our Go API as the

2:21:50next service to incrementally add. I'll

2:21:51also call out that issue I ran into with

2:21:53Node 24. That would not be an issue when

2:21:56running these remotely with GitHub

2:21:58actions. That's only an issue I'm

2:21:59encountering locally with ACT. Once I'm

2:22:01able to update ACT within Devbox to

2:22:03support those, I'll likely update the

2:22:04version used for these different

2:22:06actions. How can we get our Golane tests

2:22:08to work? Now, the simplest approach

2:22:10might be to copy this entire job. Call

2:22:12this one test node.

2:22:15Call this one test

2:22:18go. And then all we really need to do

2:22:21here is to modify this step to install

2:22:23and configure the Golang tool chain and

2:22:25then modify the name and the working

2:22:26directory of these to call the

2:22:28corresponding tasks within the Go API

2:22:30subdirectory. Let's go ahead and use

2:22:31this copy paste approach for now before

2:22:33we refactor it into a more modular

2:22:35approach. So we've got test go here.

2:22:38Instead of setup node, we're going to

2:22:39look at setup Golang. Here's the setup

2:22:41go environment.

2:22:43Now if we look at the releases, looks

2:22:46like they did use this newer node

2:22:48runtime. So let's use the previous one

2:22:50of 5.5.

2:22:55Instead of the node version file, this

2:22:56takes as input the Go version file.

2:22:58That's going to be services go api

2:23:00Golang. And then instead of

2:23:02package.json, JSON

2:23:05it will be our go.mod file. In terms of

2:23:07the cache, it is enabled by default and

2:23:10the file that we care about for

2:23:11determining the cache key is going to be

2:23:13our go.sum file which is similar to the

2:23:15lock file in npm. We still need task. So

2:23:18that'll stay the same. Instead of node

2:23:21here, we're installing our app

2:23:22dependencies at the path services go api

2:23:24golang. And then we'll run our tests.

2:23:27And so I think with those minor

2:23:28modifications, we should now have two

2:23:30different jobs. One testing our node

2:23:32application, one testing our Go

2:23:33application.

2:23:36The logs from these are interplaced.

2:23:38We've got our yellow prefixed node and

2:23:40our green prefixed Go.

2:23:50Scrolling up, we see our Go tests were

2:23:52executed here.

2:23:54They ran and they passed. So, we

2:23:56successfully installed and configured

2:23:57our Go tool chain, built our

2:23:58application, and ran the tests against

2:24:00it. Now, let's start to refactor to make

2:24:02this a little more maintainable. We

2:24:03don't want to have to copy and paste

2:24:05this job across every different language

2:24:06configuration that we might want. And

2:24:08instead, we can utilize how we

2:24:10structured the repo along with the

2:24:12matrix feature within GitHub actions to

2:24:14combine these into a single job that

2:24:16runs with two different configurations.

2:24:17So, we'll go up here to our first job.

2:24:19This will be test all the same runs on,

2:24:22but now we're going to add a strategy.

2:24:24I'm actually going to set fail fast to

2:24:26false. So, if it's running the test for

2:24:27one service, it won't cancel the test

2:24:28for the other. So we'll get signal about

2:24:30each of them on whether they're passing

2:24:31or failing rather than just one if it

2:24:33fails. And then we'll use the matrix

2:24:35strategy. Within the matrix strategy,

2:24:37we're going to specify the service that

2:24:39is being tested. And here we're going to

2:24:41have a list that contains the paths to

2:24:44our two services. So the first one is

2:24:47going to be services API node

2:24:52and the second one

2:24:55services go

2:25:00API golang.

2:25:02We'll set up node here. We need to set

2:25:05up go as well.

2:25:10Now the rest of the workflows were

2:25:12identical. So I can remove this

2:25:20We'll rename that to make it generic.

2:25:21And then we need to extract the working

2:25:23directory from the matrix context. So to

2:25:26do that, we can use our dollar curly

2:25:27braces syntax

2:25:32and specify we want the matrix service

2:25:34to be the working directory.

2:25:36Now I'm not sure if I need this trailing

2:25:38slash here or not. Let's actually take

2:25:39it away. Similarly, for our testing,

2:25:43we'll want to use the matrix service as

2:25:44our working directory. And now, there's

2:25:47still one issue here, and that's that

2:25:48for my Go project, I'm not going to have

2:25:50these files available. And for my node

2:25:52project, I won't have these files

2:25:54available. So, let's make these steps

2:25:55conditional based on what the service

2:25:57is. So, in this case, I'm only going to

2:25:59set up node if my matrix value matrix.

2:26:04Equals single quotes

2:26:08services/node

2:26:11slashstar

2:26:15and I'm only going to set up go if my

2:26:17matrix.service

2:26:19is under the go subdirectory. Let's see

2:26:22if I got that right.

2:26:31Ah so I think what was happening here is

2:26:32that uh in this case both of these

2:26:35conditionals were returning false. So I

2:26:37think it was looking for a literal match

2:26:39of that exact string rather than

2:26:40leveraging this glob pattern. Instead

2:26:42I'm going to use the uh starts with

2:26:44function

2:26:47and in this case I'm going to say matrix

2:26:50matrix.

2:26:51Starts with

2:26:53services/node.

2:26:55We'll close that and do the same thing

2:26:57here

2:27:01except here it will be services go. Now

2:27:03that we're using the starts with

2:27:05approach, I believe the filter should

2:27:07work properly. Looks like in the Go job,

2:27:10we're setting up Go. In the node job,

2:27:13we're setting up node.

2:27:15We installed our dependencies, ran our

2:27:17tests,

2:27:18and both of these jobs succeeded.

2:27:21So now we have a single matrix job which

2:27:24runs for both of our services,

2:27:26conditionally installs necessary

2:27:28dependencies, and executes our tests.

2:27:29Next, we can extend this to work for our

2:27:31React application as well. Our tests for

2:27:33the React client are also in Node.js,

2:27:35but we have a different path here. So,

2:27:37I'm going to modify our conditional

2:27:38slightly to add an or condition. So, if

2:27:41it starts with node or if it starts with

2:27:44services react,

2:27:48we want to set up our node environment.

2:27:52And so just by modifying those two

2:27:54things, adding our client React and

2:27:56telling it to install node appropriately

2:27:58because we've used the conventions of

2:28:00specifying our engine version in our

2:28:02package.json, specifying that task to

2:28:05have task installci and task test.

2:28:08Only those two modifications were

2:28:10required to now run our test job for the

2:28:12React project as well. Let's validate

2:28:14that things are working.

2:28:20So it looks like yellow is our node API,

2:28:25green is our Go API,

2:28:27and purple is our React client. So let's

2:28:30look down here and validate that our

2:28:32client React ran its just tests

2:28:37and they passed. So we made those two

2:28:39little tweaks and now we're running our

2:28:40tests across an entirely new service.

2:28:43Now the final project that we want to

2:28:45execute tests for is our Python load

2:28:46generator. So we're going to add that to

2:28:49our matrix. So it's a path python load

2:28:53generator

2:28:56Python.

2:28:58Uh we need to add an install step to

2:29:01configure Python and Poetry, which is

2:29:03the package manager that we're using. So

2:29:05we'll look up the official setup Python

2:29:07action.

2:29:09So here's the official Python setup

2:29:11action.

2:29:13Let's find latest releases. we're likely

2:29:16going to hit that same node 24 issue. So

2:29:18I'm actually going to use this version

2:29:205.6

2:29:22which is commit hash A26A

2:29:25uses the official setup Python

2:29:31at that particular hash. The

2:29:32configurations we need here to set up

2:29:34caching and the appropriate engine

2:29:36version are Python version file

2:29:40and you'll notice these additional tool

2:29:42tips. These are coming from that GitHub

2:29:43actions extension. It's looking up the

2:29:45metadata associated with the action and

2:29:46providing me with the available fields

2:29:48that I can specify. The Python version

2:29:50file in this case going to be

2:29:51services/python/load

2:29:55generator python slash and because we're

2:29:59using poetry it'll be pi projecttoml.

2:30:03This just made me realize that having

2:30:05these hardcoded is a bug such that for

2:30:07example a react client would have used

2:30:09the incorrect inputs here and these

2:30:11should instead be dynamic using the

2:30:13matrix.service. service.

2:30:21We also want to configure caching

2:30:24using type poetry

2:30:26and we'll specify the cache dependency

2:30:28path

2:30:30as the poetry.lock

2:30:33file. Now, interestingly, the runner

2:30:35does not have poetry installed out of

2:30:36the box, nor does this install and

2:30:38configure it. Before we can execute

2:30:40this, we'll need to install poetry.

2:30:42Let's first attempt to run this and see

2:30:43it fail before we add a poetry setup

2:30:45step. Oh, also I didn't make this

2:30:48conditional. So, we don't want this to

2:30:49run every time. Instead, we only want to

2:30:51run if our service starts with

2:30:54services/python.

2:30:56I've already added Python to my matrix.

2:30:58I'm going to go ahead and comment these

2:31:01out and just test this for my Python

2:31:03service.

2:31:10As expected, we're unable to locate

2:31:12Poetry. So, we're going to add a step

2:31:14prior to doing our setup Python to

2:31:16install the version of poetry that we

2:31:17want. So, we're going to have a step

2:31:19install poetry.

2:31:22It's going to have the same conditional

2:31:23as the Python install. And then we can

2:31:25search the marketplace for poetry.

2:31:34This one looks fine. We can take the

2:31:36latest version going here to the source

2:31:38code and seeing that this release 141.

2:31:41And so we'll specify uses pointing it at

2:31:44that action at the commit and the tag

2:31:46that we want.

2:31:49Now if we run again,

2:31:55we get past that issue with poetry. We

2:31:57installed our project, download

2:31:58dependencies, and ran our tests. And so

2:32:00at this point, we're looking pretty

2:32:02good. We've defined a workflow in about

2:32:0350 lines of YAML that can handle setting

2:32:05up the appropriate tool chains and test

2:32:07all of our different services across the

2:32:09codebase. However, as defined, it's

2:32:11going to test each of those services

2:32:13with each change. Now, we want to think

2:32:15about how we can filter this down. So,

2:32:16it's only running the test for the

2:32:18services which were modified. There's a

2:32:20couple of approaches we could take. We

2:32:21could have split these into separate

2:32:22workflows and then used conditionals on

2:32:24the trigger. So, saying only trigger

2:32:26this this path changed or that path

2:32:28changed. However, the approach I like is

2:32:30to have an upstream job which does that

2:32:32validation for us and then pass that

2:32:34into a downstream step such that this

2:32:36matrix will be dynamic at runtime and

2:32:38only contain the services which have

2:32:40changed. An action which I really like

2:32:41for doing this is this path changes

2:32:43filter which does exactly like I

2:32:45described. It's going to look at the

2:32:46repo and see based on my branch or pull

2:32:49request which files within the repo have

2:32:51changed and then based on my

2:32:53configuration output a set of services

2:32:55that it should run. Using which files

2:32:57were modified is a specific somewhat

2:32:59naive implementation of what might more

2:33:01generally be called a determinator

2:33:03within a build system. So something like

2:33:04basil is going to have a much more

2:33:06sophisticated approach to determining

2:33:07based on the inputs to a particular

2:33:09build target when it needs to be run.

2:33:11And so I'm using this path filter

2:33:12approach to approximate something like

2:33:14this. and it's fine as long as the

2:33:16assumption that each of my services

2:33:18lives in a subdirectory that is not

2:33:20impacted by any others is valid. So if

2:33:22we look at an example of how this is

2:33:24used, let's go to this example from

2:33:27centry.io within their backend.yml file,

2:33:30we're using this action and it

2:33:31references this file filters YAML file.

2:33:34Let's go to that. Within file filters,

2:33:36we have a whole bunch of different top

2:33:38level keys defined. These are the

2:33:40different potential outputs of this

2:33:41action. And depending if the files

2:33:43specified in the array under that key

2:33:46have been modified, these will either be

2:33:48included or excluded from the output

2:33:50list.

2:33:52So we'll go here and find the version

2:33:53that we want to use 3.0.2

2:33:56commit DE90 and we'll add that to an

2:33:59upstream job. So I'm going to call this

2:34:01job filter.

2:34:04We use our same runs on

2:34:11the set of steps.

2:34:13It's going to have one step.

2:34:16We'll call it filter with the ID filter

2:34:18and we need an ID here because we're

2:34:20going to pass an output from this job to

2:34:22the downstream job. We can specify that

2:34:25it uses that action at the desired

2:34:27commit. Then we're going to do two

2:34:28things. One, we need to pass it a token

2:34:31so it can access the GitHub API and

2:34:32appropriately determine what has

2:34:34changed. And two, we're going to pass it

2:34:36the filters key and we're going to

2:34:38reference a new file that we're going to

2:34:40create just like that one we looked at.

2:34:43I'm going to create a file called

2:34:44filefilters.yaml under GitHub utils.

2:34:49Now within this, I'm going to have a

2:34:51tople key for each of my services.

2:34:55And under that I want to include all the

2:34:57paths that are associated with this

2:35:00particular service. So in this case

2:35:02anything under that service directory

2:35:04will be included. And I'm using glob

2:35:05patterns here. So this will capture any

2:35:06file modified in that subdirectory. Now

2:35:08I also have the migrations associated

2:35:10with this service. And so we saw that

2:35:12earlier when we ran our DB migrations.

2:35:15This is where those migrations live. If

2:35:16those change effectively our API Golang

2:35:20is changing as well. And I'll want to

2:35:21run the tests and build those. And then

2:35:23finally, I would want my GitHub action

2:35:26file to be included as well, such that

2:35:28if I modify my test file, I'm going to

2:35:30execute those tests.

2:35:33I'll do something similar for each of my

2:35:35other services.

2:35:46Then I'm also going to add another key

2:35:48for my migrations. While we don't have

2:35:50any tests to execute for the migrations

2:35:52currently, we will want to build these.

2:35:54And I'm going to use the same approach

2:35:55with filtering for our build workflow.

2:35:57Here I can create an anchor and call

2:35:59this API

2:36:02Golang and reference it in my migrator

2:36:04section. Effectively, these two will now

2:36:07be identical. Whenever any file changes

2:36:09matching these, both the API Golang and

2:36:11the API Golang migrator will be detected

2:36:13as having changed. Now, I just noted

2:36:15these squiggles. This could either be

2:36:17GitHub

2:36:19token or we could have done GitHub

2:36:22token. Either of those are valid. I like

2:36:25using this to make it very clear that

2:36:27this is indeed a secret value. To get

2:36:30the output of this step from this job to

2:36:32pass it down to the next job, I do need

2:36:34to use the output key here.

2:36:38I'll name the output services.

2:36:41And now I need to reference

2:36:43the set of steps for my ID specifically

2:36:47the set of outputs from that step and

2:36:50the output name is changes that comes

2:36:52from the third party action. We can look

2:36:54at its action.yamel to see it. Open our

2:36:56action.yaml. Look at our outputs.

2:36:59The output is changes and it's going to

2:37:01be a JSON array with the names of all

2:37:03the filters matching of any change

2:37:05files. Because we want the filter job to

2:37:07run first, we'll add a needs key and

2:37:09specify filter. And then we'll need to

2:37:12figure out how to take that changes JSON

2:37:14array and populate our matrix from it.

2:37:16Let's go ahead and make sure this runs

2:37:18first.

2:37:25Now it's saying our file did not exist.

2:37:28Let's see.

2:37:30Ah,

2:37:31so as you can see, I'm trying to filter

2:37:33based on files in my repository, but I

2:37:35didn't actually check out the code

2:37:36first.

2:37:38I need to check out the code,

2:37:41which we use the same approaches down

2:37:43below. And let's try it again.

2:37:50Okay, it's telling me that I either have

2:37:52to explicitly set the base or I need a

2:37:54default branch specified in my event

2:37:56payload when actually being triggered

2:37:58from a pull request etc. It's going to

2:38:00have this default branch included in the

2:38:03metadata associated with that triggering

2:38:04event. But here we can add our own event

2:38:07JSON file and pass that via ACT to

2:38:09specify it accordingly. So I'll add a

2:38:11new file here in my test subdirectory

2:38:13under workflows. This will be

2:38:15event.json. JSON

2:38:18containing

2:38:21repository

2:38:25default branch

2:38:27as main

2:38:32and then I need to pass that when I make

2:38:33my act call. So in this task target I

2:38:36can add a dash e pass in my current

2:38:39working directory because the event JSON

2:38:41lives here and then the file name. Now

2:38:44if you remember this action did take in

2:38:46a GitHub token. So I want to define that

2:38:48as well. The way I can do that is to

2:38:50pass a secret with - s GitHub token

2:38:53equals and I'll define this in a second

2:38:59as an environment variable up here.

2:39:04Right? So this is going to run the

2:39:05GitHub O token command uh which will

2:39:09based on my logged in session of the

2:39:10GitHub CLI generate a token which is

2:39:12then passed into my act command. I will

2:39:16go ahead and when I'm done recording uh

2:39:18revoke access from the GitHub CLI which

2:39:20that should wipe the permissions for any

2:39:21of these tokens that are shown on

2:39:23screen. And now let's see if by adding

2:39:25that event.json file with the default

2:39:28branch included in the metadata as well

2:39:30as passing this GitHub token if we'll be

2:39:32able to run successfully.

2:39:39That action performed some git commands,

2:39:41compared the current state of our branch

2:39:43against main, found all the files which

2:39:44were modified, and determined that all

2:39:46of the services defined within my file

2:39:48filter YAML were changed. And so the

2:39:50output of that step is now this list

2:39:53containing each of my services. At this

2:39:55point, I can now modify my matrix

2:39:56strategy to use that output and

2:39:58dynamically execute these jobs based on

2:40:00my filters. This is going to use the

2:40:03from JSON function and we're going to

2:40:05take an input to that the needs array

2:40:08specifically our filter job within it

2:40:11the outputs and we call the output

2:40:15services.

2:40:18Now if I run this, we should see the

2:40:20filter job run first. Generate the set

2:40:22of services containing all of my

2:40:23services I had before and run all four

2:40:25jobs in parallel.

2:40:30You can see I've got four different

2:40:32prefixes there. One's going to be my Go

2:40:34API, one's my node API, my React client,

2:40:37and my Python load generator.

2:40:42And it looks like all four ran and

2:40:44succeeded. Now the main thing that we

2:40:46need to do is rather than use a workflow

2:40:47dispatch, let's modify this to run on

2:40:50pushes to main as well as pull requests

2:40:52against main. So I'll start by adding

2:40:55push

2:40:58filtering down to the main branch.

2:41:02We also want to filter to only run uh to

2:41:05changes to that services subdirectory

2:41:09or

2:41:11changes to this file. For PRs, simply

2:41:14specifying the pull request key will run

2:41:16this for any pull request. But because

2:41:17we have that filtering job, if the pull

2:41:19request doesn't touch anything that is

2:41:20relevant, it will skip the heavy test

2:41:22job and only run that very short filter

2:41:24job. And so we now have a workflow which

2:41:26dynamically determines which of our

2:41:28services have changed and based on that

2:41:30installs the necessary tool chains, sets

2:41:32up caching appropriately, and runs our

2:41:34tests. Let's go ahead and check this

2:41:36into our branch.

2:41:40We are going to make a few more tweaks

2:41:41to this as we move forward. For example,

2:41:43we're going to pull some of these setup

2:41:45tasks into a composite action that can

2:41:47be re reused across workflows. And we're

2:41:49also going to modify it slightly when we

2:41:51add branch protections to our pull

2:41:53requests to prevent merging domain if

2:41:54tests haven't run. There's some quirks

2:41:56around how those are defined and

2:41:58conditional jobs that we need to handle,

2:42:00but we'll come back to that when we get

2:42:01to it. Now, if we go to our capstone

2:42:03repo and create a pull request based on

2:42:04my branch,

2:42:10we should see that kick off the test

2:42:12workflow. The upstream filtering job

2:42:14ran. It detected that all the services

2:42:16had changed and kicked off five copies

2:42:18of that test job with the matrix

2:42:20strategy. If we click into one of these,

2:42:22this API Golang migrator service, it

2:42:25doesn't require Node, Go, or Python. It

2:42:27does require task. When we run the test,

2:42:30we see in this case there are no tests,

2:42:32but we have a command that just echoes

2:42:34no op, no tests for the service yet, so

2:42:36that it can adhere to the same interface

2:42:37as the rest of these services. If we

2:42:39click into node, for example, we'll see

2:42:41pretty much the same thing, except

2:42:42instead, except the setup node

2:42:44environment is executed.

2:42:46And when we click into the tests, it

2:42:48actually ran the tests. The next major

Capstone: Build/Push Workflow

2:42:50workflow that I want to author is this

2:42:52build push workflow. This is going to

2:42:54build our container images and push them

2:42:56to DockerHub. It's going to be a bit

2:42:57similar in that we're going to want to

2:42:58perform a filtering step to determine

2:43:00which services need to be built. Then

2:43:01we're going to build the images and push

2:43:02them to docker up. In terms of the

2:43:05conventions that I used here, each of

2:43:06our services is going to have a Docker

2:43:08file within it. So you can see the

2:43:10Golang one here.

2:43:14You can see the NodeJS one here.

2:43:17They'll also have tasks for building a

2:43:20container image as well as building a

2:43:21multiarchchitecture image with build x.

2:43:26And finally, they import this utils task

2:43:28file which has targets for generating

2:43:31appropriate versions using git describe

2:43:33to leverage the latest release tags

2:43:35within the repo and how many commits

2:43:37away you are from that. So for our

2:43:39production images, we're going to end up

2:43:40with a tag like x.y.z. However, in our

2:43:43other environments, when we build an

2:43:45image, it will get tagged with the

2:43:46latest SER version that it can find,

2:43:48followed by the number of commits that

2:43:49have happened since that release, and

2:43:51lastly, the commit hash of the build.

2:43:53This gives you at a glance of how far

2:43:55away you are from the production

2:43:56release, and the ability to look up the

2:43:57current commit. Each service task file

2:43:59also specifies the container image repo

2:44:01that will push to. For example, this is

2:44:03the DockerHub repo for the node API,

2:44:05while this is the DockerHub repo for the

2:44:07Golang API.

2:44:10With this CI API defined, we can now

2:44:12build out our workflow. I'll navigate to

2:44:14my placeholder. And like with our test

2:44:15workflow, let's start by getting it

2:44:17working for a single service. Let's name

2:44:19the job build push

2:44:22container images.

2:44:26We'll run on Ubuntu 2404 and define the

2:44:28steps required. First, we want to check

2:44:31out the code. Let's copy that step from

2:44:33here.

2:44:36In this case, we do need to make one

2:44:38modification because I'm using git

2:44:39describe which uses git tags. I want to

2:44:41use the fetch depth zero option. So I'll

2:44:43do width

2:44:45fetch depth zero. And this ensures that

2:44:48the git tags and the full history of the

2:44:50repo is checked out and not just the

2:44:51latest commit. This will allow git

2:44:53describe to work properly. Next, I want

2:44:55to make sure task is installed

2:44:57just like in my other workflow. For now,

2:44:59I'll just copy this step. Eventually,

2:45:01I'm going to move this out into a

2:45:02composite action that can be shared

2:45:03between the two. At this point, I want

2:45:05to authenticate to DockerHub.

2:45:08On the marketplace, Docker provides this

2:45:10official Docker login action

2:45:13where we need to pass it a username and

2:45:14token. My usage is going to look similar

2:45:16to this except I'm pinning to the commit

2:45:19hash. Now, in order to do this, we need

2:45:21a username. These are going to be set

2:45:23within the repo. So I'll go and set a

2:45:25Docker Hub username in the variable

2:45:26section and a Docker Hub token in the

2:45:28secret section under my Capstone repo

2:45:31settings secrets and variables actions

2:45:35under variables I have dockerhub

2:45:37username as SID palace and under secrets

2:45:41I have this dockerhub token to generate

2:45:43that I went to hub.docker.com docker.com

2:45:45signed in navigated to account settings

2:45:48under personal access tokens I generated

2:45:51this capstone repo token with read write

2:45:53access and copy the value that was

2:45:55produced into the secret at this point

2:45:58let's go ahead and set up act to be able

2:46:00to run this locally I'll create a build

2:46:02push directory

2:46:06copy my task file from the test

2:46:08directory and first let's create an

2:46:10empty eventjson eventually we'll use

2:46:12this but for now we'll leave it

2:46:14and update the workflow it references.

2:46:18Navigating to that directory.

2:46:20If I run trigger workflow, we see that

2:46:22it ran, but the off to Docker Hub failed

2:46:25because we weren't properly

2:46:26authenticated. In this case, when I'm

2:46:28testing, I generally am not going to

2:46:29want to actually push those container

2:46:30images. So, I'm going to add a

2:46:32conditional to my Docker O step and

2:46:34configure my workflow so that if I'm

2:46:35running it locally, it will skip that.

2:46:38We can do this by specifying a

2:46:39conditional that says only run the step

2:46:41if the act environment variable is not

2:46:43set to true. Now when we run this again,

2:46:47it skips that step and completes

2:46:49successfully. Now by doing this, we are

2:46:51not testing it as part of act and it's

2:46:53something that we're going to have to

2:46:54rely on testing remotely. However,

2:46:56because it's quite a simple

2:46:57configuration, I'm not too worried about

2:46:58that. There's two additional GitHub

2:47:00actions that I need to set up here. The

2:47:02first is going to be to set up Q emu,

2:47:04and that's to allow us to build

2:47:05multiarchchitecture images. Even though

2:47:07we're running this on a single runner,

2:47:08eventually we'll move this to use

2:47:09namespaces remote builders and we can

2:47:12eliminate this step, but for now it's

2:47:14useful to have. The one that I'll use is

2:47:15from Docker. We don't need to provide

2:47:17any inputs. We just need to specify the

2:47:19version we want to use.

2:47:22And then we want to set up build X,

2:47:23which is the Docker build tool that we

2:47:25use to build multiarchchitecture images.

2:47:27Again, we have an official action from

2:47:28Docker. It doesn't require any inputs.

2:47:31My usage will be like this, but I'll pin

2:47:33it to the commit hash.

2:47:36Let's run it once more.

2:47:40We see the job succeeded. Now, we need

2:47:43to generate the image tag that we'd like

2:47:44to use when building the image. For

2:47:48this, we'll be able to use those tasks

2:47:49that I highlighted earlier. But we do

2:47:51want to do a little bit of logic around

2:47:53whether we're building for production or

2:47:55staging, etc. The way I'm going to

2:47:57design this is that for pushes to main,

2:47:59we'll consider that a deployed to

2:48:00staging and production will be deployed

2:48:03on specific release tags following a

2:48:05convention for each service. I'm going

2:48:07to start by just using a placeholder.

2:48:10I'll just hardcode a version.

2:48:13I'll hardcode an image tag

2:48:16and write both of those to the GitHub

2:48:18output.

2:48:26Because I'm going to reference this in

2:48:27the next step, I do need an ID here.

2:48:29I'll call it image tag. And let's add a

2:48:31to-do. Generate the actual version/tag.

2:48:35From here, we can actually build our

2:48:37image. For this, we can use the official

2:48:39Docker build and push action. There's a

2:48:41whole bunch of different inputs we can

2:48:42provide to this. We're only going to

2:48:43need a handful of them. We'll use

2:48:45version 6.18. First, we're going to

2:48:47specify the context. And the context is

2:48:49going to vary for each service. But

2:48:51because we're establishing our baseline

2:48:53for just getting it working with one,

2:48:54let's use the Node.js image. So this

2:48:56will be services

2:48:58node API

2:49:00node. The push input, we can actually

2:49:02use that act environment variable again.

2:49:04If we're not running an act, go ahead

2:49:05and push. If so, skip it. For our tags,

2:49:07this is where we're going to use the

2:49:08image tag from the previous step. And we

2:49:11can say steps.

2:49:13Tagoutputs

2:49:16do image tag. This was tags plural. We

2:49:20can specify platforms. In this case,

2:49:22while we do have QEMU set up, so we

2:49:24could build it across multiple

2:49:25architectures in order to keep it as

2:49:27fast as possible. Let's only specify the

2:49:29architecture of the runner itself. So do

2:49:31Linux/ AMD

2:49:3464. And then we can use the caching

2:49:37options to say cache from of type equals

2:49:41GitHub actions. So this is going to

2:49:42leverage the GitHub actions cache to

2:49:44store and retrieve layers. And then

2:49:46cache 2 will similarly say type equals

2:49:49GitHub actions and mode equals max. Now

2:49:52when we shift to using namespaces remote

2:49:54builders, we can eliminate these caches,

2:49:57but for now it can be useful. Let's try

2:49:59running it.

2:50:03Looks like it's actually building our

2:50:04container image and the job succeeded.

2:50:07Awesome. So at this point we have a

2:50:09workflow that can execute end to end. It

2:50:11configures docker and uses the

2:50:13convention of having a docker file

2:50:14within each service to build out that

2:50:16service. Now let's perform a similar

2:50:17approach to what we did with test to use

2:50:19a matrix strategy to build all our

2:50:20services in this fashion. I can copy

2:50:22what we did for test. We're going to

2:50:23have that upstream filter job. Then

2:50:25we'll add our matrix strategy

2:50:31and modify our context to use the

2:50:33matrix.

2:50:37Now if I run this again,

2:50:40oh first I notice that we are missing

2:50:42our needs key here

2:50:45to specify that the filter job must be

2:50:46upstream.

2:50:48But we also get that same error as

2:50:50before where our default branch was not

2:50:52specified. Let's copy the event.json

2:50:54from the test work file

2:50:58to ensure that it's defined.

2:51:02Now our filter job output all the

2:51:03different services

2:51:05and our build push job is going off and

2:51:08setting up and building all those

2:51:10container images.

2:51:13Looks like it's done setting up Docker

2:51:14and it's actually building all those

2:51:16containers in parallel across the

2:51:17different jobs. And so just like that,

2:51:20all five of our container images were

2:51:21built because we're running an act. We

2:51:23did not push them, but our workflow

2:51:25appears to be working as expected. So

2:51:28what else do we need to do here? We

2:51:29still need to get our version tags

2:51:31working properly. This is going to

2:51:32require some nuance around deciding

2:51:34whether we're building for a staging

2:51:36environment or a production environment

2:51:37and then calling into the appropriate

2:51:39tasks. We also copy pasted this install

2:51:42task step across our two workflows. I

2:51:45was thinking it could be useful to

2:51:46abstract this out into a shared

2:51:48composite action along with some of the

2:51:49other tool chain dependency installation

2:51:51stuff that we saw in our test workflow.

2:51:53Now, the convention that I like to use

2:51:54here is to put actions within

2:51:56agitub/action

2:51:57directory. I'll then create a directory

2:51:59with the name of the action I want to

2:52:00use

2:52:02and within that I need an actionl.

2:52:07I'm going to name my action setup

2:52:09dependencies and I'll describe it as

2:52:10setup tools and dependencies. Now

2:52:12eventually we're going to need inputs

2:52:13but for now I'll skip it. Then I can

2:52:15specify the runs key using composite.

2:52:19And now I can specify the steps that

2:52:21should live within it.

2:52:23Let's start by just adding that task

2:52:25step directly from our other workflow.

2:52:30Eventually, we can pull out the version

2:52:31as an input, but for now, let's just

2:52:33leave it as is. Now, within my build

2:52:35push workflow, instead of calling out to

2:52:37that third party action directly, I can

2:52:39replace this with a reference to my

2:52:41composite action.

2:52:45Let's see if that still works. I'll

2:52:47comment out the bottom of this and

2:52:49hardcode my service matrix so I only run

2:52:51one copy of it. But now, if I trigger

2:52:53this

2:52:57Despite moving the task install step

2:52:58into the composite action, it still gets

2:53:00executed here. Let's make this a little

2:53:02more versatile by allowing us to specify

2:53:04the version that we want. So as an

2:53:06input, we might take as a boolean to

2:53:07install task or not.

2:53:11So if this is true, we're going to

2:53:12install task. We'll default it to true.

2:53:15And then we can also specify a version.

2:53:22and we were installing 3.44.1.

2:53:27Also, this needs to be a string. Input

2:53:29types have to be string. So, I'm

2:53:30changing this from a boolean to a string

2:53:32here by adding quotes. Now, within our

2:53:34step here, we can make it conditional.

2:53:37If inputsinstall

2:53:39task

2:53:42equals true. This is a quirk of the fact

2:53:45that you can't pass a boolean as an

2:53:47input. That's why we have to check if

2:53:48install task matches the string true

2:53:50directly. And then we can also template

2:53:53our version here as well as here. Let's

2:53:55run it again.

2:53:58We can see we got our version of task

2:54:00installed. And if we wanted a different

2:54:01version, we could do so by modifying our

2:54:03usage of this composite action to say

2:54:06with task version, let's say v3.44.0.

2:54:14And so we get that other version of task

2:54:16installed.

2:54:18For now, I'll just accept the defaults.

2:54:20Eventually, if there were more workflows

2:54:21that depended on having the Node.js or

2:54:23the Go or the Python tool chains

2:54:25installed and configured, we could move

2:54:26those to the setup dependencies

2:54:28composite action as well and share all

2:54:30of that logic such that each workflow

2:54:32would call the setup dependencies

2:54:33composite action and it would handle all

2:54:35of that tool chain setup for us. Okay,

2:54:37now that we are avoiding that copy paste

2:54:40across workflows, uh let's go ahead and

2:54:42work on on removing those hard-coded

2:54:43version tags. Now, I don't need to

2:54:45actually build the image here to test

2:54:46this. I just want to make sure that my

2:54:48version tags are looking good. In order

2:54:50to do that, I'll actually just echo

2:54:51these to terminal.

2:54:53We don't need to write them to the

2:54:54output.

2:54:59So now if I run this,

2:55:05we see those were echoed out. Also, if

2:55:09we're not actually building the image,

2:55:10we can skip our setup of these

2:55:11dependencies. I'll add them back once

2:55:13I'm done getting this working.

2:55:16There we go. Okay, now we're able to

2:55:17very quickly run our workflow and see

2:55:19that these are coming out properly.

2:55:21There's a few factors that determine

2:55:23what we want our image tag to be. One of

2:55:26which is whether it's the production

2:55:27environment or a non-production

2:55:28environment. And so I'm actually going

2:55:30to add an environment variable here

2:55:33environment. We'll start with the

2:55:35production hardcoded. Eventually, this

2:55:37will be dynamic. And we also need to

2:55:39know which service we are running

2:55:40against. In this case, we can get that

2:55:42from our matrix.

2:55:48Now, finally, because we want to execute

2:55:49the task file from the same directory as

2:55:51the service, we'll set our working

2:55:52directory equal to matrix.service as

2:55:54well. Now, for version, instead of this

2:55:56hard-coded value, we can execute this

2:55:58task. So, task utils generate version

2:56:01passing it the name of our service.

2:56:04And for the image tag, it will be very

2:56:06similar

2:56:08except it'll be generate version tag.

2:56:11These are coming from that imported

2:56:13utils task file. If I look at it, it's

2:56:15going to use git describe, finding a git

2:56:17tag associated to my service, adding in

2:56:19a prefix, and echoing it out. And then

2:56:22for the version tag, we're just

2:56:24appending the image repo to the front of

2:56:26it. So if we go ahead and run this,

2:56:28let's see what we get out.

2:56:30So for my version, I got version 1.0.9.

2:56:33There have been 47 commits to the repo

2:56:35since then. My current git hash starts

2:56:36with 188f7. For the image tag, it pulled

2:56:40the image repo out of my node API task

2:56:42file and appended that to the front. And

2:56:44so now this is a valid container image

2:56:46tag that I could use to tag my image and

2:56:48push it to docker. If I now add these

2:56:50steps back and these steps back, I can

2:56:54make my matrix dynamic again. And with

2:56:56that, I should now be building all five

2:56:58of my container images with appropriate

2:56:59tags. However, this environment of

2:57:01production is still hard-coded. Now, I

2:57:03have a variant of this task called

2:57:05generate extended version tag which will

2:57:08produce these tags where that center

2:57:09number of commits is left padded with

2:57:11zeros. And the reason for this is that I

2:57:13want my versions to sort correctly

2:57:15alphabetically. If you don't left pad

2:57:17this, then let's say 11 commits comes

2:57:19before three commits because it starts

2:57:20with that one if you're sorting

2:57:22alphabetically. This is useful in a

2:57:23variety of other tools. And so as long

2:57:25as we don't go more than 10,000 commits

2:57:27between releases, this format should be

2:57:29good to go. Uh to handle this, I want to

2:57:31add a conditional here that basically

2:57:32says if it is production, we'll use this

2:57:34one. However, otherwise we want to use

2:57:39the extended one.

2:57:42So this will be extended.

2:57:46Extended.

2:57:48Uh let's just test this as staging for

2:57:51example. We'll comment these out again

2:57:53just to validate.

2:57:58And now rather than just having 47 here,

2:58:01we've got 47, which is what we want. So

2:58:04how can we determine which environment

2:58:06we're deploying to? Also, if we're

2:58:08building based on a release tag, our

2:58:10standard path filtering approach is not

2:58:12going to work. Instead, we want to use

2:58:14the tag itself to determine which

2:58:15service it is associated with. So in our

2:58:17filtering job, if we're pushing to the

2:58:18main branch, we want to use the standard

2:58:20approach that we've seen. If we're

2:58:21pushing to a tag, we want to extract the

2:58:23service name from that tag and set that

2:58:26as our service for the matrix. Let's

2:58:27call this step set output

2:58:32ID set output.

2:58:35And for this, we're going to run and

2:58:37we'll have a little inline bash to

2:58:38determine exactly that. We'll start by

2:58:40saying if the GitHub ref is of type tag,

2:58:43then our tags are going to look like

2:58:45service node API at

2:58:49and then a version. We want to extract

2:58:51the tag using the GitHub ref name

2:58:53environment variable. We can then

2:58:54extract the service name from that tag

2:58:56using a bash expansion extracting

2:58:58everything before the at sign. And so if

2:59:00this is our tag, then service will be

2:59:02this. And in this case, because it's

2:59:04coming from a tag, we want to use our

2:59:06production variant. So we can say echo

2:59:08services equals

2:59:12we need to escape our quotes here. Here

2:59:14our services matrix is going to be a

2:59:16single service based on the one we

2:59:17extracted from our tag. We want that

2:59:19into GitHub output

2:59:22and we want to echo environment

2:59:25production. The other case is generally

2:59:27going to be our push domain. For that we

2:59:28do want to use the output of our filter.

2:59:30So we'll output the changes from that

2:59:32step into our GitHub output. and we want

2:59:34to specify that it's going to the

2:59:36staging environment. Now end our

2:59:38conditional at this point. If we're

2:59:39coming from a tag, we'll use this

2:59:41approach. If we're coming from a push

2:59:42domain, we'll use this approach. Now,

2:59:44one more thing that is useful is to be

2:59:46able to manually execute this workflow

2:59:48for a particular service and version. In

2:59:51order to do that, I'm going to add one

2:59:52more conditional. So, this will be else

2:59:54if

2:59:57and in this case, I want to check if the

2:59:58triggering event was a workflow

3:00:00dispatch. So, this allows me to trigger

3:00:02it manually. If it was I'm going to add

3:00:04an input here

3:00:07of service and version for description.

3:00:10This is the service to build.

3:00:14We can make this of type choice. So this

3:00:16will actually give us a drop down in the

3:00:17GitHub user interface. Make it required

3:00:20and specify the options. In this case,

3:00:21we want our options to be the services

3:00:23as defined. For version, we can specify

3:00:26that it's the version to use for tagging

3:00:27images. In this case, we can specify an

3:00:30explicit version or we can let it fall

3:00:32back to the get describe. I can now set

3:00:34my services matrix here using that

3:00:35input. And let's say if we're running

3:00:37this workflow manually, we want it to be

3:00:38the development environment. So now

3:00:40instead of taking it from my filter

3:00:42step, I'm going to take this from my set

3:00:44output step. I also want to take the

3:00:47environment from that step.

3:00:50And I named this services instead of

3:00:52changes as defined here. So now let's

3:00:55see what happens if I run it.

3:00:58Okay, the error that I got is that my

3:01:00task doesn't exist. I think what

3:01:01happened is because I didn't specify an

3:01:03inputs.service, this ended up empty and

3:01:06it tried to run against that empty path,

3:01:08in which case there was no task file and

3:01:10there was therefore no task to find. I

3:01:12can modify my event.json to say

3:01:16inputs

3:01:18service

3:01:21and pass it the node API.

3:01:26And now it worked as expected. This does

3:01:28bring up one additional bug though. If

3:01:29the path filter outputs an empty array,

3:01:32it seems that the downstream job still

3:01:34tries to run, but just with an empty

3:01:35string as the service name. Let's add a

3:01:37conditional to that step to handle that

3:01:40case. I found this issue on the repo uh

3:01:42of someone who ran into a similar

3:01:44problem where when you have an empty

3:01:45array, you hit some issues and the

3:01:47author of the action provides this as

3:01:49one potential option for handling this.

3:01:51This looks good. I think I named my

3:01:53upstream job filter instead of changes.

3:01:56So I can modify that. But otherwise,

3:01:58it's saying only run this job if it's

3:02:00not equal to an empty matrix and it's

3:02:03not equal to an empty string. So I can

3:02:05add my conditional to this job.

3:02:08And that should handle the case where no

3:02:10surfaces have changed. I actually need

3:02:12this in my test workflow as well. So I

3:02:14may as well add it.

3:02:18And now we should be in pretty good

3:02:19shape. One piece of logic I haven't

3:02:21really validated is this bash script to

3:02:23extract the tag from the git tag. If I

3:02:26wanted to validate that, what I could do

3:02:28is add a separate event.json.

3:02:32In this one, instead of using a workflow

3:02:34dispatch type, I'll make this a push tag

3:02:37event.

3:02:39And within this event, I'm going to

3:02:40specify a ref and a ref type of tag. And

3:02:43this corresponds to an actual release in

3:02:45my repo that I want to test against. I

3:02:47now need to add a task to my task file.

3:02:51This case, I'll copy this. We'll call

3:02:54this trigger workflow tag. We'll pass it

3:02:56the name of our new event. This will be

3:02:58a push event. And we want to make one

3:03:01additional tweak and that is to add the

3:03:03no skip checkout flag.

3:03:07And what this does is rather than

3:03:08mounting my local code into the

3:03:10containers as it's testing, it's going

3:03:12to do the checkout based on the ref in

3:03:14my event JSON. Let's give it a shot. So

3:03:17I'll do trigger workflow tag.

3:03:20Ah, it's saying there's no workflows to

3:03:22run and that is because my workflow

3:03:25currently only has a workflow dispatch

3:03:27trigger. Let's add triggers for for push

3:03:29into main and push into tags that match

3:03:31our desired convention. So we can do

3:03:33push branches

3:03:36main

3:03:38paths

3:03:41services or star. Uh so this will build

3:03:44images when we push domain with a change

3:03:46to at least one of our services. And

3:03:48then we also want to trigger on pushes

3:03:50to tags that match a particular

3:03:52convention. So in our case, our

3:03:54convention is going to be any tag that

3:03:57has some string followed by the at sign

3:03:59followed by a semantic version. So

3:04:01number dot number dot number. Now I can

3:04:03attempt to rerun it.

3:04:06It successfully found that trigger.

3:04:09it failed on that same thing we've been

3:04:11hitting before with the node 24 version.

3:04:13Uh this is because the checkout action

3:04:15also was upgraded recently. To get

3:04:17around this, I can use a slightly older

3:04:18version of the checkout action which

3:04:19uses node 20. Again, this would not be

3:04:22an issue when running on actual GitHub

3:04:24action runners. It's just a limitation

3:04:26of my current version of ACT. I'll do it

3:04:28here as well. The reason I didn't hit

3:04:31this before is by default ACT is going

3:04:33to skip the checkout step and mount our

3:04:35local code into the container. But here

3:04:36we want to check out the code from

3:04:38GitHub associated with this tag. And so

3:04:40it hit the issue. If I run it again,

3:04:44looks like we successfully checked out

3:04:46our code.

3:04:50Seems like we still needed this default

3:04:51branch to be included in our tag event.

3:04:54So let's try once more.

3:04:58Scrolling down, it looks like it

3:05:00successfully set our output as the

3:05:02production environment and our service

3:05:04as services go API Golang. So that

3:05:07appears correct. Ah, now our failure is

3:05:10that we are no longer successfully

3:05:13installing task because this API Golang

3:05:16tag is referring to an older version.

3:05:18That composite action doesn't exist. So

3:05:20to get around this, uh, let's go back to

3:05:22how we had it before where we were just

3:05:24installing tasks directly to avoid the

3:05:26need for that composite action to exist.

3:05:27So I'll just copy it from here.

3:05:31We'll temporarily remove this

3:05:34and instead install it directly and try

3:05:36it once more.

3:05:39So that's the benefit of skipping the

3:05:40checkout. You automatically can use can

3:05:42utilize local changes without needing

3:05:44them to exist remotely. However, for

3:05:45testing certain things like this, we

3:05:47want to use an actual git tag. Okay,

3:05:49interesting. So it successfully

3:05:51generated a version based on the tag

3:05:53that we gave it. However, in this case,

3:05:55for a production image, I would want

3:05:56this to just be 1.0.1 without the

3:05:59extended information. Let's see where my

3:06:01bug is. We've got environment

3:06:02production.

3:06:04Ah, so my environment was still

3:06:06hardcoded to staging here, which is why

3:06:07I got that extended git tag. Instead, I

3:06:10can do

3:06:11needs

3:06:13filter outputs.

3:06:19Uh, which I set up above

3:06:22here. And now if I rerun it, let's see

3:06:25if I get just the 1.0.1

3:06:28as my version.

3:06:32And there we go. Now it appropriately

3:06:34identified that because I'm in

3:06:36production, I want to use the shorter

3:06:38version rather than the extended

3:06:40version. And we see that reflected here.

3:06:42Now, if you'll remember, I also passed a

3:06:43version in via the workflow dispatch.

3:06:46And let's go ahead and use that if it's

3:06:48defined. So I'll make this an else-if.

3:06:50Make this an if. We can extract the

3:06:53input version here

3:06:56and that'll either be and say if it's a

3:06:58workflow dispatch go ahead and pull it

3:07:00out otherwise set it to an empty string

3:07:02in our script. If the input version

3:07:04exists, we'll go ahead and use it

3:07:08by storing it in the version variable

3:07:10and then passing that into another task

3:07:12target we have called manual version

3:07:14tag, which rather than using git

3:07:16describe is going to take that version

3:07:17specified and append it onto the repo

3:07:20defined within the task files. To

3:07:22validate that, I can go back to my event

3:07:24here. Let's call it a workflow dispatch

3:07:28event. We can add version

3:07:32fu bar as update this task name to

3:07:36workflow dispatch

3:07:39reference the new name of the action and

3:07:41now because we have a version specified

3:07:42in that workflow dispatch we should hit

3:07:45that branch of the conditional use it

3:07:47and tag our image accordingly

3:07:50and so here you can see the version is

3:07:52fubar baz and the image tag ended up

3:07:54with my node API as the repo and the

3:07:56fuar baz as the version so now That's

3:07:58working as expected. At this point, we

3:08:00can go ahead and push it to our branch.

3:08:06And now, because we don't have a trigger

3:08:08for pull requests and we're only

3:08:09triggering on pushes to the main branch,

3:08:11we wouldn't expect any runs of this

3:08:12workflow to run. But because we've used

3:08:14ACT to validate the different

3:08:16conditions, I have relatively high

3:08:17confidence that it's going to behave as

3:08:18expected. We'll see this in action once

3:08:20we get the rest of our workflows in

3:08:21place and we actually start to merge

3:08:23some PRs modifying services within the

3:08:25repo. I can go ahead and uncomment the

3:08:28steps that I have commented that were

3:08:29just there for testing purposes.

3:08:37Push that to GitHub. Okay, with our test

Capstone: Update GitOps Manifests Workflow

3:08:39workflow and our build push workflow in

3:08:41good shape, the next major workflow that

3:08:43we want to build out is going to be that

3:08:45one in the bottom right, the update

3:08:46GitOps manifest workflow. So, this is

3:08:48going to be kicked off at the end of our

3:08:50build workflow. It's going to take the

3:08:51version of the images that are built and

3:08:53update the corresponding references

3:08:54within our git repository for the

3:08:56Kubernetes manifest that would deploy

3:08:57these applications out. So, we're going

3:08:59to kick it off from that build push

3:09:00workflow. We're going to check out the

3:09:01codebase containing those

3:09:02configurations, update them, and then

3:09:04commit those back to the repo. Now, I

3:09:06could have put this logic as a separate

3:09:08job within my build push workflow.

3:09:10However, I think separating it out makes

3:09:11it a little cleaner and easier to read.

3:09:13Also, having them decoupled allows me to

3:09:15run and test one without impacting the

3:09:16other. I have this placeholder workflow

3:09:18here that will update to handle this

3:09:20process. The first thing we're going to

3:09:21need to do is to check out the code.

3:09:24I'll use the same version. I'm using my

3:09:27other workflows

3:09:29as inputs to this workflow. I'm going to

3:09:30take both the service, the version, and

3:09:34the environment.

3:09:36All three of those are going to be

3:09:38required.

3:09:40We'll give them a description.

3:09:45We'll specify their type. In this case,

3:09:47I'm going to use choice. This will allow

3:09:49me to have a specified set of options to

3:09:51choose from to ensure I don't

3:09:53accidentally input an incorrect service.

3:09:55And these are going to be all the

3:09:56choices that are available.

3:09:58I forgot the input key.

3:10:01Version will be of type string.

3:10:04For environment, let's have three

3:10:06choices. Let's say

3:10:08development,

3:10:10staging, or production. I'm going to

3:10:12have a single job called update

3:10:14manifest.

3:10:16Most of the heavy lifting here for

3:10:18actually modifying the state of the repo

3:10:20and committing that back is going to be

3:10:21handled with task. So once again, I'll

3:10:23need to install my setup dependencies

3:10:25composite action. Now I can copy that

3:10:27from my build push action.

3:10:29Copy the reference to my composite

3:10:31action here. We configure that to by

3:10:33default install task at the appropriate

3:10:35version. So we don't need any inputs.

3:10:37Let me show you within my task file the

3:10:39script that's actually going to do this

3:10:40updating. It's going to be under

3:10:43utils tax file update image tags. And so

3:10:46what this is going to do is going to

3:10:47recursively go through my repo and find

3:10:50any file containing a specific comment

3:10:52within that file, replace the version

3:10:54associated with that comment.

3:10:56Essentially, I provided a tag, a comment

3:10:58to look for, and a starting path within

3:11:00my repo as the entry where it's going to

3:11:02recursively search from. I also have a

3:11:04set of specified excluded files such

3:11:06that this action doesn't modify this

3:11:08task file or somewhere else. If I had a

3:11:09read me where one of these identifier

3:11:10comments was defined, you can see I'm

3:11:12just searching for all YAML files from

3:11:14that starting path, grepping for my

3:11:17identifier comment, and then looping

3:11:18through each of those and updating them.

3:11:20There was a weird quirk around how the

3:11:22default version of said on MacOSS and

3:11:24Linux behaved because I'm using Devox

3:11:27and installing said there, it should use

3:11:28the new version and behave

3:11:30appropriately. For a particular service,

3:11:32I'm formatting my identifier comment as

3:11:34the environment underscore and then the

3:11:36service release tag. Let me show you

3:11:38what this looks like. Under the deploy

3:11:41subdirectory under Kubernetes and

3:11:43Clucuddle, I can click into the services

3:11:45and find one of my manifests. So this is

3:11:48the deployment YAML file that will

3:11:51handle the deployment of my node API. As

3:11:53you can see under the image tag, we're

3:11:55using this template for API node. If we

3:11:57look in the config directory, there's a

3:11:59production.yamel and a staging.yamel.

3:12:01The staging.yamel has the version we

3:12:04want deployed to staging with that

3:12:05identifier comment. So again, it's

3:12:07environment underscore and then path to

3:12:09service. In the production environment,

3:12:11it looks similar. However, we're using

3:12:13the production prefix instead. So when

3:12:17we run this update image tag, passing it

3:12:19an appropriate environment and service

3:12:21release tag, it's going to recursively

3:12:22find all those associated references and

3:12:25update them accordingly. So I can have a

3:12:27step update manifests. We need to set

3:12:30the working directory to call this from

3:12:32within the appropriate service.

3:12:36And then we're going to call that task

3:12:37that I just showed and we're going to

3:12:39pass it the new tag as the version

3:12:42that's being input here

3:12:45and the environment

3:12:47from the inputs as well.

3:12:50As a test, I'm just going to run get

3:12:52diff

3:12:54to see what this command modified. Now,

3:12:56I want to be able to run this. So, I

3:12:58need to create my act command. I'll go

3:13:00ahead and create a directory

3:13:04with a task file referencing this

3:13:06workflow.

3:13:09It's using the workflow dispatch. I

3:13:11should no longer need this because I'm

3:13:12not using that filter step. But I do

3:13:15want inputs

3:13:17service.

3:13:19Let's use node as our example. Services

3:13:21slash node API node

3:13:26version. We'll just set it to foo bar as

3:13:30and environment.

3:13:33We'll test with staging.

3:13:36Now, if I trigger my workflow,

3:13:41it's not properly picking up the version

3:13:43of said to use. Let's see why that is.

3:13:45So, in my task file,

3:13:48it's trying to pull in this variable to

3:13:51use as the said binary. top. And so

3:13:55previously I had been dynamically

3:13:56detecting whether I had the Mac OS

3:13:58version of said or the new version of

3:14:00said. Once I added that to Devbox, I

3:14:02removed that. And so this is no longer

3:14:04necessary here.

3:14:06And I can just call set directly.

3:14:10If I run it again,

3:14:12we can see this time it did run

3:14:14successfully, but it's showing us a diff

3:14:15associated with our

3:14:18our local updates

3:14:21and all the changes we haven't committed

3:14:23yet. Let me go ahead and commit these to

3:14:24my branch

3:14:28and run it once more.

3:14:30All right. Now, the only diff we see is

3:14:32that it found my version within that

3:14:34staging configuration file for the node

3:14:36API and replaced the current version

3:14:39with fuar baz that I input. This is

3:14:40exactly what I wanted. Let's try it

3:14:42again and instead make my input have the

3:14:44production environment.

3:14:47I'll also modify this to be bing. So now

3:14:49we wanted to find the production config

3:14:51for the node API and replace the current

3:14:53version with fubarb as bing. First we

3:14:55see the diff of our event.json. We don't

3:14:57actually care about that because get

3:14:58diff is an interactive command. It's not

3:15:00actually logging that all the way out.

3:15:01Let's modify our test command

3:15:04to add the no pager option to our diff.

3:15:08This should spit out the full diff to

3:15:09the terminal.

3:15:11And so we see both the change to our

3:15:13event.json that's expected, but also it

3:15:15identified that production.yamel YAML

3:15:17based on the corresponding comment and

3:15:19replace this version with the new

3:15:21version specified. So it appears to be

3:15:23behaving as expected. However, this

3:15:25change only exists in the workflow at

3:15:27runtime and now we need to commit it

3:15:28back to the main branch. When I first

3:15:30attempted this, I used this third party

3:15:31action get auto commit. It takes

3:15:33whatever modifications you've made

3:15:34during your workflow and commits those

3:15:36back to a specified branch. This worked

3:15:38fine when I was testing it. However,

3:15:40because I can potentially have many of

3:15:42these workflow deploy GitOps update

3:15:44workflows running at once, I ran into

3:15:46the case where I would check out the

3:15:48code, make my changes, and another

3:15:49workflow independently would commit in

3:15:52the meantime. There's a couple of ways I

3:15:54can handle this. One is I can modify the

3:15:56concurrency settings for my workflow to

3:15:58ensure that they all run in serial. This

3:16:00would work, however, it would be slow

3:16:02and not necessarily ideal. What I did

3:16:04instead was move some of this logic into

3:16:06my utility task file into this commit

3:16:08push action. So here I'm taking in a

3:16:10commit message, a branch, a number of

3:16:12retries, some amount of delay, and then

3:16:15some amount of jitter so we're not

3:16:16retrying at the exact same time. This

3:16:18will take the changes that are made in

3:16:20my workflow, attempt to commit them if

3:16:22they fail because of a conflict or

3:16:23another update to the main branch. It

3:16:25will pull the latest from main, attempt

3:16:27to reconcile those, and then attempt to

3:16:29recommit again. Now, by default, I'm

3:16:30going to retry up to five times with

3:16:32exponential backoff in between. By

3:16:34default, I'm going to have a commit

3:16:36message structured like this. chore

3:16:38automated update skip CI. However, we

3:16:40can generate a commit message that

3:16:42contains information about which service

3:16:44is being updated. Let's go ahead and do

3:16:46that now. So, before I update here,

3:16:50because I'm going to use an output from

3:16:51this step, I want to give it an ID.

3:16:57And essentially, I'm going to take the

3:16:58information provided as inputs, the

3:17:00service, the version, and the

3:17:00environment, and write the corresponding

3:17:02commit message. So I want to say chore

3:17:04update environment comma service comma

3:17:07version skip CI. Now the skip CI tag

3:17:09will tell GitHub not to run any

3:17:10associated workflows on those commits.

3:17:12Then I want to store that to the output.

3:17:16And now we can consume that down below

3:17:18when we actually perform our update.

3:17:21Because this is actually modifying the

3:17:23state of the repo. I'm not going to run

3:17:25this within act. So I'll add my

3:17:27conditional to avoid running this

3:17:28locally. This is the type of thing

3:17:30that's quite hard to test locally

3:17:32because you're actually modifying the

3:17:33remote state of the git repo. I need

3:17:36this to run from within my service

3:17:38directory.

3:17:40And the actual command I want to run is

3:17:41going to be a task command coming from

3:17:43that utils task file. And it's going to

3:17:45run get commit push. We do need to

3:17:47provide some inputs. So the commit

3:17:49message

3:17:51is going to equal

3:17:53the output from that other step.

3:17:56the branch that we want to commit back

3:17:58to.

3:18:00So now we have the ability to generate

3:18:02the necessary modifications to our

3:18:03GitOps manifest as well as an

3:18:05appropriate commit message and then

3:18:06commit those back to the main branch.

3:18:07One thing I mentioned earlier is

3:18:09controlling the concurrency of this. So

3:18:11it's likely that this workflow is going

3:18:12to have lots of different instances for

3:18:14different services that are being

3:18:15updated in parallel. So let's add a

3:18:17concurrency key with a group and a

3:18:21cancel in progress. We can use this so

3:18:23that only one of these workflows is

3:18:25running per service per environment. To

3:18:26do that, we want to define the group

3:18:28that includes the workflow, the

3:18:30environment, and the service in the key.

3:18:33This way, let's say we had two updates

3:18:35to staging happen back to back for the

3:18:37same service. We'll run the first

3:18:38workflow to its entirety, deploy that

3:18:40version. Once that is finished, we'll

3:18:42deploy the second version. This helps to

3:18:44avoid two updates trying to be applied

3:18:46to the exact same configuration file at

3:18:49once. Let's run it once more.

3:18:53Okay. And with that, we have this

3:18:54workflow working as expected. However,

3:18:56we still need to trigger this from our

3:18:58build workflow. So, jump back to build

3:18:59push.

3:19:01We can add another step to our build

3:19:03push job. This will be trigger deploy.

3:19:07Again, we don't want to actually trigger

3:19:09the deploy when running with an act. So,

3:19:10we can add our conditional

3:19:13here. We need to pass it a GitHub token

3:19:15because we're going to make API calls

3:19:16against the GitHub API.

3:19:19And the GitHub CLI uses this environment

3:19:22variable to load in the token.

3:19:25In order for us to have permissions for

3:19:26this token to make commits back to main,

3:19:28we need to update our job permissions.

3:19:30So under the build push container image

3:19:32job, we can add permissions key and give

3:19:35it the ability to not only read the

3:19:36contents of the repo, but also write to

3:19:38our actions. Now we just need to issue a

3:19:40GitHub CLI command to trigger that

3:19:42workflow. That's going to be GitHub

3:19:45workflow.

3:19:47pass it the file where the workflow

3:19:48lives along with the inputs of our

3:19:52service, our version, and our

3:19:54environment. When this workflow ends,

3:19:56each of those matrix jobs is going to

3:19:58trigger a copy of that other workflow to

3:20:00update the GitOps manifests. At this

3:20:02point, we have our three primary

3:20:03workflows built out. We can make

3:20:05modifications to our codebase, test them

3:20:07on a pull request. When we merge that,

3:20:09and push it to main, it will build our

3:20:11images and deploy those out to a staging

3:20:13environment. We could also manually tag

3:20:15a release for any of our services by

3:20:17pushing a git tag with the appropriate

3:20:19format. However, there's a couple of

3:20:20additional workflows that I want to

3:20:22implement to make our lives a little

3:20:23nicer. First, we can automate that

Capstone: Release Automation Workflow

3:20:25release process using a tool from Google

3:20:27called release, please. It'll look at a

3:20:29specific format of our commit messages

3:20:31to determine the next appropriate

3:20:32version and automatically generate pull

3:20:34requests and tags to release our code to

3:20:37production. Also to keep our also to

3:20:39keep our repo tidy, I'm going to use the

3:20:42stale action from GitHub to look at any

3:20:44issues and pull requests that haven't

3:20:45been touched or updated in some period

3:20:47of time, mark those as stale, and close

3:20:49them. And then finally, to give us

3:20:51better visibility into our into the

3:20:52execution timing of our workflows, I'm

3:20:54going to export the timing data from our

3:20:56test and build push workflows into

3:20:58Honeycomb where we can track and observe

3:21:00them over time. All three of these

3:21:02workflows are pretty much just

3:21:03leveraging a single third party action

3:21:05to handle the functionality. And so

3:21:07implementing them is mostly an exercise

3:21:08of reading the documentation and

3:21:10applying them to craft the correct

3:21:12configuration for your repo. Rather than

3:21:14build them out in real time, I'll

3:21:15showcase the action documentation and

3:21:17how I've configured them to run within

3:21:18our project. Let me go ahead and check

3:21:20out the main branch

3:21:22and we'll pull up the release please

3:21:24workflow

3:21:27here. The primary execution is going to

3:21:28be on every push to the main branch. As

3:21:31I mentioned, we're going to have a

3:21:31single job with a single step. It's

3:21:33going to run this third party action.

3:21:35And then we need to configure a few

3:21:36things. First, we're passing in a token.

3:21:38Now, I could have used the autogenerated

3:21:40token with the appropriate permissions

3:21:41here. However, there's a nuance with

3:21:43GitHub actions. Any workflow runs

3:21:45triggered from within GitHub actions

3:21:47using that token will not run associated

3:21:49workflows. One way to get around that is

3:21:51to use a personal access token instead.

3:21:54In this way, the PRs generated by this

3:21:56workflow will behave as though they were

3:21:58created by whichever user this personal

3:22:00access token is associated with. Next,

3:22:02I'm passing it the path to a config file

3:22:04and manifest file. I'll pull those up in

3:22:06a second, but this is how we actually

3:22:07configure the tool to behave how we want

3:22:09and tell it about our various services.

3:22:11Also, I'm targeting the main branch as

3:22:14the branch to create PRs against. The

3:22:16manifest file is located at

3:22:18github/utils/release

3:22:20manifest and it contains a listing of

3:22:22all our services and the current

3:22:23version. Now, there is some

3:22:25documentation in the release

3:22:26documentation talking about how to

3:22:28bootstrap this. You can actually install

3:22:30release please on your system and run it

3:22:32locally passing in a GitHub token to

3:22:34bootstrap the various files. In my case,

3:22:36I just crafted this manifest file by

3:22:38hand and input the correct starting

3:22:39version for each of my different

3:22:41services. The release please config file

3:22:43is where most of the important

3:22:44configuration lives. The sections that

3:22:46we want to look at here is this packages

3:22:47object. So here we have one entry for

3:22:49each of our different packages. This

3:22:51first part is a path within the repo to

3:22:53that package and then we have a

3:22:55component name and then a release type.

3:22:57The release type determines what

3:22:58modifications release please is going to

3:23:00make whether it's updating your

3:23:01package.json file or a go.mod file. And

3:23:04there's a number of different release

3:23:05types available within the

3:23:06documentation. You can see here we're

3:23:08using node python and go. Line four is

3:23:10important because we have this monor

3:23:12repo with multiple services in it. We'll

3:23:13include the component name in our git

3:23:15tag so that we don't have conflicts

3:23:17between tagging different services. I'm

3:23:19opting not to prefix with the letter v.

3:23:21You can choose to either have v prefix

3:23:23so vxy.z or just x.y.z Z in your tag.

3:23:28And then down here at the bottom, I'm

3:23:30using a plugin called linked versions to

3:23:32tie together my Go API with the migrator

3:23:34code. So if either of these two are

3:23:36bumped, those versions will move in lock

3:23:37step and be released in tandem. Now, one

3:23:40final change that I made here is this

3:23:42change log sections block. So by

3:23:45default, release is going to look for

3:23:46commit messages that start with the

3:23:48strings fix or feet. So if we look here

3:23:51under conventional commits, this is the

3:23:53format that it's using. So you'll have a

3:23:57message that starts with either fix

3:23:59colon or feat colon and then the rest of

3:24:01your message. So here this would be for

3:24:04a new feature allow provided config

3:24:06object to extend other configs. You also

3:24:08can specify that something is a breaking

3:24:09change by adding it here in the body of

3:24:11your commit message or you can append an

3:24:15exclamation point after the type. If you

3:24:17do either of these things it will bump

3:24:18the major version of your semantic

3:24:20version with the next release. Now, by

3:24:22default, release please would would only

3:24:24use these first two types. However, when

3:24:26using this in the past, I found it

3:24:28convenient to also include chore and

3:24:30revert when determining the appropriate

3:24:32messages. And so, with that in place,

3:24:33let's go ahead and take a look at how

3:24:34this works. Now, I have a few pull

3:24:36requests here that I have added

3:24:38throughout filming this to make

3:24:40modifications. Uh, one of which was when

3:24:41we added the no pager option for the get

3:24:43diff command. Let's go in here.

3:24:47I made this change. I'm following my

3:24:49conventional commit format and I'll go

3:24:51ahead and squash this and merge it.

3:24:54Now, because this is a new commit to the

3:24:56main branch, we should see a release

3:24:59please action run. It's going to check

3:25:01out our code, look at all of the

3:25:03different commits that have happened

3:25:04since our last release, parsing them for

3:25:06that conventional commit format to

3:25:07determine the appropriate services to

3:25:09update and what version to use before

3:25:12creating or updating a corresponding

3:25:13pull request. So, if I go in here and

3:25:16look at this pull request that was

3:25:17created a while back, I can see it's

3:25:20detected all of these different changes

3:25:21since my last release. Within the PR

3:25:23description, we can see what they are

3:25:25and which commits they came from. So, in

3:25:27this case, I added a dependency install

3:25:28step. I added some example tests as new

3:25:31features for my node API. And you'll

3:25:34notice the version for my Go API and my

3:25:36migrator code are identical because I'm

3:25:38using that linked version plugin. It

3:25:40also includes this autorelease pending

3:25:41label on my PR. Once I merge this, that

3:25:45will trigger another execution of

3:25:46release main where rather than create a

3:25:48PR, it's going to go off and actually

3:25:49create these tags. Those tags will

3:25:51trigger runs of my build push workflow

3:25:53for the various services, which in turn

3:25:55will kick off my GitOps manifest

3:25:56workflow. Let's see that happen.

3:26:01So, I've merged my release PR.

3:26:04It kicked off a few workflows from that

3:26:06main branch, but now when release runs,

3:26:09it actually tags our services. So, we

3:26:11see our Go API is now tagged at 1.3.0.

3:26:13It also creates a GitHub release.

3:26:17We can see under the GitHub releases

3:26:19page. This was just released a minute

3:26:21ago as well as this. Going back to our

3:26:23actions,

3:26:25we look at our build push container

3:26:27images workflow.

3:26:30Each of our services had a build and

3:26:32when they complete they trigger they

3:26:35trigger via that workflow dispatch the

3:26:37update gi manifest workflow. Okay, we

3:26:40see some of them are succeeding, some of

3:26:41them are failing. Let's check out why

3:26:43one of these might be failing. Ah, so

3:26:45this was that issue we had with said

3:26:47that we patched while recording. I

3:26:49haven't merged this workflow yet. So,

3:26:50let's go ahead and do that.

3:26:54And you'll notice even though we ran our

3:26:56release, please workflow, we didn't get

3:26:58a new release PR. That's because that PR

3:27:01didn't modify anything that corresponded

3:27:04to our services. Let's make a quick PR

3:27:06that touches one of these services and

3:27:07see it play out. I'll pull the latest on

3:27:10main.

3:27:12We'll make some trivial change to one of

3:27:13our services. Let's do the Go API.

3:27:17I'll just remove a new line here. We'll

3:27:19create a branch to put commit that to

3:27:22commit it. Push it.

3:27:26Create a PR from that branch.

3:27:30Merge our pull request.

3:27:34Check the release police action.

3:27:38We can see it created a new PR number

3:27:4040. If we go to pull requests and look

3:27:43at it, you'll see it detected our change

3:27:45to our Go API, including that commit we

3:27:47just made. Let's go ahead and merge this

3:27:50to release that change.

3:28:01It's created a new release for both our

3:28:03Golang migrator code as well as our

3:28:05Golang API code.

3:28:10Now that those two have completed, it

3:28:12should have triggered two runs of our

3:28:13update getoff manifest workflow.

3:28:20Clicking into it, we can see it's

3:28:21deploying against our production

3:28:22environment.

3:28:25If we look at our git diff step, it

3:28:27correctly identified that it should

3:28:28update the configuration for our

3:28:30production environment with the

3:28:32appropriate new tag.

3:28:34It then committed that back to git.

3:28:37Let's look at our repo and we can see

3:28:39the latest commit to the repo is from

3:28:41that GitHub actions bot making the

3:28:44desired change. And so now we have this

3:28:46automated approach to producing releases

3:28:49of our different services and tagging

3:28:50them with an appropriate version based

3:28:52on the changes that have been made. Now

3:28:54the final two workflows that I'm going

3:28:55to add are more of just auxiliary

Capstone: Stale Issue/PR Workflow

3:28:57workflows that I think are nice to have.

3:28:59Uh, the first of which is to use the

3:29:02official GitHub stale action to

3:29:04determine if there are issues and PRs

3:29:05that have not been touched in a while

3:29:06and clean those up. Here's the

3:29:08marketplace page for that. It specifies

3:29:10that you should give it certain

3:29:11permissions depending on whether you

3:29:12want it to be able to delete branches or

3:29:13just close the corresponding PRs. And

3:29:15I'm going to use this in its most simple

3:29:17format. There's a number of options you

3:29:18can provide. You can tell it how many

3:29:20days you want uh an issue to be

3:29:21untouched before you mark it as stale.

3:29:23I'm going to go with all the defaults

3:29:24here, which will use 60 days before

3:29:26something is stale and then another 7

3:29:28days before it actually closes. Because

3:29:30I'm using all the defaults, my usage

3:29:32will look very simple. I'm going to run

3:29:34this daily. I'm giving it permissions to

3:29:37write to both issues and pull requests,

3:29:39which will allow it to add comments and

3:29:40close those if they are stale. I have a

3:29:42single job with a single step using the

3:29:45latest version of that action. Now, I

3:29:47set this up a while ago, so we should

3:29:49have an issue to test the stalebot. I

3:29:50created it July 22nd and it looks like

3:29:53we haven't waited quite long enough for

3:29:55that default 60 days to pass. Let's go

3:29:57ahead and modify this to shorten our

3:29:59input and say days before stale

3:30:04uh we'll just call it 30.

3:30:07Shorten stale length. Commit that back.

3:30:13We'll want to merge our PR back to main.

3:30:24And now rather than wait for that daily

3:30:26run, I do have a workflow dispatch

3:30:27trigger on it. So I can go ahead and run

3:30:29it now.

3:30:37It queried against the GitHub API. It

3:30:38found one stale issue because now the

3:30:40issue I just showed is older than that

3:30:4230-day threshold.

3:30:44Let's see if it added a comment. If I go

3:30:46to my issues to test stalebot,

3:30:49ah, it didn't add a comment. It added a

3:30:51label stale. And now it will use that

3:30:53label. And after 7 days, if nobody has

3:30:55touched this issue, it will go ahead and

3:30:56close it. So workflow like this is super

3:30:58easy to set up and helps to keep your

3:31:00repo tidy. And the final workflow that

Capstone: Export Timing Data Workflow

3:31:02I'm going to show is this export timing

3:31:04data workflow. I showed earlier in the

3:31:06course some of the results of this data

3:31:08going into honeycomb and being able to

3:31:10break down at a very granular level and

3:31:12track over time the different execution

3:31:14timing of our workflows. But I'll just

3:31:16show how I've configured this. The

3:31:17action that I'm using is this open

3:31:19telemetry CI/CD action. It actually

3:31:21exports it using an open telemetry

3:31:23format. So that can go into a variety of

3:31:26platforms including Axium, New Relic,

3:31:28Honeycomb. I like honeycomb so that's

3:31:30the one I've used.

3:31:34You list out the set of workflows that

3:31:35you want to run against. This is the

3:31:36name of the workflow as defined in the

3:31:38workflow file. And then I'm specifying

3:31:40that I want to execute this when these

3:31:42workflows complete. I now have a single

3:31:44job with a single step using that third

3:31:46party action. I've pinned it to the

3:31:47latest version. This will be dependent

3:31:49on whichever provider you're using. In

3:31:51this case, I'm setting it to the

3:31:52Honeycomb API. I've then generated an

3:31:54API key from the Honeycomb platform. So,

3:31:56if you go here to your Honeycomb account

3:31:57and you go under manage data, send data,

3:32:00you'll see this API key here. You could

3:32:02click it, copy or view. And now the

3:32:05particular format that this is looking

3:32:06for is to specify it within a secret

3:32:09under the X Honeycomb team OTLP header.

3:32:12So I created a GitHub secret called

3:32:14honeycomb OTLP headers. I then passed it

3:32:17this value including the API key that I

3:32:19grabbed from the honeycomb platform. I

3:32:20then also extracted the run ID from the

3:32:23triggering event so that it knows to go

3:32:25off and find the associated timing data

3:32:27from that GitHub event. And so each time

3:32:30any of these workflows run, we'll get a

3:32:32copy of this workflow, which will grab

3:32:34the associated timing data, format it

3:32:36appropriately using the open telemetry

3:32:38standard, and export that to Honeycomb.

3:32:40So for all those workflows that I've

3:32:41just been running, if we go under

3:32:42actions

3:32:44over the last 15 minutes, I've got a

3:32:46whole bunch of copies of this. They only

3:32:48take about 10 seconds to complete. They

3:32:50grab the associated timing data from Git

3:32:52from the GitHub API and ship that off to

3:32:54Honeycomb. Now, if I go to Honeycomb and

3:32:58go home in the last 30 minutes, you can

3:33:01see I've sent a whole bunch of spans

3:33:03associated with these different builds,

3:33:05I can click in to view a particular

3:33:07trace

3:33:08and see the breakdown to the step level

3:33:10of how long they took and the sequence

3:33:13in which they executed. I'll leave it as

3:33:15an exercise to the viewer to go off and

3:33:16build dashboards on this data to do

3:33:18things like track this over time, set up

3:33:20alarms if things get slower than

3:33:21expected, etc. And so with that, we've

3:33:24got a pretty robust DevOps system built

3:33:26out using GitHub actions that allows us

3:33:28to do both continuous integration as

3:33:30well as continuous deployment via GitOps

3:33:32and some niceness around developer

3:33:34experience associated with the repo and

3:33:36understanding the execution of our

3:33:37actions over time. Now, throughout the

Capstone: Using Namespace for Improved Performance

3:33:39duration of the course, you've heard me

3:33:40talk about the sponsor, Namespace. Let's

3:33:42spend the last few minutes showcasing

3:33:44what it takes to migrate an entire

3:33:46project like this over to using

3:33:47Namespace for the runners, for caching,

3:33:50and for remote container builds. As you

3:33:52can see from just a handful of lines on

3:33:53this slide, it's a relatively painless

3:33:55process where we'll find and replace a

3:33:57bunch of runs on types, specify some

3:33:59labels to ensure that our caches behave

3:34:00appropriately, and even remove a few

3:34:02setup steps which are no longer

3:34:03necessary. With these changes in place,

3:34:05my runner execution is going to cost

3:34:07about half of what it would on the

3:34:08GitHub hosted runners and my execution

3:34:10times from the improved runner

3:34:11performance, the improved caching, and

3:34:13the remote builders allowing me to build

3:34:15multiple architectures natively should

3:34:17significantly reduce my execution time.

3:34:19We can start by replacing Ubuntu 24.04

3:34:22across all of our different workflows. I

3:34:24can search across the whole project for

3:34:25the string and then I can replace it

3:34:27using one from namespace. We saw earlier

3:34:28in the course how you can use namespace

3:34:30profiles to specify the operating

3:34:32system, the machine type, etc. However,

3:34:35rather than manually managing profiles,

3:34:37you can also use labels with this

3:34:39convention nscloud, the operating

3:34:42system, the architecture, and then the

3:34:43shape meaning how much CPU and memory to

3:34:45achieve the same thing. So I'm going to

3:34:47replace runs on iuntu 24.04 with runs on

3:34:53namespace cloud iuntu 24.04 AMD 64 4 CPU

3:34:58and 16 gigs of memory.

3:35:03Replace all. And so just like that I've

3:35:05cut over all of my workflows to run on

3:35:07namespace cloud runners. Now that we are

3:35:09running on namespace cloud runners

3:35:11there's a couple of steps in my build

3:35:12push workflow which are no longer

3:35:13needed.

3:35:15Specifically,

3:35:19setting up Q emu and setting up build X

3:35:22will be handled for us by the runner. Q

3:35:24emu won't be used at all since namespace

3:35:27will execute these builds on separate

3:35:29runners, one AMD 64 and one ARM 64, so

3:35:32that those builds run natively and can

3:35:34execute much faster. And then build X

3:35:36will already be configured to point to

3:35:38those remote builders. If you wanted to

3:35:40use the remote container builders from a

3:35:42different runner instead of running this

3:35:44docker buildx setup action,

3:35:47you could run the namespace cloud setup

3:35:49action followed by the setup build x

3:35:51action to configure buildx accordingly.

3:35:54Now the final piece that I want to

3:35:55change is to use namespace cache volumes

3:35:58rather than the github action cache node

3:36:00go and python dependencies. Once again,

3:36:02you can manage cache volumes with

3:36:04profiles. You can also use labels within

3:36:05the runs on section. And so that's what

3:36:07I'll do now within the test workflow.

3:36:09This filter job doesn't need any

3:36:11caching. So we can leave it as is.

3:36:14But we want to establish cache volumes

3:36:16for each of our different services. That

3:36:18way each of them caches the specific

3:36:19dependencies of that service and don't

3:36:21pollute the other caches and namespace

3:36:23doesn't get confused about which files

3:36:25ought to be stored in that cache. The

3:36:27documentation for using this runners

3:36:29label feature is here. We'll append with

3:36:31cache to the end of our machine label.

3:36:34And then we'll add some additional

3:36:36labels here to specify the size of the

3:36:38cache and ensure that each service gets

3:36:40its own. This label will give us a 20 GB

3:36:42cache. This one tells namespace to only

3:36:45commit new files to the cache from main.

3:36:48This will avoid unnecessary updates or

3:36:49polluting our cache from PRs which may

3:36:51have different dependencies and will

3:36:53generally always be pulling a cache

3:36:54volume in sync with the latest version

3:36:57of Maine. Finally, we want to specify a

3:36:59unique tag for each service such that

3:37:01cache volumes get provisioned for each

3:37:02of them. My first attempt at this was to

3:37:04reference matrix.service,

3:37:08which seems like it should work.

3:37:09However, you can't have forward slashes

3:37:11in this context. And because my services

3:37:13are named after the path within the

3:37:14repo, they have forward slashes in them.

3:37:16Instead, I ended up needing to modify my

3:37:19filter job to include an additional

3:37:21matrix value, replacing those forward

3:37:23slashes with dashes.

3:37:25I add a step here which uses this jq

3:37:28command to generate a JSON object with

3:37:31one entry for each service containing

3:37:33the service as it was specified before

3:37:35with the forward slashes as well as a

3:37:38new value cache label where we're

3:37:40substituting a forward slash for a

3:37:42double dash. I'm then writing this to

3:37:44the output of this step.

3:37:47Need to also add it as an output of the

3:37:49job from that step. At which point

3:37:51instead of specifying matrix service

3:37:52here I can replace this

3:37:55with a matrix include key where I

3:37:58consume that array of services that

3:38:00contains my new key. So before I had

3:38:02services

3:38:05fu/bar

3:38:08baz slashbing

3:38:11and the matrix strategy would run one

3:38:13job for each. I now have include

3:38:19service

3:38:22service

3:38:23fu bar

3:38:26cache label

3:38:29fu bar

3:38:33baz bing.

3:38:35And so by passing this object into my

3:38:38include I'll get one job with these as

3:38:40my inputs for that job. I'll still be

3:38:42able to reference matrix.service exactly

3:38:44as I was before, but I'll also have this

3:38:46cache label available to me, which I can

3:38:48use to create a service unique tag to

3:38:50tell namespace to create a cache volume

3:38:52for each service independently. However,

3:38:54just because we created a cache volume

3:38:56doesn't mean we're using it quite yet.

3:38:58Now, I need to configure for my

3:38:59different services where specifically

3:39:01within the file system namespace should

3:39:02cache. That's going to depend on the

3:39:04specific runtime or package manager

3:39:06that's being used. For this namespace

3:39:08provides a custom action with examples

3:39:10of how to configure it for go, yarn,

3:39:12rust, etc. Or you can specify a set of

3:39:14paths within the file system to cover

3:39:16any case you can think of. For go, we're

3:39:18first going to disable the GitHub

3:39:19actions caching by specifying cache

3:39:22false in our setup go action. Then we'll

3:39:25add a step,

3:39:28which only executes if we're installing

3:39:29Go.

3:39:32uses that namespace caching action I

3:39:34just showed and tells it to use the go

3:39:36cache.

3:39:38We'll do something similar for node

3:39:40where we'll disable the upstream GitHub

3:39:42actions cache

3:39:44by providing an empty string. We'll copy

3:39:46this

3:39:49run it when we're installing node

3:39:53and because npm is not one of the

3:39:55default options we'll use a custom path

3:39:57input.

3:39:59We want home.npm npm as well as the node

3:40:02modules directory where our service

3:40:04lives in the codebase.

3:40:07Python will be similar.

3:40:10Once again, adding an empty string here

3:40:11disables the GitHub action cache. Run it

3:40:13only when we're installing Python

3:40:16and use the poetry cache. And you may

3:40:19have noticed I was doing this within the

3:40:21setup dependencies composite action. One

3:40:23thing I didn't show in the recording was

3:40:25migrating these language setups into

3:40:27that composite action. Similar to I how

3:40:29I moved the setup of task into this

3:40:31composite action. I also moved the setup

3:40:33of Node, Python, and Go in case I ever

3:40:36needed to use them across other

3:40:37workflows. I added boolean inputs for

3:40:39each of the different languages to

3:40:40decide if I'm installing it or not. And

3:40:42then a path to the service where I can

3:40:44find the relevant piprototl file, go.mod

3:40:48file, etc. consuming this now.

3:40:52Looks like before, but I use my service

3:40:55name to determine if I should install

3:40:57and configure each of these run times.

3:40:59The process the process of moving these

3:41:01out is almost identical to what we did

3:41:03with task. So, I didn't think it was

3:41:04worthwhile to showcase doing that. I

3:41:06just wanted to clarify why you might

3:41:07have seen a slight difference in where

3:41:09this logic lived, but I wanted to handle

3:41:11it in the same place that I was handling

3:41:13the runtime setup. To summarize, we

3:41:15modified our runs on to use namespace

3:41:17based runners. We configured them to set

3:41:18up independent cache volumes for each

3:41:20service where appropriate and cache

3:41:22dependencies from each of our specific

3:41:23tool chains also leverage namespaces

3:41:25remote container build service for

3:41:27faster multi-architecture builds. As you

3:41:29can see, the level of effort we put in

3:41:31for the cost savings and performance

3:41:33increases is well worth the investment.

3:41:35I would urge you to spend just a little

3:41:36bit of time to investigate how moving to

3:41:38namespace might help your org build and

3:41:40ship software faster. And with that,

Conclusion

3:41:42we've reached the end of the course.

3:41:44Taking a look back, we started by

3:41:45learning about the history and

3:41:46motivation for continuous integration

3:41:48and where GitHub action falls within the

3:41:50competitive landscape. We built a

3:41:51foundation by learning first about the

3:41:53core features of the platform before

3:41:54moving on to more advanced concept. We

3:41:56then explored how to use thirdparty

3:41:57actions as well as author our own before

3:42:00covering how to author workflows that

3:42:02meet the needs of your software teams.

3:42:03Finally, we brought all of this together

3:42:05into a comprehensive capstone project,

3:42:07implemented build, test, and deploy

3:42:08workflows for a realistic microser

3:42:10application. My goal for this course is

3:42:12for it to become the go-to resource for

3:42:14people who want to learn GitHub actions

3:42:15effectively. You found value in the

3:42:17course, consider sharing it with your

3:42:18colleagues at work or with your network

3:42:20on social media. If you do so, please

3:42:21tag me. I'm Sid Palace on Twitter or you

3:42:24can search Sid Palace on LinkedIn.

3:42:25Thanks again to Namespace for sponsoring

3:42:27the course. And remember, just keep

3:42:29building.

This transcript was generated from the captions YouTube publishes for this video. Get the transcript of any YouTube video atfreeyoutubetranscribe.com: free, unlimited, no sign-up.