CI/CD Practical Guide (Part 2): Comprehensive Guide to Using and Building GitHub Actions and Self-hosted Runners
A complete guide to understanding how GitHub Actions and Self-hosted Runners work, along with step-by-step usage tutorials.
ℹ️ℹ️ℹ️ The following content is translated by OpenAI.
Click here to view the original Chinese version. | 點此查看本文中文版
CI/CD Practical Guide (Part 2): Comprehensive Guide to Using and Building GitHub Actions and Self-hosted Runners
This guide will help you understand from scratch how GitHub Actions and Self-hosted Runners operate, along with hands-on tutorials.
Photo by Dan Taylor
Introduction
In the previous article, “CI/CD Practical Guide (Part 1): What is CI/CD? How to Build a Stable and Efficient Development Team through CI/CD? Tool Selection?”, we introduced what CI/CD is, the benefits it brings, and how to choose tools. This article will focus on the architecture and usage of GitHub Actions and Self-hosted Runners, guiding you step-by-step to establish several interesting automation workflows.
GitHub Actions Architecture Flowchart
Before we begin, let’s clarify the operational architecture and responsibilities of GitHub Actions.
GitHub Repo
- In the world of GitHub Actions, all Actions (Workflow YAML files) must be stored in a Git Repo (
REPO/.github/workflows/
)
GitHub Repo — Actions Secrets
Repo → Settings → Secrets and variables → Actions → Secrets.
- Stores Secret Keys and Tokens used in Actions steps e.g. Slack Bot Token, Apple Store Connect API .p8 Key
- Secrets content cannot be viewed in Action Logs and will be automatically obscured with * * * *
- Secrets content cannot be viewed or edited, only overwritten
- Secrets currently only support plain text content and cannot upload files - For binary keys, please refer to the official steps to store them after converting to Base64 encoding. - For iOS development certificate storage methods, refer to the official tutorial: Installing an Apple certificate on macOS runners for Xcode development
- Organization-level Secrets can be stored and shared across Repos.
GitHub Repo — Actions Variables
Repo → Settings → Secrets and variables → Actions → Variables.
- Stores commonly used variables in Actions steps e.g. Simulator iOS version, working directory
- Variables content can be viewed and edited
- Variables content can be output in Action Logs
- Variables only support plain text, but can also store JSON strings for parsing and use
- Organization-level Variables can be stored and shared across Repos.
GitHub Actions — Trigger
- The most important starting point in GitHub Actions — Trigger Events (Conditions)
- Only GitHub Actions that meet the trigger events will be executed
- A complete list of events can be found in the official documentation
- It basically covers all CI/CD and automation scenarios encountered. However, if there are special scenarios without events, you can only use other events + conditions in Jobs or use Schedule to manually check. e.g. If there is no PR Merged event, you can only use
pull_request: closed
+ Jobif: github.event.pull_request.merged == true
to achieve it.
Commonly Used Events:
schedule
(cron): Scheduled execution (like crontab) Can be used for automation: regularly checking PRs, packaging, executing automation scripts All executed in main / develop (Default Branch).pull_request:
PR related events Triggered when a PR is opened, assigned, labeled, or has a new Push Commit, etc.issues
,issue_comment
: Issue related events Triggered when an Issue is opened or has new comments, etc.workflow_dispatch
: Manually triggered; fields can be set for user input, and GitHub Actions provides a simple form for users to fill in information. e.g.:
workflow_call
: Triggers another Action (Workflow) to execute tasks.workflow_run
: Triggered when another Action (Workflow) executes a task.
For more event types and configuration details, please refer to the official documentation.
GitHub Actions — Workflow
- Also known as Action
- Written in YAML format, with files stored in
REPO/.github/workflows/
- The Workflow YAML file in the main branch is the primary one If you cannot see the Action being developed in other branches or if there are execution errors, try merging back to the main branch.
- The most basic unit in GitHub Actions, each Workflow represents a CI/CD or automation operation
- Workflows can call other Workflows to execute tasks (You can use this feature to separate core Workflows from called Workflows)
- It defines task names, execution strategies, trigger events, task operations, and all Action-related settings
- Currently, the file structure does not support subdirectories
- Actions are completely free (Public and Private Repos)
GitHub Actions — Workflow — Job
- The execution unit in GitHub Actions
- Defines what tasks are included in the Workflow
- Each Workflow can have multiple Jobs
- Each Job needs to specify which Runner Label to use, and the corresponding Runner machine will execute the task
- Multiple Jobs are executed concurrently (if there is a sequence, you can use
needs
constraints) - Each Job should be treated as an independent execution entity (each should be considered a Sandbox), if a Job ends and produces resource files for subsequent Jobs/Workflows, you need to Upload Artifacts or move them to a shared output directory in self-hosted.
- A Job can output strings for reference by other Jobs. (e.g. execution result true or false)
- Unless special process conditions are set, if one of the Jobs encounters an error, the other Jobs will still continue to execute.
GitHub Actions — Reuse Workflow/Job
- Define
on: workflow_call
in the Workflow to encapsulate it for reuse as a Job in other Workflows - Can be shared across Repos within the same organization
- Therefore, you can place CI/CD tasks that are shared across Repos into a shared Repo for use.
GitHub Actions — Workflow — Job — Step
- The smallest execution unit in GitHub Actions
- The program that actually executes tasks in a Job
- Each Job can have multiple Steps
- Multiple Steps are executed in order
- A Step can output strings for reference by subsequent Steps.
- Steps can directly write shell script programs You can reference gh cli, current environment variables (e.g., obtaining PR numbers), and directly perform the desired actions.
- Unless special process conditions are set, if a Step encounters an error, it will terminate immediately, and subsequent Steps will not execute.
GitHub Actions — Workflow — Job — Reuse Action Step
- You can directly reuse existing steps packaged by various experts on the Marketplace For example: Comment content to PR.
- You can also package a series of task Steps into an Action GitHub Repo for reuse by other workflows.
- Actions from Public Repos can be listed on the Marketplace.
Packaging Actions supports:
- Docker Action — Indicates that GitHub Actions will pass environment variables to the Docker container, and you can handle them as needed, whether it be shell scripts, Java, PHP, etc.
- JavaScript/TypeScript Action — Directly use node.js to write the logic for GitHub Actions, and the environment variables will also be passed for your reference. e.g. pozil/auto-assign-issue
- Composite (YAML) — Pure YAML describing task steps (same as GitHub Actions — Workflow — Job — Step) can declare which steps to perform or directly write shell scripts. e.g. ZhgChgLi/ZReviewTender
Due to space constraints, this article will not cover how to package GitHub Actions. For those interested, please refer to the official documentation: tutorials/creating-a-composite-action.
GitHub Runner
- GitHub will dispatch corresponding Jobs to Runners based on Runner Labels
- Runners only act as listeners, polling to listen for tasks dispatched by GitHub
- They only care about Jobs, not which Action (Workflow) it is Thus, you may see that after Action A’s Job-1 completes, the next one might be Action B’s Job-1, rather than completing all of Action A’s Jobs before switching to Action B.
- Runners can use GitHub Hosted Runners or Self-hosted Runners.
GitHub Hosted Runner
- Runners provided by GitHub, you can refer to the official Repo list:
- You can check what is pre-installed on the Runners: e.g. macos-14-arm64
- For iOS development, it is recommended to use the -arm64 (M series) processor Runners for faster performance.
- Just paste the YAML Label from the table in the Job
run-on
to use that Runner for executing tasks. - Public Repo pricing: Completely free with unlimited usage.
- ️ Private Repo free tier: Free tier (varies by account type, for GitHub Free as an example): Usage: 2,000 minutes free per month Storage: 500 MB
- ⚠️️Private Repo billing method: Billing starts after exceeding the free tier (you can set limits and notifications), and the pricing varies based on the operating system and core of the Runner:
about-billing-for-github-actions
You can see that the price for macOS is high due to the high equipment costs.
- Maximum concurrent task limits:
usage-limits-billing-and-administration
We’ve digressed too much; our focus is on Self-hosted Runners.
Self-hosted Runner on In-house Server
- Use your own machine as a Runner
- A physical machine can run multiple Runners concurrently to accept tasks
- Free unlimited usage Only the cost of purchasing the machine, use it as much as you want after a one-time investment! For example, if you calculate with a 32G RAM M4 Mini (≈NT$40,900), using GitHub Hosted Runners for a month would cost 500 USD; buying one and setting it up would pay off in just over three months!
- Supports Windows, macOS, Linux (x64/ARM/ARM64)
- Runners can be shared across Repos within the same organization
- ⚠️Currently: actions/cache, actions/upload-artifact, actions/download-artifact only support GitHub cloud services, meaning that this content will still be uploaded to GitHub servers and counted towards storage fees. You can set up a shared directory on your own machine instead.
- Self-hosted Runners also support Docker, k8s, but I haven’t researched that.
Setting up a Self-hosted Runner only takes a few steps (set up within 10 minutes) to go online and start accepting tasks (this article will introduce it later).
GitHub Workflow x Job x Step x Runner Relationship Flowchart
This diagram summarizes the workflow and relationships, assuming we have two Workflows and two Runners:
- CI — Assume R has 3 Jobs, each Job has several Steps, Runner Label (run-on) —
self-hosted-app
- CD — Has 4 Jobs, each Job has several Steps, Runner Label (run-on) —
self-hosted-app
- Runners — There are 2, both with the Runner Label —
self-hosted-app
As mentioned earlier, Runners and Workflows are dispatched and received based on Runner Labels. In this case, all use the same self-hosted-app
Runner Label. The Jobs in the Workflow are executed concurrently by default, while the Steps are the actual execution content of each Job, executed sequentially in order. Once all Steps are completed, the Job is considered finished.
Therefore, the actual execution timeline of the Workflow will traverse between the two Runners executing Jobs, and they will execute concurrently. The next Job executed may not necessarily be the next Job in the same Workflow.
⚠️ This is why it’s important to ensure that each Job is independent (especially in a Self-hosted Runner environment, where the output of one Job may not be cleaned up thoroughly after completion, leading us to subconsciously think that the output of this Job can be used by the next Job), and this should not be the case.
If a Job produces output for subsequent Jobs to use:
- Job Output String: Outputs plain text to variables for reference by other Jobs
- Artifact-upload/download: The last step of a Job uploads the execution result to GitHub Artifact, and the first step of another Job downloads it for further processing. e.g. For example, Job — packaging → uploads the packaging result .ipa to Artifact → Job — deployment → downloads the .ipa → uploads to App Store for deployment. Note: Currently, even if it’s Self-hosted, it will still go to GitHub Artifact cloud storage.
- AWS/GCP… cloud storage: Same as above, but using your own cloud storage service.
- [Self-hosted Only] Shared hard drive, directory: If Self-hosted Runners are all mounted to a shared directory, you can create folders based on UUID to store output results, and subsequent Jobs can read the previous Job’s Output UUID to find the corresponding storage and retrieve it for use. Note: Runners on different machines must also mount the same shared directory.
- Complete all tasks within the same Job’s Steps.
Learning by Doing GitHub Actions — Case Implementation
“Better to act than to talk.” The definitions and process structure mentioned above may have left everyone a bit confused. Next, I will directly present three functional examples, guiding you through hands-on practice while explaining the concepts encountered along the way, learning about GitHub Actions through doing.
Case — 1
Automatically label the File Changes Size Label after creating a Pull Request to help the Reviewer organize their review tasks.
Result Image
Operation Process
- User opens PR, reopens PR, or pushes a new commit to PR
- Triggers GitHub Actions Workflow
- Shell script retrieves the number of file changes
- Determines the count and labels the PR accordingly
- Completed
Hands-On Practice
Repo → Actions → New workflow → set up a workflow yourself.
File Name: Automation-PullRequest.yml
Action Workflows can have each task in a separate file or aggregate tasks with the same purpose in one file based on the triggering event. Since multiple jobs run concurrently, GitHub Actions currently does not support directory structures, so having fewer files and using hierarchical naming will make management easier.
Here, all actions related to PR events are placed in the same workflow.
Automation-PullRequest.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# Workflow(Action) Name
name: Pull Request Automation
# Actions Log Title
run-name: "[Pull Request Automation] ${{ github.event.pull_request.title || github.ref }}"
# Trigger Events
on:
# PR Events
pull_request:
# PR - Opened, Reopened, New Push Commit
types: [opened, synchronize, reopened]
# In the same Concurrency Group, if there is a new Job, it will cancel the one currently running
# For example, if a task triggered by a Push Commit has not executed yet and another Push Commit occurs, the previous task will be canceled
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true
# Job Items
# Jobs will run concurrently
jobs:
# Job ID
label-pr-by-file-count:
# Job Name (optional, omitted for better readability in logs)
name: Label PR by changes file count
# If this Job fails, it does not affect the entire Workflow, and other Jobs continue
continue-on-error: true
# Set maximum Timeout to prevent endless waiting in case of anomalies
timeout-minutes: 10
# Runner Label - Use GitHub Hosted Runner ubuntu-latest to execute the job
# If it's a Private Repo, usage will be calculated, and exceeding limits may incur costs
runs-on: ubuntu-latest
# Job Steps
# Steps will execute in order
steps:
# Step Name
- name: Get changed file count and apply label
# Step ID (optional, not needed if there are no subsequent Steps to reference Output)
id: get-changed-files-count-by-gh
# Inject external environment parameters into the runtime
env:
# secrets.GITHUB_TOKEN is a Token automatically generated during GitHub Actions execution, no need to set it in Secrets, it has some GitHub Repo API Scopes permissions
# https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-guides/use-github_token-in-workflows
# The gh (GitHub) CLI needs to inject GH_TOKEN into ENV for permissions to operate
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Shell script
# GitHub Hosted Runner has gh CLI pre-installed, no need to install it in the Job
run: |
# ${{ github.xxx }} is a GitHub Actions Context expression
# It is not a Shell variable, but a value replaced by GitHub Actions during the YAML parsing phase
# Other parameters: https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
# Get PR number:
PR_NUMBER=${{ github.event.pull_request.number }}
# Get Repo:
REPO=${{ github.repository }}
# Use GitHub API (gh CLI) to get the number of changed files
FILE_COUNT=$(gh pr view $PR_NUMBER --repo $REPO --json files --jq '.files | length')
# Print Log
echo "Changed file count: $FILE_COUNT"
# Label logic
if [ "$FILE_COUNT" -lt 5 ]; then
LABEL="XS"
elif [ "$FILE_COUNT" -lt 10 ]; then
LABEL="S"
elif [ "$FILE_COUNT" -lt 30 ]; then
LABEL="M"
elif [ "$FILE_COUNT" -lt 80 ]; then
LABEL="L"
elif [ "$FILE_COUNT" -lt 200 ]; then
LABEL="XL"
else
LABEL="XXL"
fi
# Use GitHub API (gh CLI) to remove the current Size Label
EXISTING_LABELS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
for EXISTING in $EXISTING_LABELS; do
case "$EXISTING" in
XS|S|M|L|XL|XXL)
echo "🧹 Removing existing label: $EXISTING"
gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$EXISTING"
;;
esac
done
# (Optional) Create the label if it does not exist
if ! gh label list --repo "$REPO" | grep -q "^$LABEL"; then
echo "🆕 Creating missing label: $LABEL"
gh label create "$LABEL" --repo "$REPO" --description "Size label: $LABEL" --color "ededed"
else
echo "✅ Label '$LABEL' already exists"
fi
# Use GitHub API (gh CLI) to label the PR
gh pr edit $PR_NUMBER --repo $REPO --add-label "$LABEL"
After committing the file to the main branch of the Repo, opening a new PR will automatically trigger GitHub Actions:
The Action execution status shows Queued, indicating that the task is waiting for the Runner to take over.
Execution Result
Once completed successfully, the PR will automatically be labeled with the corresponding label! The record will show that it was labeled by github-actions
.
Complete Code: Automation-PullRequest.yml
Directly Use Pre-Packaged Action Steps: pascalgn/size-label-action
As mentioned earlier, you can directly use pre-packaged Actions. The task of labeling PR Size Labels already has a ready-made solution available. The above example is for educational purposes; in practice, you do not need to reinvent the wheel.
You can simply use it directly in the Action Workflow Job Step:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# Workflow(Action) Name
name: Pull Request Automation
# Trigger Events
on:
# PR Events
pull_request:
# PR - Opened, Reopened, New Push Commit
types: [opened, synchronize, reopened]
# In the same Concurrency Group, if there is a new Job, it will cancel the one currently running
# For example, if a task triggered by a Push Commit has not executed yet and another Push Commit occurs, the previous task will be canceled
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true
# Job Items
# Jobs will run concurrently
jobs:
# Job ID
label-pr-by-file-count:
# Job Name (optional, omitted for better readability in logs)
name: Label PR by changes file count
# If this Job fails, it does not affect the entire Workflow, and other Jobs continue
continue-on-error: true
# Set maximum Timeout to prevent endless waiting in case of anomalies
timeout-minutes: 10
# Runner Label - Use GitHub Hosted Runner ubuntu-latest to execute the job
# If it's a Private Repo, usage will be calculated, and exceeding limits may incur costs
runs-on: ubuntu-latest
# Job Steps
# Steps will execute in order
steps:
# Step Name
- name: Get changed file count and apply label
# Step ID (optional, not needed if there are no subsequent Steps to reference Output)
id: get-changed-files-count-by-gh
# Directly use pre-packaged code
uses: "pascalgn/size-label-action@v0.5.5"
# Inject external environment parameters into the runtime
# Parameter naming and available parameters should refer to the documentation: https://github.com/pascalgn/size-label-action/tree/main
env:
# secrets.GITHUB_TOKEN is a Token automatically generated during GitHub Actions execution (github-actions identity), no need to set it in Secrets, it has some GitHub Repo API Scopes permissions
# https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-guides/use-github_token-in-workflows
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
This pre-packaged Action is a JavaScript Action. You can refer to the actual execution code in the following file: dist/index.js.
Supplement on name and run-name:
- name: Name of the Action Workflow
- run-name: Title of the execution record (can include PR Title or Branch or Author…etc) If it is an on:pull_request event, the default is the PR Title.
Case — 2
Automatically assign the author to themselves and comment if there is no Assignee after creating a Pull Request. (This only executes on the initial creation.)
Result Image
Operation Process
- User opens PR
- Triggers GitHub Actions Workflow
- GitHub script retrieves assignee
- If there is no assignee, assigns the author of the PR & comments the message
- Completed
Hands-On Practice
Repo → Actions → New workflow → set up a workflow yourself.
File Name: Automation-PullRequest.yml
(same as above)
Automation-PullRequest.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# Workflow(Action) Name
name: Pull Request Automation
# Actions Log Title
run-name: "Pull Request Automation - Daily Checker"
# Trigger Events
on:
# PR Events
pull_request:
# PR - Opened, Reopened, New Push Commit
types: [opened, synchronize, reopened]
# In the same Concurrency Group, if there is a new Job, it will cancel the one currently running
# For example, if a task triggered by a Push Commit has not executed yet and another Push Commit occurs, the previous task will be canceled
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true
# Job Items
# Jobs will run concurrently
jobs:
# Job ID
label-pr-by-file-count:
# Please refer to the previous section, omitted....
# ---------
assign-self-if-no-assignee:
name: Automatically assign to self if no assignee is specified
# Since this is a shared trigger event, the Job will determine itself; it will only execute when the Pull Request is Opened (initial creation), otherwise it will be Skipped
if: github.event_name == 'pull_request' && github.event.action == 'opened'
# If this Job fails, it does not affect the entire Workflow, and other Jobs continue
continue-on-error: true
# Set maximum Timeout to prevent endless waiting in case of anomalies
timeout-minutes: 10
# Runner Label - Use GitHub Hosted Runner ubuntu-latest to execute the job
# If it's a Private Repo, usage will be calculated, and exceeding limits may incur costs
runs-on: ubuntu-latest
steps:
- name: Assign self if No Assignee
# Use GitHub Script (JavaScript) to write the script (Node.js environment)
# Compared to writing directly in Shell Script, this is more convenient and visually appealing
# No need to inject environment variables or GITHUB_TOKEN manually
uses: actions/github-script@v7
with:
script: |
// In github-script, context variables are automatically injected for direct reference in JavaScript
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
const issue = context.payload.pull_request; // If you want to support Issues as well, you can write it as context.payload.issue || context.payload.pull_request
const assignees = issue.assignees || [];
const me = context.actor;
if (assignees.length === 0) {
// Set Assignee to self
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: [me]
});
// Leave a comment to notify
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `🔧 No assignee was set, so I have assigned this to myself (@${me}).`
});
}
This time we demonstrate using GitHub Script (JavaScript) to write the script, which offers more flexibility and better syntax.
Of course, if you want to follow my previous suggestion of having each task in a separate file, you can remove the Job If condition and directly set the Action Workflow trigger conditions:
Automation-PullRequest-Auto-Assign.yml
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Workflow(Action) Name
name: Pull Request Automation - Auto Assignee Self
# Trigger Events
on:
# PR Events
pull_request:
# PR - On Open
types: [opened]
jobs:
assign-self-if-no-assignee:
name: Automatically assign to self if no assignee is specified
runs-on: ubuntu-latest
steps:
# Please refer to the previous section, omitted....
After committing the file to the main branch of the Repo, opening a new PR will automatically trigger GitHub Actions:
Now there are two Jobs to execute!
Execution Result
Once completed successfully, if the PR has no Assignees, it will automatically assign the PR author and comment the message. (All actions are performed under the github-actions
identity)
Complete Code: Automation-PullRequest.yml
Test Reopening (Reopened) PR
You can see that only the Size Label Job will execute, while the Auto Assignee Job was Skipped.
This task also has a pre-packaged Action available for reuse; you can refer to: pozil/auto-assign-issue.
Case — 3
Automatically count the current number of PRs and how long they have been open every morning at 9 AM, sending notification messages to the Slack workgroup, and automatically closing PRs that have been open for over 3 months.
Result Image
- Slack workgroup automatically receives reports every morning
- Automatically closes PRs that have been open for more than 90 days
Workflow
- GitHub Actions automatically triggers every morning at 9 AM
- Triggers the GitHub Actions Workflow
- GitHub script retrieves the list of open PRs and counts how many days they have been open
- Sends the statistical report message to Slack
- Closes PRs that have been open for more than 90 days
- Done
Get Started
Repo → Actions → New workflow → set up a workflow yourself.
File Name: Automation-PullRequest-Daily.yml
Automation-PullRequest-Daily.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# Workflow(Action) name
name: Pull Request Automation - Daily Checker
# Trigger events
on:
# Scheduled to run automatically
# https://crontab.guru/
# UTC time
schedule:
# UTC 01:00 = 09:00 UTC+8 every day
- cron: '0 1 * * *'
# Manual trigger
workflow_dispatch:
# Job tasks
# Jobs will run concurrently
jobs:
# Job ID
calculate-pr-status:
# Job name (optional, but makes logs easier to read)
name: Calculate PR Status
# Runner Label - uses GitHub Hosted Runner ubuntu-latest to execute the job
# If it's a Private Repo, usage will be calculated, and exceeding limits may incur costs
runs-on: ubuntu-latest
# Job Output
outputs:
pr_list: ${{ steps.pr-info.outputs.pr_list }}
# Job steps
# Steps will execute in order
steps:
# Step name
- name: Fetch open PRs and calculate
# Step needs to reference Output, must be set
id: pr-info
uses: actions/github-script@v7
with:
script: |
const now = new Date();
const per_page = 100;
let page = 1;
let allPRs = [];
while (true) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page,
page,
});
if (prs.length === 0) break;
allPRs = allPRs.concat(prs);
if (prs.length < per_page) break;
page++;
}
const result = allPRs.map(pr => {
const created = new Date(pr.created_at);
const daysOpen = Math.floor((now - created) / (1000 * 60 * 60 * 24));
return {
pr: pr.number.toString(),
title: pr.title,
idle: daysOpen
};
});
// Set back to Output, only accepts String
core.setOutput('pr_list', JSON.stringify(result));
# ----
send-pr-summary-message-to-slack:
name: Send PR Summary Message to Slack
# By default, Jobs run concurrently; using needs forces the current Job to wait until the needed Job completes
needs: [calculate-pr-status]
runs-on: ubuntu-latest
steps:
- name: Generate Message
# Step needs to reference Output, must be set
id: gen-msg
uses: actions/github-script@v7
with:
script: |
const prList = JSON.parse(`${{ needs.calculate-pr-status.outputs.pr_list }}`);
const blocks = [];
// Title
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `📬 *Open PR Report*\nTotal: *${prList.length}* PR(s)`
}
});
// Each PR in a line
for (const pr of prList) {
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `• <https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${pr.pr}|PR #${pr.pr}> *${pr.title}* - 🕒 ${pr.idle} day(s)`
}
});
}
// Set back to Output, only accepts String
core.setOutput('blocks', JSON.stringify(blocks));
# Use the official Slack API GitHub Actions
# https://tools.slack.dev/slack-github-action/sending-techniques/sending-data-slack-api-method/
# Send message
- name: Post text to a Slack channel
uses: slackapi/slack-github-action@v2.1.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ vars.SLACK_TEAM_CHANNEL_ID }}
blocks: ${{ steps.gen-msg.outputs.blocks }}
# ----
auto-close-old-prs:
name: Auto Close Old PRs
needs: [calculate-pr-status]
runs-on: ubuntu-latest
steps:
- name: Auto close PRs opened more than 90 days
uses: actions/github-script@v7
with:
script: |
const prList = JSON.parse(`${{ needs.calculate-pr-status.outputs.pr_list }}`);
const oldPRs = prList.filter(pr => pr.idle > 90);
for (const pr of oldPRs) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: parseInt(pr.pr),
state: 'closed'
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(pr.pr),
body: `⚠️ This pull request has been automatically closed because it has been open for more than 90 days. Please reopen if needed.`
});
}
console.log(`Closed ${oldPRs.length} PR(s)`);
In this example, we use:
- on: schedule Crontab to automatically trigger and workflow_dispatch to support manual triggering
- Job output/Step output (both can only be strings)
- Multiple Jobs are concurrent by default but can use
needs
for time dependency - Retrieve settings from Repo Secrets/Variables
- Integrate with Slack API
Repo Secrets — Add SLACK_BOT_TOKEN
- For creating a Slack App and setting message permissions, you can refer to my previous article
Repo Variables — Add SLACK_TEAM_CHANNEL_ID
After committing the file to the main branch of the Repo, return to Actions and manually trigger it:
It will automatically trigger daily in the future.
Actions → Pull Request Automation — Daily Checker → Run workflow → Branch: main → Run workflow.
After execution, you can click to view the execution status:
Due to the needs
constraint, the Jobs process will have Calculate PR Status
complete first before concurrently executing Auto Close Old PRs
and Send PR Summary Message to Slack
.
Execution Results
After all tasks successfully execute, you can check the Slack message:
Success 🚀🚀🚀
Full code: Automation-PullRequest-Daily.yml
Summary
I hope the three examples above give you a preliminary understanding of GitHub Actions and inspire your automation workflow ideas. You can come up with your own workflows (please be sure to refer to trigger events), and then write scripts to execute; also remember to check the Marketplace for existing steps that you can use directly.
This article is just an introduction (even without checking out code). The next article, “CI/CD Practical Guide (3): Implementing CI and CD Workflows for App Projects Using GitHub Actions”, will introduce more complex and in-depth GitHub Actions Workflows.
GitHub Automation Extension Topics
GitHub can integrate with Slack to subscribe to Repo PR update notifications, Push Default Branch notifications, etc.
Question 1. GitHub messages cannot tag people, only tagging GitHub accounts, and Slack accounts do not receive notifications:
Slack App or search for GitHub in Apps → open the message window → complete the Connect GitHub Account step, so GitHub knows what your corresponding Slack UID is and can tag you.
Question 2. Pull Request Reminder
I remember this was originally a feature developed by a third party, but it was later integrated directly into GitHub. Don’t foolishly write scripts using GitHub Actions for this!
- After entering the Slack Channel and rules, it will automatically send reminder messages and tag Reviewers every day. You can refer to the setup here, and the result is shown in the image below:
https://michaelheap.com/github-scheduled-reminders/
Question 4. GitHub PR messages, authors do not receive notifications:
This is a long-standing issue: the PR Slack message actually does not notify the author when someone replies in threads. The Slack message sent by GitHub only tags Reviewers, not the Assignee or PR author.
At this point, GitHub Actions can be used to create an automation tool. We can add a Job to append the author tag at the end of the PR Description, so that when the message is sent to Slack, it will also tag the author’s Slack:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Workflow(Action) name
name: Pull Request Automation
# Please refer to the previous text, omitted....
# ---------
append-author-to-pr-description:
name: Append author to PR description
# Since this is a shared trigger event, the Job will determine itself; it will only execute the Job when the Pull Request is Opened (first created), otherwise it will be Skipped
if: github.event_name == 'pull_request' && github.event.action == 'opened'
# If this Job fails, it does not affect the entire Workflow, and other Jobs will continue
continue-on-error: true
# Set the maximum Timeout duration to prevent endless waiting in case of anomalies
timeout-minutes: 10
# Runner Label - uses GitHub Hosted Runner ubuntu-latest to execute the job
# If it's a Private Repo, usage will be calculated, and exceeding limits may incur costs
# But this kind of automation task is not likely to exceed usage
runs-on: ubuntu-latest
steps:
- name: Append author to PR description
env:
# secrets.GITHUB_TOKEN is a token automatically generated during GitHub Actions execution, no need to set it in Secrets, it has some GitHub Repo API Scopes permissions
# gh (GitHub) CLI needs to inject GH_TOKEN into ENV for permission to operate
# https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-guides/use-github_token-in-workflows
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ${{ github.xxx }} is a GitHub Actions Context expression
# It is not a Shell variable, but a value replaced by GitHub Actions during the YAML parsing phase
# Other parameters: https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
PR_NUMBER: ${{ github.event.pull_request.number }}
AUTHOR_TAG: '@${{ github.event.pull_request.user.login }}'
run: |
PR_BODY=$(gh pr view $PR_NUMBER --repo ${{ github.repository }} --json body -q ".body")
NEW_BODY=$(printf "%s\n\nCreated by %s" "$PR_BODY" "$AUTHOR_TAG")
gh pr edit $PR_NUMBER --repo ${{ github.repository }} --body "$NEW_BODY"
Full code: Automation-PullRequest.yml
When opening a PR, it will automatically append the author information at the end of the description, and the Slack message will correctly tag the author:
Question 5. GitHub sends too many notification emails, which is very disruptive:
After setting up GitHub Repo notifications and Slack, you can go to your GitHub personal settings to turn off email notifications:
Others
Actions currently do not have a directory structure, but you can pin (Pin) five Actions on the Actions page; you can also use Disable to pause a specific Action.
You can check GitHub Actions usage and execution performance in Insights:
Reuse Workflow Supplement
The next article uses the same Repo to split Reuse Workflow; here’s a supplement on an example of splitting Reuse Workflow across different Repos.
First, define a Reuse Workflow: Automation-label-pr-base-branch.yml: in the ZhgChgLi/github-actions-ci-cd-demo-share-actions Repo.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
name: Automation-label-pr-base-branch
run-name: "[Automation-label-pr-base-branch] ${{ github.event.inputs.PR_NUMBER || github.ref }}"
concurrency:
group: ${{ github.workflow }}-${{ github.event.inputs.PR_NUMBER || github.ref }}
cancel-in-progress: true
# Trigger events
on:
# Other Workflows call this Workflow to trigger
workflow_call:
# Input data
inputs:
# PR Number
PR_NUMBER:
required: true
type: string
# Secret inputs
secrets:
GH_TOKEN:
description: "GitHub token for API access"
required: true
jobs:
label-pr-base-branch:
name: Label PR Base Branch
continue-on-error: true
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Label PR by Base Branch
id: label-pr-base-branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ inputs.PR_NUMBER }}"
REPO="${{ github.repository }}"
echo "📦 Processing PR #$PR_NUMBER in repo $REPO"
# Get the base branch of the PR
BASE_BRANCH=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json baseRefName --jq '.baseRefName')
echo "🔖 PR Base Branch: $BASE_BRANCH"
# Allowed base branch labels
BRANCH_LABELS=("develop" "main" "master")
# Check if the label is in the allowed list
if [[ " ${BRANCH_LABELS[@]} " =~ " ${BASE_BRANCH} " ]]; then
LABEL="$BASE_BRANCH"
else
echo "⚠️ Base branch '$BASE_BRANCH' not in allowed list, skipping label."
exit 0
fi
# Remove existing base branch labels
EXISTING_LABELS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
for EXISTING in $EXISTING_LABELS; do
if [[ " ${BRANCH_LABELS[@]} " =~ " ${EXISTING} " ]]; then
echo "🧹 Removing existing base branch label: $EXISTING"
gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$EXISTING"
fi
done
# Create the label if it doesn't exist
if ! gh label list --repo "$REPO" | grep -q "^$LABEL$"; then
echo "🆕 Creating missing label: $LABEL"
gh label create "$LABEL" --repo "$REPO" --description "PR targeting $LABEL branch" --color "ededed"
else
echo "✅ Label '$LABEL' already exists"
fi
# Add the base branch label
echo "🏷️ Adding label '$LABEL' to PR #$PR_NUMBER"
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$LABEL"
Returning to our main ZhgChgLi/github-actions-ci-cd-demo repo, we will add a main workflow Demo-Reuse-Action-From-Another-Repo.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: Automation-label-pr-base-branch
on:
pull_request:
types: [opened]
jobs:
call-label-pr-workflow:
name: Call Base Branch Label Workflow
# ✅ If the called workflow is in the same repo:
# uses: ./.github/workflows/Automation-label-pr-base-branch.yml
# Note: This syntax cannot specify branches (it always uses the caller workflow's branch)
# Reference examples:
# - CD-Deploy-Form.yml calls this repo workflow
# - CD-Deploy.yml calls cross-repo workflow
#
# ✅ If the called workflow is in another repo:
# uses: {owner}/{repo}/.github/workflows/{file}.yml@{branch_or_tag}
# You can specify branch or tag
# ref: https://github.com/ZhgChgLi/github-actions-ci-cd-demo-share-actions/blob/main/.github/workflows/Automation-label-pr-base-branch.yml
uses: ZhgChgLi/github-actions-ci-cd-demo-share-actions/.github/workflows/Automation-label-pr-base-branch.yml@main
with:
PR_NUMBER: ${{ github.event.number }}
# If all secrets need to inherit from the caller workflow, use `inherit`
# secrets: inherit
#
# If only specific secrets need to be passed, specify them individually
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Try opening a PR after committing the file.
Workflow: Main Repo’s Demo-Reuse-Action-From-Another-Repo.yml → Calls and passes parameters to another Repo’s Automation-label-pr-base-branch.yml to execute tasks → Returns to the main Repo to report results.
Success!
Private Repo Access Issues
Here, we need to note a security issue: Public Repos have no problems, but if the Reuse Workflow is within a Private Repo, access is only possible if it is within the same organization + the other Repo is also Private + permissions are set to allow = to access and use the Private Repo Reuse Workflow.
Public — ZhgChgLi/ReuseRepo
+Private or Public — ZhgChgLi or harry/myRepo
= ✅Private — ZhgChgLi/ReuseRepo
+Private or Public — harry/myRepo
= ❌Private — ZhgChgLi/ReuseRepo
+Public — ZhgChgLi/anotherRepo
= ❌Private — ZhgChgLi/ReuseRepo
+Private — ZhgChgLi/anotherRepo
= ✅
To set permissions, go to the Reuse Workflow Repo → Settings → Actions → General → Access:
Change the selection to:
1
2
Accessible from repositories in the 'ZhgChgLi' organization
Workflows in other repositories that are part of the 'ZhgChgLi' organization can access the actions and reusable workflows in this repository. Access is allowed only from private repositories.
Click “Save” to complete.
If you do not have permission to access, the following error message will appear:
1
2
3
4
5
Invalid workflow file: .github/workflows/Demo-reuse-action-from-another-repo.yml#L21
error parsing called workflow
".github/workflows/Demo-reuse-action-from-another-repo.yml"
-> "ZhgChgLi/github-actions-ci-cd-demo-share-actions/.github/workflows/Automation-label-pr-base-branch.yml@main"
: workflow was not found.
GitHub Actions Script Syntax Supplement
Here’s a supplement regarding the ${{ XXX }}
or ${XXX}
or $XXX
variable syntax that might confuse some.
Demo-Env-Vars- .yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
name: Demo-Env-Vars
on:
workflow_dispatch:
jobs:
print-env-vars:
runs-on: ubuntu-latest
steps:
- name: print-env
run: env
- name: context-vars-vs-vars
env:
SAY_MY_NAME: "Heisenberg"
# GitHub Actions Context expression, as explained below
FROM_REF: "${{ github.ref }}"
run: |
# Shell Script:
# Referencing custom injected ENV variable content
# ${SAY_MY_NAME} or $SAY_MY_NAME both work
# ${XX} is more useful for string concatenation:
echo "HI: ${SAY_MY_NAME}"
# 💡 GitHub Actions Context expression
# This is not a shell variable, but a value replaced by GitHub Actions during the YAML parsing phase
# ⚠ Will fail if executed locally or outside of GitHub Actions environment
# 🔗 https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
BRANCH_NAME_FROM_CONTEXT="${{ github.ref }}"
# 💡 GitHub Actions runtime environment variables
# These variables are automatically injected by GitHub Actions when the runner executes the shell
# ✅ In other environments, you can use export or ENV to predefine the same variables
# 🔗 https://docs.github.com/en/actions/learn-github-actions/environment-variables
BRANCH_NAME_FROM_ENV_VARS="${GITHUB_REF}"
echo "FROM_REF: ${FROM_REF}"
echo "BRANCH_NAME_FROM_CONTEXT: ${BRANCH_NAME_FROM_CONTEXT}"
echo "BRANCH_NAME_FROM_ENV_VARS: ${BRANCH_NAME_FROM_ENV_VARS}"
- name: print-github-script-env
uses: actions/github-script@v7
env:
SAY_MY_NAME: "Heisenberg"
# GitHub Actions Context expression, as explained above
FROM_REF: "${{ github.ref }}"
with:
script: |
// GitHub Script: (JavaScript (Node.js)):
// Retrieve ENV values from process.env
console.log(`HI: ${process.env.SAY_MY_NAME}`);
console.log(`FROM_REF: ${process.env.FROM_REF}`);
// In github-script, context variables are automatically injected for direct reference in JavaScript
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
const branch_name_from_context_vars = context.ref;
console.log(`branch_name_from_context_vars: ${branch_name_from_context_vars}`);
// You can also use GitHub Actions Context expression (though it’s not very meaningful):
const branch_name_from_context = "${{ github.ref }}";
console.log(`branch_name_from_context: ${branch_name_from_context}`);
for (const [key, value] of Object.entries(process.env)) {
console.log(`${key}=${value}`);
}
// The github object in github-script is an Octokit REST API instance
// Used to interact with the GitHub API
// For example:
// await github.rest.pulls.list({
// owner: context.repo.owner,
// repo: context.repo.repo,
// state: "open"
// });
# gh CLI does NOT use GITHUB_TOKEN by default; requires GH_TOKEN
- name: gh CLI without GH_TOKEN (expected to fail)
continue-on-error: true
run: |
PR_COUNT=$(gh pr list --repo $GITHUB_REPOSITORY --json number --jq 'length')
echo "Found $PR_COUNT open pull requests"
- name: gh CLI with GH_TOKEN (expected to succeed)
env:
# Assign GH_TOKEN so gh CLI can authenticate
# https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-guides/use-github_token-in-workflows
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_COUNT=$(gh pr list --repo $GITHUB_REPOSITORY --json number --jq 'length')
echo "Found $PR_COUNT open pull requests"
- name: github-script auto-authentication (no GH_TOKEN needed)
uses: actions/github-script@v7
with:
script: |
// github = Octokit REST client (auto-authenticated with GITHUB_TOKEN)
const pulls = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: "open"
});
console.log(`Found ${pulls.data.length} open pull requests`);
${ { XXX } } GitHub Actions Context Expression
${{ XXX }}
is a GitHub Actions Context expression, which is replaced by GitHub Actions with the corresponding value during the YAML parsing phase (as shown in the above imageBRANCH_NAME_FROM_CONTEXT
)- For a complete list of usable expressions, refer to the official documentation here
${XXX}
or$XXX
are real environment variables, and${XXX}
is more useful for string concatenation- GitHub Actions automatically injects some environment variables; for a complete list, refer to the official documentation here
actions/github-script@v7
- In
github-script
, you can also use${{ XXX }}
as a GitHub Actions Context expression, but it’s not very meaningful; because in the github-script environment, context variables are automatically injected for direct reference in JavaScript - The
github
object ingithub-script
is an Octokit REST API instance github-script
can also inject variables fromenv:
, but you need to access them using${process.env.xxx}
- Some environment variables are also automatically injected; for a complete list, refer to the official documentation here using
${process.env.xxx}
; but since context variables are already available, accessing them from there is not very meaningful.
github-script
automatically includesgithub-token
, no need to specify.
gh cli GH_TOKEN
- When using
gh cli
in GitHub Actions Shell Script, you need to specify theGH_TOKEN
parameter. - You can directly use the default Token —
secrets.GITHUB_TOKEN
(automatically generated during GitHub Actions execution). - The default Token has some basic permissions; if the command you want to execute does not have enough permissions, you will need to use a Personal Access Token. It is recommended to create a PAT using a shared account for team use.
Complete code: Demo-Env-Vars- .yml
GitHub Actions has been developed; the next step is to replace the GitHub Hosted Runner with our own Self-hosted Runner.
GitHub Hosted Runner has a free quota of 2,000 minutes per month (starting), running such small automation tasks doesn’t take much time, and since it runs on Linux machines, the cost is very low, possibly not even reaching the free limit; there’s no need to switch to a Self-hosted Runner. Changing the Runner also requires ensuring the Runner environment is correct (for example, GitHub Hosted Runner comes with gh cli, while self-hosted Runner needs to have it installed properly), this article is purely for educational purposes to demonstrate the switch.
If it’s for executing CI/CD tasks, then using a Self-hosted Runner is necessary.
Adding a Self-hosted Runner
This article uses macOS M1 as an example.
- Settings → Actions → Runners → New self-hosted runner.
- Runner image: macOS
- Architecture: For M1, remember to select ARM64 for faster execution.
Open a Terminal on your physical computer.
Follow the Download steps to complete on your local computer:
1
2
3
4
5
6
7
8
9
10
# Create a Runner directory at your desired path
mkdir actions-runner && cd actions-runner
# Download the Runner image
curl -o actions-runner-osx-x64-2.325.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.325.0/actions-runner-osx-x64-2.325.0.tar.gz
# Optional: Validate the hash
echo "0562bd934b27ca0c6d8a357df00809fbc7b4d5524d4aeb6ec152e14fd520a4c3 actions-runner-osx-x64-2.325.0.tar.gz" | shasum -a 256 -c
# Unzip
tar xzf ./actions-runner-osx-x64-2.325.0.tar.gz
The above is just a reference; it is recommended to follow the steps on your settings page to ensure you have the latest version of the Runner image.
Configure Settings:
1
2
# Please refer to the commands on the settings page; the token will change over time
./config.sh --url https://github.com/ORG/REPO --token XXX
You will be prompted to enter:
- Enter the name of the runner group to add this runner to: [press Enter for Default] Just press Enter *Only runners registered at the organization level will have the group feature.
- Enter the name of runner: [press Enter for ZhgChgLideMacBook-Pro] You can enter a desired runner name e.g.
app-runner-1
or just press Enter - This runner will have the following labels: ‘self-hosted’, ‘macOS’, ‘X64’ Enter any additional labels (ex. label-1,label-2): [press Enter to skip] Enter the desired runner label; you can add multiple custom labels for easier future use. As mentioned earlier, GitHub Actions/Runner finds tasks based on corresponding labels, if you only use the default label, the runner may pick up other runners in the organization to execute jobs, so it’s safest to create a custom one. I randomly set a label
self-hosted-zhgchgli
. - Enter name of work folder: [press Enter for _work] Just press Enter
When you see √ Settings Saved, it means the setup is complete.
Start the Runner:
1
./run.sh
When you see √ Connected to GitHub, Listening for Jobs, it means it is now listening for Actions tasks:
This Terminal window must remain open to continuously receive tasks.
🚀🚀🚀 You can open multiple Terminals on the same computer in different directories to run multiple Runners.
Returning to the Repo settings page, you can also see the Runner waiting for tasks:
Status:
- Idle: Waiting for tasks
- Active: A task is currently running
- Offline: Runner is not online
Workflow (GitHub Actions) Runner Switching to Self-hosted Runner
Taking Automation-PullRequest.yml
as an example:
1
2
3
4
5
6
7
8
9
10
11
12
13
# Please refer to the previous text, omitted....
jobs:
label-pr-by-file-count:
# Please refer to the previous text, omitted....
runs-on: [self-hosted-zhgchgli]
# Please refer to the previous text, omitted....
# ---------
assign-self-if-no-assignee:
# Please refer to the previous text, omitted....
runs-on: [self-hosted-zhgchgli]
steps:
# Please refer to the previous text, omitted....
After committing the file to the main branch of the repo, reopen the PR to trigger the validation of Actions.
Back in the Runner Terminal, you can see that a new task has come in and is currently executing with the execution results:
It failed because my local computer does not have the gh cli environment installed:
After installing gh on the physical computer using brew install gh
, I triggered the execution again:
Success! Now this task is completely running on our own computer, without using GitHub Hosted Runner, and without incurring costs.
We can click into the Action Log to see which Runner and machine the task is executing on:
runs-on: [ Runner Label Setting
Here it is AND, not OR; GitHub Runner does not currently support OR selection of Runners for execution.
For example: [self-hosted, macOS, app]
→ means the Runner must have all three Labels self-hosted, macOS, app
to match and take on the task for execution.
If a Job wants to test results under different Runner environments simultaneously, you can use the matrix
parameter:
1
2
3
4
5
6
7
8
9
10
11
jobs:
test:
runs-on: ${{ matrix.runner }}
strategy:
matrix:
runner:
- [self-hosted, linux, high-memory]
- [self-hosted, macos, xcode-15]
steps:
- run: echo "Running on ${{ matrix.runner }}"
This way, this Job will run once in parallel on the following two Runner Labels:
- self-hosted, linux, high-memory
- self-hosted, macos, xcode-15
Currently, Runner does not support:
- OR selection of Runners
- Runner weight settings
Registering Runner as a Service
You can refer to the official documentation on “Configuring the self-hosted runner application as a service” to register the Runner directly as a system Service, allowing it to run in the background (without opening Terminal in the foreground) and automatically start after booting.
If you have multiple Runners, remember to adjust the “Customizing the self-hosted runner service” settings to register different names.
I have a pending issue to research regarding iOS, which is that after switching to the background Service, I encountered an error during Archive (suspected to be related to keychain permissions). At that time, I didn’t have time to resolve it, so I continued using the foreground Terminal Runner.
If you want to achieve automatic startup on boot in the traditional foreground way, you need to add an automatic startup configuration file in ~/Library/LaunchAgents
:
actions.runner.REPO.RUNNER_NAME.plist
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>actions.runner.REPO.RUNNER_NAME</string>
<!-- Specify Terminal.app to start -->
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>Terminal</string>
<string>/Users/zhgchgli/Documents/actions-runner/run.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/zhgchgli/Documents/actions-runner</string>
</dict>
</plist>
For those interested in delving deeper into DevOps, you can refer to the official k8s Runner documentation.
Complete Project Repo
Official Documentation
For more detailed setup methods, be sure to refer to the official manual.
AI Can Help!
In practice, providing ChatGPT with the complete steps and timing can help you write GitHub Actions ready for direct use!
Summary
Now you should have a certain understanding of GitHub Actions + Self-hosted Runner. In the next article, I will start building the entire process step-by-step using App (iOS) CI/CD as a case study.
Series Articles:
- CI/CD Practical Guide (1): What is CI/CD? How to build a stable and efficient development team through CI/CD? Tool selection?
- CI/CD Practical Guide (2): Comprehensive use and construction of GitHub Actions and self-hosted Runners
- CI/CD Practical Guide (3): Implementing CI and CD workflows for App projects using GitHub Actions
- CI/CD Practical Guide (4): Using Google Apps Script Web App to connect GitHub Actions to build a free and easy-to-use packaging tool platform
Buy me a coffee
If you have any questions or suggestions, feel free to contact me.
This article was first published on Medium ➡️ Click Here
Automatically converted and synchronized using ZMediumToMarkdown and Medium-to-jekyll-starter.