TL;DR - use GitHub Actions for general CI/CD since you should be using GitHub anyway - use Jenkins for self-hosted or more powerful / flexible / extensive CI/CD.
- Key Points
- GitHub Actions Runners
- GitHub Actions Marketplace
- Mac Runner Versions vs XCode versions
- GitHub Actions Master Template & Reusable Workflows
- Cloud OIDC Integration
- GitHub Actions Best Practices
- Security Hardening for GitHub Actions
- Pin 3rd party GitHub Actions to Git Hashrefs, not tags
- Avoid
${{ inputs }}Shell Injection - Validate all
${{ inputs }} - Enforce Shell Error Detection for entire Workflow
- Always Quote all Variables in Shell
- Serialize Workflows with Steps sensitive to Race Conditions
- Serialize all workflows that commit to the same Git repo
- Avoid Race Condition - Do Not Tag from Moving Targets eg.
masterorlatest - Do Not Write Legacy Technical Debt Code
- Deduplicate Code Using Environment Variables grouped in top-level
envsection - Begin Workflow Jobs with an Environment Printing Step
- Avoid putting Sensitive information such as Secrets in Global Environment Variables
- Look up GitHub Actions Contexts Fields and Environment Variables
- Sparse Checkouts
- Import Reusable Workflows using Tags
- Reusable Workflow Updates Lifecycle
- GitHub Actions vs Jenkins
- Diagrams
- Troubleshooting
- fully hosted
- unlimited build minutes for public projects
- 2,000 free private build minutes for users / orgs
- 50,000 build minutes per month for cloud enterprise Orgs (essentially free - better than paying for CircleCI!)
- optional self-hosted runners - HariSekhon/Kubernetes-configs - github-actions
- Good Integrations:
GITHUB_TOKENautomatically available with tunable permissions- Code Scanning via Sarif file uploads to the Security Tabs of a repo (see
HariSekhon/GitHub-Actions)
- free for public repos
- requires a Security seats license per commit user in last 90 days on top of the GitHub Enterprise user seat license
- PR / issues actions / comments / auto-merges
- GitHub Marketplace - lots of 3rd parties already have importable actions
- no auto-cancellation of older builds
- no auto-retry like Jenkins
retry{} - can't compose environment variables from other environment variables (must use step action workaround, see HariSekhon/GitHub-Actions main.yaml template)
- can't use environment variables in GitHub Actions
with:inputs to imported actions/workflows - can't export environment variables to GitHub Actions / Reusable Workflows
- Secrets must be passed explicitly via
${ secrets.<name> }
https://github.com/actions/runner-images#available-images
Don't forget to search here for useful actions!
https://github.com/marketplace
Read the READMEs here to see what versions for XCode are available in different macOS runner verisons:
https://github.com/actions/runner-images/tree/main/images/macos
https://github.com/marketplace/actions/setup-xcode-version
The code snippet examples on the rest of this page are copied from this real-world repo which has been used in production and supports all of my public GitHub projects:
This allows workflows to get short lived tokens and assume roles with permissions to cloud resources without having to use static AWS Access Keys, which may be disallowed in some enterprises by guardrail policies.
Configure OIDC to Google Cloud
Configure OIDC to HashiCorp Vault
Check your current OIDC providers:
https://console.aws.amazon.com/iam/home?#/identity_providers
aws iam list-open-id-connect-providersaws iam list-open-id-connect-providers --query OpenIDConnectProviderList --output textIf you've already got one for GitHub Actions:
arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com
See details for each OIDC provider, to check the GitHub Actions OIDC thumbprints:
aws iam list-open-id-connect-providers --query OpenIDConnectProviderList --output text |
xargs -L1 aws iam get-open-id-connect-provider --open-id-connect-provider-arnIf there isn't one already, create it:
AWS IAM UserGuide OIDC Provider
You can omit the thumbprint and it'll figure it out from the cert:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
#--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1,1c58a3a8518e8759bf075b76b750d4f2df264fcdAWS IAM UserGuide OIDC Verify Thumbprint
Create the AWS IAM Role to allow GitHub Actions to assume it:
OWNER="$(gh repo view --json owner -q .owner.login | tee /dev/stderr)"REPO="$(gh repo view --json name -q .name | tee /dev/stderr)"aws iam create-role \
--role-name GitHubActionsRole \
--assume-role-policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [
{
\"Effect\": \"Allow\",
\"Principal\": {
\"Federated\": \"arn:aws:iam::$AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com\"
},
\"Action\": \"sts:AssumeRoleWithWebIdentity\",
\"Condition\": {
\"StringLike\": {
\"token.actions.githubusercontent.com:aud\": \"sts.amazonaws.com\",
\"token.actions.githubusercontent.com:sub\": \"repo:$OWNER/$REPO:ref:refs/heads/${BRANCH:-*}\"
}
}
}
]
}"If the GitHub Actions workflow is using an environemtn, the condition should instead use
repo:$OWNER/$REPO:environment:$ENVIRONMENT - don't forget to define your ENVIRONMENT environment variable first.
Check it in UI:
https://console.aws.amazon.com/iam/home#/roles/details/GitHubActionsRole
Grant the role permissions to the resources, such as an S3 bucket containing the proprietary iXGuard installer:
BUCKET=my-s3-cicd-installablesaws iam put-role-policy \
--role-name GitHubActionsRole \
--policy-name ReadS3BucketPolicy \
--policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [
{
\"Effect\": \"Allow\",
\"Action\": [
\"s3:GetObject\",
\"s3:ListBucket\"
],
\"Resource\": [
\"arn:aws:s3:::$BUCKET\",
\"arn:aws:s3:::$BUCKET/*\"
]
}
]
}"The credentials step should look like this:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
role-session-name: github-actions-ixguard-download
aws-region: eu-west-1
audience: sts.amazonaws.comRead this doc carefully:
For security, pin 3rd party GitHub Actions to a @<git_hashref> rather than a git tag, and comment what tag the hashref
represents.
Otherwise a compromised 3rd party GitHub Actions repo can be retagged with any arbitrary code which to induce malicious code injection into your repo under your permissions when next called.
You can use
github_tag_hashref.sh
script to quickly get the hashref of a given Github Actions owner/action@tag:
github_tag_hashref.sh owner/action@tag5f066a372ec13036ab7cb9a8adf18c936f8d2043
You can also do this manually like this:
git ls-remote --tags "https://github.com/$owner/$repo" "$tag"5f066a372ec13036ab7cb9a8adf18c936f8d2043 refs/tags/v0.5.3
Do NOT use ${{ inputs.BLAH }} directly in shell steps.
This leads is a script injection vulnerability.
Always put ${{ inputs.BLAH }} into an env field either at top level or step level depending on your preference.
env:
MY_VAR: "${{ inputs.my_var }}"This will quote any shell escape sequences. This is like SQL parameterized queries to avoid SQL Injection.
In Shell step just use it as a normal enviroment variable.
steps:
- name: Use Input as a normal Environment Variable
run: echo "$MY_VAR"Validate all ${{ inputs }} contain what you expect them to contain.
Eg. a directory only has alphanumeric characters and no .. for traversal attacks.
Do not validate the ${{ inputs }} in shell steps as per above you will introduce a code injection attack that
precedes the evaluation of the shell step to validate it!
Instead, validate the env quoted content of the resulting environment variable from the section above.
Make any unhandled error code in shell steps fail, including in subshells or unset variables, and trace the output for immediately easier debugging to see which shell command line failed.
Add this near the top of your workflow:
defaults:
run:
shell: bash -euxo pipefail {0}This is basic shell scripting best practice in case your shell commands or variables end up containing an unexpected space or other character that will otherwise break shell interpretation and lead to unexpected results.
NO:
var=$(somecommand)NO:
echo $varYes:
var="$(somecommand)"echo "$var"concurrency:
# XXX: don't set this to the same group in a reusable workflow and calling workflow, that will result in a deadlock
group: ${{ github.workflow }}
cancel-in-progress: falseSerialize otherwise you will end up with git commit race condition breaking the workflow on git push:
concurrency:
# TODO: could possibly improve this to only serialize for the given branch
group: my-repo-git-changes
cancel-in-progress: falseThese can change in between the time you trigger the call or the workflow gets to the step that uses them, which can lead to very confusing results when you don't get the version of code or docker image that you expected.
Always work on a hashrf.
You can determine the Git commit hashref from a given tag like so:
GIT_SHORT_SHA="$(git rev-list -n 1 --abbrev-commit "$TAG")"Do not write legacy or technical debt code that will need to be changed later.
Be diligent about future engineering time, whether its yours or your colleagues.
These old constructs will break at some point and screw some poor engineer who inherits your code.
NO:
- name: Save state
run: echo "::save-state name={name}::{value}"
- name: Set output
run: echo "::set-output name={name}::{value}"Yes:
- name: Save state
run: echo "{name}={value}" >> "$GITHUB_STATE"
- name: Set output
run: echo "{name}={value}" >> "$GITHUB_OUTPUT"Documentation:
Abstract out variable things that might change like server addresses, URLs, Docker image repo paths in the top-level
env section before the jobs section.
This makes it easier to see the variable parts of the code and manage them,
rather than interspersing them throughout sub env fields under jobs and steps.
Unfortunately at time of writing env fields cannot be composed of other env variables like can be done in
Jenkins, which would lead to better deduplication of string components among different environment
variables.
You can also verify the environment variables at the start of the job in a single step using the Environment step below.
This aids in debugging as it costs nothing computationally or time wise but means that you can at any time inspect the
environment of the job and any variables you expect to be set, whether implicitly available in the system or set by
yourself at a top level env section as per the section above.
steps:
- name: Environment
run: |
echo "Environment Variables:"
echo
env | sortOr if even better show if you're inside Docker and on which Linux distro and version to aid any future environment debugging:
steps:
- name: Environment
run: |
[ -e /.dockerenv ] && ls -l /.dockerenv
echo
cat /etc/*-release
echo
echo "Environment Variables:"
echo
env | sortSecrets should of course go in secrets instead of environment variables...
Secrets are not accessible to 3rd party actions unless explicitly passed to them.
However, Global Environment Variables are available to 3rd party actions, so you must not put reference Secrets into Global Environment Variables if using 3rd party actions.
eg. don't do this:
env:
MY_SECRET: ${{ secrets.MY_SECRET }} # env.MY_SECRET is now readable by 3rd party actionsEven consider restricting semi-sensitive environment variable content to specific steps where they're needed instead of Global Environment Variables.
I provide this repo:
HariSekhon/GitHub-Actions-Contexts
which runs workflows weekly to show the real Environment Variables and GitHub Actions Context fields available.
These are not always well documented and up to date in GitHub documentation so this is useful to see what is actually available and what the field contents look like right now.
The context fields changes for different trigger types such as Push vs manually triggered Workflow Dispatch,
see the badge in the README and click the different types to compare.
If you are working with a big repo, such as in monorepos, and only need the workflow to operate on a subset of the directory tree, then you may want to sparse checkout instead for efficiency:
uses: actions/checkout@v4
with:
ref: "master"
sparse-checkout: |
charts
sparse-checkout-cone-mode: false # false = patterns, true = literal directoriesFor more details, read:
https://git-scm.com/docs/git-sparse-checkout
This prevents your calling workflow from breaking when the reusable workflow has updates to its behaviour or especially its input parameters.
uses: HariSekhon/GitHub-Actions/.github/workflows/some-workflow.yaml@1.0.0See the Reusable Workflow Updates Lifecycle section for more details.
When making changes and updates to GitHub reusable workflows, this can be handled in two ways.
Calling workflows import reusable workflows directly from branch eg.
uses: HarSekhon/GitHub-Actions/.github/workflows/mobile-ios-fastlane.yaml@mainWhen updates are merged to trunk all calling workflows automatically receive the updates.
This is risky in that if you change any input parameters it will break production client workflows.
There is no nice way to stagger releases of major changes.
They must be simultaneously coordinated across all client workflows.
Breaking changes to a reusable workflow need to be coordinated by finding all production calling workflows and updating their input parameters to match, and then merge everything across all repos at the same time.
One hacky workaround that has been done is forking a reusable workflow file to another file and make updates there, and then updating client workflows to call that different workflow file to get the updates. Needless to say, that sucks.
Calling workflows import reusable workflows using a fixed git tag eg.
uses: HariSekhon/GitHub-Actions/.github/workflows/mobile-ios-fastlane.yaml@fastlane-1.0.0GitHub workflow updates are merged to the trunk branch (main or master) and then git tagged.
This is preferred to pin calling client workflows to these tags because it means that even breaking changes to the reusable workflow will not impact existing production workflows which will still be using the previous tag.
Production calling workflows have to be updated to explicitly call the newer tag, and this can be staggered and tested one by one, at leisure when they want to receive the improvements and verifying that their input parameters match any updates to the reusable workflow.
The drawback is that this adds an extra step to receive updates / improvements.
One must find all calling workflows and manually commit @<tag> updates to each of them to import the newer workflow.
This is still the preferred method as it is more production-robust.
- GitHub Actions is fully-hosted so immediately available and bypasses most operational & governance issues where CloudBees is focused
- GitHub Actions is much cheaper - we already have 50,000 build minutes a month essentially for free, and $0.008 per minute thereafter
- Legacy Enterprise licensing just doesn't make sense any more given company estates are increasingly cloud-based
these days
- 2 vendors I was working with were trying to switch based to PAYG licensing model based on my feedback - cloud-native billing can work out to pennies on the dollar
- GitHub Actions also has self-hosted runners, so can operate it at the same cost as Jenkins free, just paying for
your own compute
- many hosted CI/CD providers are offering this in the 2020s now I've noticed with the standardization on Docker and Kubernetes
- GitHub Actions has a much better API than Jenkins
- GitHub Actions has a much better CLI than Jenkins
- GitHub Actions has better/easier integrations
- GitHub Actions is more self-service for developers who are often already using it for their open source projects
- GitHub Actions is supporting open-source projects the most among hosted CI/CD providers by being completely free for public projects, without usage limits, and Jenkins has no comparable hosted counterpart to date
- Jenkins is more powerful and flexible at the expense of more administration due to being self-hosted. What the world
really needs is a cloud hosted Jenkins.
- Jenkins can compose environment variable from other variables like a regular programming language - GitHub Actions at time of writing can't do this.
I've had similar feedback from both technical player-managers, developers and DevOps colleagues.
I'd like to see CloudBees build a 100% hosted Jenkins solution with the same billing cost per minute as GitHub Actions, with a whole new clean Rest API and CLI. Self-hosted runners at no cost are pretty important too, both to access internal tooling services as part of CI/CD pipelines as well as to control costs. Some of the other vendors who have tried to limit self-hosted runners to more expensive plans have essentially shot themselves in the foot because they've made themselves economically non-competitive.
From the HariSekhon/Diagrams-as-Code repo:
Open Diagrams-as-Code README.md to enlarge:
If your workflow hangs indefinitely on this message:
Waiting for a runner to pick up this job...
It may be caused by specifying a runner image that doesn't exist, such as:
runs-on: macos-14.1but if you check here:
https://github.com/actions/runner-images#available-images
only integer macOS versions are available, change it to this to get it to be picked up and run:
runs-on: macos-14This is not intuitive, it should really tell you that there is no such runner version available instead of just hanging.
Have raised this issue as product feedback here.
Executable `/opt/hostedtoolcache/Ruby/3.3.4/x64/bin/ruby` not found
This was caused by stale cache when the ruby/setup-ruby@v1 action updated the minor version from 3.3.4 to 3.3.5
leading to the above cached ruby binary path using 3.3.4 not being found.
Solution: Delete the Cache and then re-run.
Via UI:
https://github.com/<OWNER>/<REPO>/actions/caches
or
Via GitHub CLI in the git checkout:
gh cache listgh cache delete "$CACHE_ID" # from above commandor lazily, copy this to blast all caches in the current repo:
gh cache ls --json 'key' --jq '.[].key' |
while read -r key; do gh cache delete "$key"; done(this is safe to do as they'll just get rebuilt and clear the above error)
Ported from private Knowledge Base page 2019+
