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.