Post

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.

CI/CD Practical Guide (Part 2): Comprehensive Guide to Using and Building GitHub Actions and Self-hosted Runners

ℹ️ℹ️ℹ️ 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](https://unsplash.com/@theoneandonlydantaylor?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

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.

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 + Job if: 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:

[Images list from June 2025](https://github.com/actions/runner-images){:target="_blank"}

Images list from June 2025

  • You can check what is pre-installed on the Runners: e.g. macos-14-arm64

[macos-14-arm64](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md){:target="_blank"}

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](https://docs.github.com/en/billing/managing-billing-for-your-products/about-billing-for-github-actions){:target="_blank"}

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](https://docs.github.com/en/actions/concepts/overview/usage-limits-billing-and-administration#usage-limits){:target="_blank"}

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

[Demo PR](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/pull/11){:target="_blank"}

Demo PR

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

[Demo PR](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/pull/11){:target="_blank"}

Demo PR

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

[Demo PR](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/pull/11){:target="_blank"}

Demo PR

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

[Demo PR](https://github.com/ZhgChgLi/github-actions-ci-cd-demo/pull/11){:target="_blank"}

Demo PR

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!

GitHub has a built-in by Team setting; you need to first add a Team for the organization, add the Repo to the Team, and then you can set up Pull Request Reminders.

  • 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/](https://michaelheap.com/github-scheduled-reminders/){:target="_blank"}

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 image BRANCH_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 in github-script is an Octokit REST API instance
  • github-script can also inject variables from env:, 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 includes github-token, no need to specify.

gh cli GH_TOKEN

  • When using gh cli in GitHub Actions Shell Script, you need to specify the GH_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:

Buy me a coffee

This series of articles has taken a significant amount of time and effort to write. If the content has been helpful to you and has made a tangible improvement in your team’s work efficiency and product quality, feel free to buy me a coffee as a token of appreciation!

[Buy me a coffee](https://www.buymeacoffee.com/zhgchgli){:target="_blank"}

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.

Improve this page on Github.

Buy me a beer

219 Total Views
Last Statistics Date: 2025-07-23 | 163 Views on Medium.
This post is licensed under CC BY 4.0 by the author.