Post

GitHub Actions|Self-hosted Runner Setup and Usage Guide for Efficient CI/CD

Discover step-by-step instructions to implement GitHub Actions and Self-hosted Runner, resolving CI/CD deployment challenges and accelerating your automation workflows for reliable and scalable software delivery.

GitHub Actions|Self-hosted Runner Setup and Usage Guide for Efficient CI/CD

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


CI/CD Practical Guide (Part 2): Comprehensive Use and Setup of GitHub Actions and Self-hosted Runner

Take you through the basics of GitHub Actions/Self-hosted Runner operation and a step-by-step tutorial.

Photo by [Dan Taylor](https://unsplash.com/@theoneandonlydantaylor?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Dan Taylor

Preface

The previous article “CI/CD Practical Guide (Part 1): What is CI/CD? How to Build a Stable and Efficient Development Team with CI/CD? Tool Selection?” introduced what CI/CD is, the benefits it brings, and tool selection. This article will focus on the architecture and usage of GitHub Actions and Self-hosted Runners, and guide you step-by-step to create several interesting automated workflows to help you get started.

GitHub Actions Architecture Flowchart

Before we begin, let’s first clarify the operational framework, workflow relationships, and responsibilities of GitHub Actions.

GitHub Repo

  • In the world of GitHub Actions, all Actions (Workflow YAML files) must be stored within a Git repo (REPO/.github/workflows/).

GitHub Repo — Actions Secrets

Repo → Settings → Secrets and variables → Actions → Secrets.

  • Store the Secret Key and Token used in the Actions steps
    e.g. Slack Bot Token, Apple Store Connect API .p8 Key

  • *Secrets cannot be viewed in the Action Log and will be automatically masked with * * * **

  • Secrets content cannot be viewed or edited, only overwritten

  • Secrets currently only support plain text content and cannot upload files
    - For binary data, please refer to the official steps to store using Base64 encoding.
  • Can store organization-level Secrets, shared across Repos

GitHub Repo — Actions Variables

Repo → Settings → Secrets and variables → Actions → Variables.

  • Store 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 the Action Log

  • Variables only support plain text, but you can store JSON strings and parse them yourself for use.

  • You can save organization-level Variables and share them across Repos.

GitHub Actions — Trigger

  • The Most Important Starting Point in Github Actions — Trigger Events (Conditions)

  • Only GitHub Actions triggered by the specified event will run.

  • The complete list of events is available in the official documentation

  • It basically covers all event scenarios in CI/CD and automation.
    However, if there are special cases without direct events, you can only use other events combined with conditional checks in the Job or use Schedule to manually check.
    For example, if there is no PR Merged event, you can use pull_request: closed + Job if: github.event.pull_request.merged == true to achieve this.

Common Events:

  • schedule (cron): Scheduled execution (same as crontab)
    Can be used for automation: scheduled PR checks, scheduled builds, scheduled execution of automation scripts
    All run uniformly on main / develop (Default Branch).

  • pull_request:: PR-related events
    When a PR is opened, assigned, labeled, new commits are pushed, etc.

  • issues and issue_comment: Issue-related events
    When an Issue is opened, when there is a new comment, and so on.

  • workflow_dispatch: manual trigger; you can set required input fields, and GitHub Actions provides a simple form for users to fill in information.
    e.g.:

  • workflow_call: Triggers another Action (Workflow) to execute a task.

  • workflow_run: Triggers this task when another Action (Workflow) runs a job.

For more event types and configuration details, please refer to the official documentation.

GitHub Actions — Workflow

  • a.k.a Action

  • Use YAML to write .yaml files, all stored under the REPO/.github/workflows/ directory.

  • Use the Workflow YAML file in the main branch as the source of truth
    If you can’t see the Action being developed or encounter errors in other branches, try merging back into the main branch first.

  • The most basic unit in GitHub Actions, each Workflow represents a CI/CD or automation process.

  • Workflow can call other Workflows to execute tasks
    (This feature can be used to separate core Workflows and called Workflows)

  • This defines the task name, execution strategy, trigger events, task jobs, and all other action-related settings.

  • The current file structure does not support subdirectories.

  • Action Completely Free (Public and Private Repo)

GitHub Actions — Workflow — Job

  • Execution Units in GitHub Actions

  • Definition of Tasks in a Workflow

  • Each Workflow can have multiple Jobs

  • Each Job needs to specify which Runner Label to use. During execution, the corresponding Runner machine will be used to run the task.

  • Multiple Jobs Run Concurrently (use needs to enforce order if needed)

  • Each Job should be treated as an independent execution unit (each as a Sandbox). If a Job produces resource files that need to be used by subsequent Jobs or Workflows after completion, you must Upload Artifacts or move them to a shared output directory on self-hosted runners.

  • Job can output strings for other Jobs to reference.
    (For example, execution results like true or false)

  • If no specific workflow conditions are set, when multiple Jobs are running and one Job encounters an error, the other Jobs will still continue to execute.

GitHub Actions — Reuse Workflow/Job

  • Defining on: workflow_call in a workflow allows it to be encapsulated and reused as a Job by other workflows.

  • Sharing across repositories is possible within the same organization.

  • Therefore, CI/CD tasks shared across repositories can be placed in a shared repository for common use.

GitHub Actions — Workflow — Job — Step

  • Minimal Workflow in GitHub Actions

  • The program that actually executes the tasks in the Job

  • Each Job can have multiple Steps

  • Multiple steps are executed in order

  • Step can output strings for subsequent Steps to reference and use.

  • Steps can directly write shell script programs
    You can reference gh cli and current environment variables (e.g., get PR number) to directly do what you want.

  • If no specific flow conditions are set, the Step will stop immediately if an error occurs, and subsequent Steps will not execute.

GitHub Actions — Workflow — Job — Reuse Action Step

  • You can directly reuse existing workflows created by experts on Marketplace.
    For example: Comment content on PR.

  • You can also package a series of your work task steps into an Action GitHub Repo for others to reuse directly.

  • Public Repo Actions can be published to the Marketplace

Packaging Action supports use:

  • Docker Action — means GitHub Actions will pass environment variables into the Docker container, and then you can handle them however you want, such as with shell scripts, Java, PHP, etc.

  • JavaScript/TypeScript Action — Write GitHub Actions logic directly using node.js, with environment variables provided for your reference.
    e.g. pozil/auto-assign-issue

  • Composite (YAML) — Pure YAML describing task steps (similar to GitHub Actions — Workflow — Job — Step). You can declare which steps to perform or write shell scripts directly within it.
    e.g. ZhgChgLi/ZReviewTender

Due to length constraints, this article will not cover how to package a Github Actions Action. For those interested, please refer to the official documentation: tutorials/creating-a-composite-action.

GitHub Runner

  • GitHub assigns jobs to Runners based on the Runner Label for execution

  • Runner acts only as a listener, polling and monitoring tasks dispatched by GitHub.

  • Only cares about the Job, not which Action (Workflow) it belongs to.
    Therefore, after Action A’s Job-1 finishes, the next one will be Action B’s Job-1, instead of completing all Jobs of Action A before moving to Action B.

  • Runner can use GitHub Hosted Runner or Self-hosted Runner.

GitHub Hosted Runner

  • GitHub provides Runners, refer to the official Repo list:

[List of Images for 2025/06](https://github.com/actions/runner-images){:target="_blank"}

Images list for 2025/06

  • What is pre-installed on the Runner can be viewed by clicking:
    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

  • iOS development prioritizes using the -arm64 (M-series) processor Runner for faster performance

  • Simply paste the YAML Label from the table into the Job run-on to use that Runner for the task.

  • Public Repo Pricing: Completely Free with Unlimited Use

  • Private Repo Free Tier:
    Free tier (varies by account type, GitHub Free as an example):
    Usage: 2,000 minutes free per month
    Storage: 500 MB

  • ⚠️️Private Repo Billing Method:
    After exceeding the free quota, usage-based billing starts (with adjustable limits and notifications). Prices vary depending on the operating system and cores of the machine running 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 macOS is expensive because the device costs are very high.

  • Maximum Concurrent Task Limit:

[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

This part is too off-topic; our focus is on Self-hosted Runner.

Self-hosted Runner on In-house Server

  • Using Your Own Machine as a Runner

  • A physical machine can run multiple Runners to handle tasks concurrently

  • Free Unlimited Usage Without Restrictions
    Only the machine purchase cost, pay once and use as much as you want!
    Based on a 32G RAM M4 Mini (=NT$40,900), if you use GitHub Hosted Runner, it costs 500 USD per month; buying a machine and setting it up pays off in just over three months!

  • Supports Windows, macOS, Linux (x64/ARM/ARM64)

  • The same organization can share Runners across Repos

  • ⚠️Currently: actions/cache, actions/upload-artifact, and actions/download-artifact only support GitHub cloud services, meaning these contents are still uploaded to GitHub servers and storage usage is charged.
    You can set up a shared directory on your own machine as an alternative.

  • Self-hosted Runner also supports Docker, k8s, but I haven’t explored it.

Setting up a Self-hosted Runner takes just a few steps (ready within 10 minutes) to go online and start handling tasks (explained later in this article).

GitHub Workflow x Job x Step x Runner Process Relationship Diagram

Here is a diagram summarizing the workflow and relationships, assuming we have two Workflows and two Runners:

  • CI — Suppose R has 3 Jobs, each Job contains several Steps, Runner Label (run-on) — self-hosted-app

  • CD — There are 4 Jobs, each with several Steps. Runner Label (run-on) — self-hosted-app

  • Runner — There are 2, and both Runner Labels are — self-hosted-app

As mentioned earlier, the Runner and Workflow dispatch and receive tasks based on the Runner Label. In this case, the same self-hosted-app Runner Label is used. Jobs in the Workflow run concurrently by default, while Steps are the actual execution content of each Job, running sequentially in order. When all Steps are completed, the Job is considered finished.

Therefore, the actual execution timeline of the Workflow will alternate between two Runners to run Jobs, can run concurrently, and the next Job after the current one finishes is not necessarily the next Job in the same Workflow.

⚠️ This is why ensuring each Job is independent is crucial (especially in a Self-hosted Runner environment, where cleanup after a Job may not be thorough. We might subconsciously assume the output of one Job can be used by the next Job), but it should not be used this way.

If a Job produces output to be used by subsequent Jobs:

  • Job Output String : Output plain text to a variable for other jobs to reference

  • Artifact-upload/download: The last step of a Job uploads the execution results to GitHub Artifact, and the first step of another Job downloads it for further processing.
    e.g. For example, Job — Packaging → upload the packaged .ipa to Artifact → Job — Deployment → download the .ipa → upload to App Store for deployment
    Note: Currently, even for Self-hosted runners, the artifacts are stored on GitHub Artifact cloud storage.

  • AWS/GCP… Cloud Storage: Same as above, but using your own cloud storage service.

  • [Self-hosted Only] Shared Drive, Directory: If all Self-hosted Runners mount a shared directory, you can create folders based on UUIDs in this directory to store output results. Subsequent jobs can read the previous job’s output UUID to locate and retrieve the corresponding stored data.
    Note that 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 Study Implementation

“Saying is not as good as doing.” The above explanation of terms and process structure might have been confusing. Next, we will directly show three functional examples, guiding you to practice hands-on while explaining encountered concepts, learning by doing to understand what GitHub Actions really is.

Case — 1

Automatically add File Changes Size Label after creating a Pull Request to help Reviewers manage their review tasks easily.

Results 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, Pushes New Commit to PR

  • Trigger GitHub Actions Workflow

  • shell script to get the number of file changes

  • Determine Quantity to Label PRs

  • 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 group them by trigger event or purpose in the same file. Multiple Jobs run concurrently. Additionally, because GitHub Actions currently does not support directory structures, having fewer files and using hierarchical file naming is easier to manage.

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 event
  pull_request:
    # PR - opened, reopened, or new Push Commit
    types: [opened, synchronize, reopened]

# Cancel running jobs in the same concurrency group if a new job starts
# For example, if a new Push Commit triggers a job before the previous one runs, the previous job is canceled
concurrency:
  group: ${{ github.workflow }}-${{ github.ref_name }}
  cancel-in-progress: true

# Job tasks
# Jobs run concurrently
jobs:
  # Job ID
  label-pr-by-file-count:
    # Job name (optional, better for logs)
    name: Label PR by changes file count

    # If this job fails, it won't affect the whole workflow; other jobs continue
    continue-on-error: true
    
    # Set max timeout to avoid endless waiting in abnormal cases
    timeout-minutes: 10

    # Runner Label - use GitHub Hosted Runner ubuntu-latest to run the job
    # For Private Repos, usage is counted and may incur costs
    runs-on: ubuntu-latest

    # Job steps
    # Steps run sequentially
    steps:
      # Step name
      - name: Get changed file count and apply label
        # Step ID (optional, only needed if outputs are referenced later)
        id: get-changed-files-count-by-gh
        # Inject external environment variables into runtime
        env:
          # secrets.GITHUB_TOKEN is automatically generated by GitHub Actions, no need to set manually, has some GitHub Repo API scopes
          # https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-guides/use-github_token-in-workflows
          # gh (GitHub CLI) requires GH_TOKEN in ENV for permission to operate
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        # Shell script
        # GitHub Hosted Runner has gh CLI preinstalled, no need to install before use
        run: \|
          #   ${{ github.xxx }} is a GitHub Actions Context expression
          #   Not a shell variable, replaced by GitHub Actions during YAML parsing
          #   More info: 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 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 current Size labels
          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 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 "Size label: $LABEL" --color "ededed"
          else
            echo "✅ Label '$LABEL' already exists"
          fi
          
          # Use GitHub API (gh CLI) to add the label
          gh pr edit $PR_NUMBER --repo $REPO --add-label "$LABEL"

After committing files to the repo’s main branch, opening a new PR will automatically trigger GitHub Actions:

Action execution status showing Queued means the task is waiting for the Runner to pick it up and execute.

Execution Result

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

Demo PR

After execution is complete and successful, the PR will automatically be labeled accordingly! The record will show it was labeled by github-actions.

Full code: Automation-PullRequest.yml

Use Prebuilt Action Steps: pascalgn/size-label-action

Earlier, it was mentioned that you can directly use pre-built Actions created by others. The task of marking the PR Size Label already has ready-made solutions available. The above example is only for teaching purposes; in practice, you don’t need to reinvent the wheel.

You can complete the task directly by using it 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
50
# Workflow(Action) Name
name: Pull Request Automation

# Trigger Event
on:
  # PR event
  pull_request:
    # PR - opened, reopened, or new push commit
    types: [opened, synchronize, reopened]


# Cancel running jobs in the same Concurrency Group if a new job starts
# For example, if a push commit triggers a job and another push commit happens before it runs, the previous job will be canceled
concurrency:
  group: ${{ github.workflow }}-${{ github.ref_name }}
  cancel-in-progress: true

# Job tasks
# Jobs run concurrently
jobs:
  # Job ID
  label-pr-by-file-count:
    # Job name (optional, better readability in logs)
    name: Label PR by changes file count

    # If this job fails, do not affect the entire workflow, continue with other jobs
    continue-on-error: true
    
    # Set maximum timeout to prevent endless waiting in case of issues
    timeout-minutes: 10

    # Runner Label - use GitHub Hosted Runner ubuntu-latest to run the job
    # For private repos, usage counts and may incur costs
    runs-on: ubuntu-latest

    # Job steps
    # Steps run sequentially
    steps:
      # Step name
      - name: Get changed file count and apply label
        # Step ID (optional, only needed if outputs are referenced later)
        id: get-changed-files-count-by-gh
        # Use a pre-built action from others
        uses: "pascalgn/size-label-action@v0.5.5"
        # Inject external environment variables into runtime
        # For parameter names and usage, see: https://github.com/pascalgn/size-label-action/tree/main
        env:
          # secrets.GITHUB_TOKEN is automatically generated by GitHub Actions (github-actions identity), no need to set manually in Secrets, has some GitHub Repo API scopes
          # 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 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, Branch, Author, etc.)
    If the event is on:pull_request, the default is the PR Title.

Case — 2

Automatically assign the author and add a comment prompt if there is no Assignee after creating a Pull Request. (This only runs on the initial creation)

Result Image

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

Demo PR

Workflow

  • User opens a PR

  • Trigger GitHub Actions Workflow

  • GitHub script to get assignee

  • If there is no assignee, assign the PR author & comment a 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
74
# Workflow(Action) Name
name: Pull Request Automation

# Actions Log Title
run-name: "Pull Request Automation - Daily Checker"

# Trigger Events
on:
  # PR Event
  pull_request:
    # PR - opened, reopened, or new push commit
    types: [opened, synchronize, reopened]


# Cancel running jobs in the same concurrency group if a new job starts
# For example, if a push commit triggers a job that hasn't started yet and a new push commit happens, the previous job is canceled
concurrency:
  group: ${{ github.workflow }}-${{ github.ref_name }}
  cancel-in-progress: true

# Job Tasks
# Jobs run concurrently
jobs:
  # Job ID
  label-pr-by-file-count:
    # Please refer to previous text, omitted....
  # ---------
  assign-self-if-no-assignee:
    name: Automatically assign to self if no assignee is specified
    # Since the trigger event is shared, this job checks if the PR is opened (first creation) before running; otherwise, it will be skipped
    if: github.event_name == 'pull_request' && github.event.action == 'opened'

    # If this job fails, it won't affect the whole workflow; other jobs continue
    continue-on-error: true
    
    # Set maximum timeout to prevent endless waiting in abnormal situations
    timeout-minutes: 10
    
    # Runner Label - use GitHub Hosted Runner ubuntu-latest to run the job
    # For private repos, usage is counted and may incur charges
    runs-on: ubuntu-latest

    steps:
      - name: Assign self if No Assignee
        # Use GitHub Script (JavaScript) to write the script (Node.js environment)
        # Compared to shell script above, this is easier and cleaner
        # No need to manually inject environment variables or GITHUB_TOKEN
        uses: actions/github-script@v7
        with:
          script: \|
            // github-script automatically injects the context variable for direct JavaScript use
            // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context

            const issue = context.payload.pull_request; // To support issues too, use context.payload.issue \|\| context.payload.pull_request
            const assignees = issue.assignees \|\| [];
            const me = context.actor;

            if (assignees.length === 0) {
              // Assign self as assignee
              await github.rest.issues.addAssignees({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                assignees: [me]
              });

              // Leave comment notification
              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, offering more flexibility and easier coding.

Of course, if you want to follow what I said before about one file per task, you can remove the Job If. . and directly set the trigger conditions in the Action Workflow:

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 Event
on:
  # PR Event
  pull_request:
    # PR - When opened
    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 text, omitted....

After committing files to the repo’s main branch, opening a new PR will automatically trigger GitHub Actions:

There are now two jobs to be executed!

Execution Results

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

Demo PR

After execution and success, if the PR has no Assignees, it will automatically assign the PR author and comment a message. (All actions are performed using the github-actions identity)

Full Code: Automation-PullRequest.yml

Testing Reopened PR

You can see that only the Size Label Job was executed, while the Auto Assignee Job was skipped.

This task also has a ready-made Action that can be reused, see: pozil/auto-assign-issue.

Case — 3

Automatically count the current number of PRs and how long they have been open every day at 9 AM, then send a notification message to the Slack workgroup. Automatically close PRs that have been open for more than 3 months.

Result Image

  • Slack workgroup automatically receives reports every morning

  • Automatically close PRs older than 90 days

Workflow

  • GitHub Actions Automatically Triggered Every Day at 9 AM

  • Trigger GitHub Actions Workflow

  • GitHub script to get the list of open PRs and count how many days they have been open

  • Send Statistical Report Messages to Slack

  • Close PRs Older Than 90 Days

  • Completed

Hands-On Practice

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
152
# Workflow(Action) Name
name: Pull Request Automation - Daily Checker

# Trigger Events
on:
  # Scheduled automatic execution
  # https://crontab.guru/
  # UTC time
  schedule:
    # 01:00 UTC = 09:00 UTC+8 daily
    - cron: '0 1 * * *'
  # Manual trigger
  workflow_dispatch:

# Job Items
# Jobs run concurrently
jobs:
  # Job ID
  caculate-pr-status:
    # Job Name (optional, improves log readability)
    name: Calculate PR Status
    # Runner Label - Use GitHub Hosted Runner ubuntu-latest to run the job
    # For private repos, usage counts and may incur costs
    runs-on: ubuntu-latest

    # Job Output
    outputs:
      pr_list: ${{ steps.pr-info.outputs.pr_list }}

    # Job Steps
    # Steps run sequentially
    steps:
      # Step Name
      - name: Fetch open PRs and calculate
        # To expose Step Output, set id
        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 output, only accepts String
            core.setOutput('pr_list', JSON.stringify(result));
  # ----
  send-pr-summary-message-to-slack:
    name: Send PR Summary Message to Slack
    # Jobs run concurrently by default; using needs forces this job to wait for the specified job to complete
    needs: [caculate-pr-status]
    runs-on: ubuntu-latest
    
    steps:
      - name: Generate Message
        # To expose Step Output, set id
        id: gen-msg
        uses: actions/github-script@v7
        with:
          script: \|
            const prList = JSON.parse(`${{ needs.caculate-pr-status.outputs.pr_list }}`);
            const blocks = [];
      
            // Title
            blocks.push({
              type: "section",
              text: {
                type: "mrkdwn",
                text: `📬 *Open PR Report*\nTotal: *${prList.length}* PR(s)`
              }
            });
      
            // One line per PR
            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 output, only accepts String
            core.setOutput('blocks', JSON.stringify(blocks));

            
      # Use Slack's 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: [caculate-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.caculate-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 schedule for automatic trigger and workflow_dispatch for manual trigger support

  • Job output/Step output (must be strings only)

  • Multiple jobs run concurrently by default but can use needs to set time-dependent dependencies.

  • Get Settings from Repo Secrets/Variables

  • Connecting to Slack API

Repo Secrets — Add SLACK_BOT_TOKEN

  • For setting up a Slack App and configuring message permissions, you can refer to my previous article.

Repo Variables — Add SLACK_TEAM_CHANNEL_ID

After committing files to the Repo main branch, go back to Actions and manually trigger it to check:

It will be automatically triggered 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 workflow will have Calculate PR Status complete first, then Auto Close Old PRs and Send PR Summary Message to Slack will run concurrently.

Execution Result

After all tasks are successfully executed, you can check the Slack messages:

Success 🚀🚀🚀

Full code: Automation-PullRequest-Daily.yml

Summary

I hope the above three examples give you a basic understanding of GitHub Actions and inspire your creativity in automation. You can design your own workflows (be sure to first check the trigger events) and then write scripts to execute them. Also, remember to visit the Marketplace to find existing actions you can use to stand on the shoulders of giants.

This article is just an introduction (without even checking out the code). The next article, CI/CD Practical Guide (Part 3): Implementing CI and CD Workflows for App Projects Using GitHub Actions, will cover more complex and advanced GitHub Actions workflows.

GitHub Automation Extended Topics

GitHub can integrate with Slack to subscribe to Repo PR update notifications, Push Default Branch notifications, and more.

Issue 1. GitHub messages cannot tag people, only GitHub accounts are tagged, Slack accounts do not receive notifications:

Slack App or search for GitHub in Apps → open the message window → complete the Connect GitHub Account step. This lets GitHub know your corresponding Slack UID so it can mention you.

Question 2. Pull Request Reminder

I remember this was originally a third-party feature, but later it was directly integrated into GitHub. Don’t foolishly write scripts yourself using GitHub Actions!

GitHub’s built-in setting is by Team. You need to create a Team for your organization and add Repos to the Team before you can set up Pull Request Reminders.

  • After joining the Slack Channel and saving the rules, daily reminder messages will be automatically sent with the Reviewer tagged. Setup instructions can be found here. The result is shown in the image below:

<https://michaelheap.com/github-scheduled-reminders/>{:target="_blank"}

https://michaelheap.com/github-scheduled-reminders/

Issue 4. GitHub PR message, author does not receive notification:

This is a perennial issue: the PR Slack message does not actually notify the author when someone replies in Threads. The Slack message sent by GitHub only mentions Reviewers, not the Assignee or PR author.

At this point, GitHub Actions as an automation tool comes in handy. We can add a Job to append the author tag at the end of the PR Description, so the message sent to Slack 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 itself checks and runs only 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 won't affect the entire Workflow; other Jobs will continue
    continue-on-error: true
    
    # Set maximum timeout to prevent endless waiting in abnormal situations
    timeout-minutes: 10
    
    # Runner Label - use GitHub Hosted Runner ubuntu-latest to run the job
    # For Private Repos, usage counts and may incur costs if exceeded
    # But such small automation jobs rarely exceed limits
    runs-on: ubuntu-latest
    steps:
      - name: Append author to PR description
        env:
          # secrets.GITHUB_TOKEN is automatically generated by GitHub Actions during execution; no need to set manually in Secrets, it has some GitHub Repo API scopes
          # gh (GitHub) CLI requires GH_TOKEN injected into ENV to have 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
          #   Not a shell variable, but replaced by GitHub Actions during YAML parsing with corresponding values
          #   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, the author information is automatically added after the description, and Slack messages correctly tag the author:

Issue 5. GitHub sending excessive notification emails is very disruptive:

After setting up notifications for both the GitHub Repo and Slack, all notifications are received in 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 up to five Actions on the Actions page. You can also disable an Action to pause it.

You can view GitHub Actions usage and performance in Insights:

Reuse Workflow Supplement

Next article uses the same repo to split Reuse Workflow. Here is 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
75
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 event
on:
  # Triggered by other workflows calling this workflow
  workflow_call:
    # Data input
    inputs:
      # PR Number
      PR_NUMBER:
        required: true
        type: string
    # Secret input
    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 PR's base branch
          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 label is in 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 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 base branch label
          echo "🏷️ Adding label '$LABEL' to PR #$PR_NUMBER"
          gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$LABEL"

Back to our main ZhgChgLi/github-actions-ci-cd-demo repo, 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 method cannot specify the branch (always uses the caller workflow's branch)
    #    Reference examples:
    #    - CD-Deploy-Form.yml calls workflow in this repo
    #    - CD-Deploy.yml calls workflow across repos
    #
    # ✅ 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 should be inherited from the caller workflow, use `inherit`
    # secrets: inherit
    #
    # If only specific secrets are needed, specify individually
    secrets:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Try opening a PR after committing the file.

Workflow: The 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 is a security issue to note: Public Repos are completely fine, but if Reuse Workflow is in a Private Repo, it can only be accessed and used if other Repos are in the same organization + those Repos are also Private + permissions are granted.

  • 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 = ✅

Set allowed methods by going to Reuse Workflow Repo → Settings → Actions → General → Access:

Change selection:

1
2
Accessible from repositories in the 'ZhgChgLi' organization
Workflows in other repositories within the 'ZhgChgLi' organization can access the actions and reusable workflows in this repository. Access is allowed only from private repositories.

Click “Save” to save and complete.

If you don’t have access permission, 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

Adding some clarification on the ${{ XXX }} or ${XXX} or $XXX variable syntax that everyone might find confusing.

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, explained below
          FROM_REF: "${{ github.ref }}"
        run: \|
          # Shell Script:
          # Referencing custom injected ENV variable values
          # ${SAY_MY_NAME} or $SAY_MY_NAME both work
          # ${XX} is better for string concatenation:
          echo "HI: ${SAY_MY_NAME}"
          
          # 💡 GitHub Actions Context expression
          # This is not a shell variable, but replaced by GitHub Actions during YAML parsing
          # ⚠ Will fail if run locally or outside 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
          # ✅ Can be pre-defined in other environments with export or ENV
          # 🔗 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, same as above
          FROM_REF: "${{ github.ref }}"
        with:
          script: \|
            // GitHub Script: (JavaScript (Node.js)):
            // Get ENV values from process.env
            console.log(`HI: ${process.env.SAY_MY_NAME}`);
            console.log(`FROM_REF: ${process.env.FROM_REF}`);
            // github-script automatically injects context variables for direct JavaScript access
            // 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}`);
            // Can also use GitHub Actions Context expression (though less 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 for interacting 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, replaced by GitHub Actions with the corresponding value during the YAML parsing phase (as shown above with BRANCH_NAME_FROM_CONTEXT)

  • The complete usable expressions can be referenced in the official documentation

  • ${XXX} or $XXX are real environment variables. ${XXX} is more convenient for string concatenation.

  • GitHub Actions injects some default environment variables. For the full list, please refer to the official documentation

actions/github-script@v7

  • In github-script, you can also use ${{ XXX }}, which is a GitHub Actions Context expression, but it is not very meaningful; because in the github-script environment, context variables are injected by default and can be directly referenced in JavaScript.

  • In github-script, the github object is an instance of the Octokit REST API.

  • github-script can also inject variables from env:, but you need to access them using ${process.env.xxx}.

  • The same environment variables are injected by default and can be fully referenced in the official documentation. Access them via ${process.env.xxx}; however, since there is already a context variable, accessing them this way is not very meaningful.

  • github-script automatically includes the github-token, no need to specify it.

gh cli GH_TOKEN

  • Using gh cli in GitHub Actions Shell Script requires specifying the GH_TOKEN parameter

  • You can directly use the default token — secrets.GITHUB_TOKEN (automatically generated during GitHub Actions execution)

  • The default token has basic permissions. If higher permissions are needed, use a Personal Access Token. For team use, it is recommended to create a PAT with a shared account.

Full code: Demo-Env-Vars- .yml

GitHub Actions has been developed. The next step is to replace the GitHub Hosted Runner with your own Self-hosted Runner.

GitHub Hosted Runner offers a free quota of 2,000 minutes per month (starting point). Running these small automation tasks takes little time, and since it runs on Linux machines, the cost is very low, often not even reaching the free limit; it’s not necessary to switch to a Self-hosted Runner. Changing the Runner also requires ensuring the Runner environment is set up correctly (for example, GitHub Hosted Runner comes with the gh CLI pre-installed, but with a self-hosted Runner, you need to install it yourself). This article only switches Runners for teaching purposes.

Self-hosted Runner is only required when running CI/CD tasks.

Add 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 a physical computer.

Complete on your local computer following the Download steps:

1
2
3
4
5
6
7
8
9
10
# Create the Runner directory in 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

# Extract the archive
tar xzf ./actions-runner-osx-x64-2.325.0.tar.gz

The above is for reference only; it is recommended to follow the steps on your setup page to get the latest version of the Runner image.

設定 Configure:

1
2
# Please refer to the settings page command, the Token changes over time
./config.sh --url https://github.com/ORG/REPO --token XXX

You will be asked to enter in sequence:

  • Enter the name of the runner group to add this runner to: [press Enter for Default] Press Enter directly
    Only runners registered at the Organization level have the Group grouping feature

  • Enter the name of runner: [press Enter for ZhgChgLideMacBook-Pro] You can enter the 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 Runner labels you want to set. You can add multiple custom labels for easier use later.
    As mentioned before, GitHub Actions/Runner finds tasks based on the corresponding labels. If you only use the default labels, the runner might pick up jobs intended for other runners in the organization. Creating a custom label is the safest option.
    Here, I randomly set a label self-hosted-zhgchgli

  • Enter name of work folder: [press Enter for _work] Press Enter directly

When √ Settings Saved. appears, it means the settings are complete.

Start Runner:

1
./run.sh

When you see √ Connected to GitHub and Listening for Jobs, it means the Actions tasks are being monitored:

This Terminal window will keep receiving tasks as long as it remains open.

🚀🚀🚀You can start multiple Runners by opening multiple Terminals in different directories on the same computer.

Back on the Repo settings page, you can also see the Runner waiting for tasks:

Status:

  • Idle: inactive, waiting for tasks

  • Active: A task is currently running

  • Offline: Runner is not online

Workflow (GitHub Actions) Runner Switch 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 files to the Repo’s main branch, reopen a PR to trigger and verify the Actions.

Back in the Runner Terminal, you can see new tasks coming in, along with their execution and results:

Failed because my local computer does not have the gh cli environment installed:

After installing gh on a physical computer using brew install gh, run it again:

Success! Now this task runs entirely on our own computer, without using GitHub Hosted Runner or incurring any charges.

We can click into the Action Log to see which Runner and machine the task is running on:

runs-on: [ Runner Label] Setting

This is AND, not OR. GitHub Runner currently does not support OR when selecting Runners to execute.

For example: [self-hosted, macOS, app] → means the Runner must have all three labels self-hosted, macOS, app to match and execute the task.

If a job wants to test results across 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 Job will run concurrently once in each of the following two Runner Labels Runners:

  • self-hosted, linux, high-memory

  • self-hosted, macos, xcode-15

Runner is currently not supported:

- OR Select Runner

- Runner Weight Settings

Register Runner as a Service

You can refer to the official document “Configuring the self-hosted runner application as a service” to register the Runner directly as a system service. This allows it to run in the background (no need to keep the Terminal open in the foreground) and start automatically after boot.

If you have multiple runners, remember to adjust the “Customizing the self-hosted runner service” settings to register different names.

iOS side, I have an issue to investigate and exclude: after switching to background Service, I encounter errors during Archive (suspected to be related to keychain permissions). I didn’t have time to fix it then, so I temporarily used the foreground Terminal Runner.

If you want a traditional app to start automatically on boot, you need to add a launch agent 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 launch -->
  <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 diving deeper into DevOps, please refer to the official k8s Runner documentation.

Complete Project Repo

Official Documentation

For more detailed setup instructions, please refer to the official manual.

AI Can Help!

Tested: Provide ChatGPT with complete steps and timing, and it can write GitHub Actions for you to use directly!

Summary

You should now have a basic understanding of GitHub Actions + Self-hosted Runner. In the next article, I will start using an App (iOS) CI/CD as a case study to guide you through building the entire process step by step.

Series Articles:

Buy me a coffee

This series of articles took a lot of time and effort to write. If the content helps you or significantly improves your team’s work efficiency and product quality, please consider buying me a coffee. Thank you for your support!

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

Buy me a coffee

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.