Post

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

A comprehensive introduction to how GitHub Actions and Self-hosted Runners work, along with a step-by-step tutorial.

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

A comprehensive introduction to how GitHub Actions and Self-hosted Runners work, along with 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

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, and we will build several interesting automation workflows together to help you get started.

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/ )
  • Actions can be shared across Repos within the same organization

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 Action steps e.g. Simulator iOS version, working directory
  • Variable contents can be viewed and edited
  • Variable contents can be output in Action Logs
  • Variables only support plain text, but can also store JSON strings for parsing and use

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 event scenarios. 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 achieve it with pull_request: closed + Job if: github.event.pull_request.merged == true.

Commonly used events:

  • schedule (cron): Scheduled execution (like crontab) Can be used for automation: regularly checking PRs, packaging, executing automation scripts
  • pull_request:: PR-related events Triggered when a PR is opened, reopened, assigned, or has new Push Commits, etc.
  • issues, issue_comment: Issue-related events Triggered when an Issue is opened or has new comments, etc.
  • workflow_dispatch: Manually triggered; you can set required fields, 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 tasks.

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 under REPO/.github/workflows/
  • The Workflow YAML file in the main branch takes precedence
  • 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 work, and all Action-related settings
  • Currently, 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 an order, you can use needs to constrain)
  • Each Job should be considered an independent execution entity (each should be treated as a Sandbox), and if there are output resource files after the Job ends that need to be used by subsequent Jobs/Workflows, you need to Upload Artifacts or move them to a shared output directory on self-hosted.
  • A Job can output strings for reference by other Jobs. (e.g. execution result true or false)

GitHub Actions — Workflow — Job — Step

  • The smallest execution unit in GitHub Actions
  • The actual program that executes tasks within a Job
  • Each Job can have multiple Steps
  • Multiple Steps are executed in order
  • A Step can output strings for subsequent Steps to reference.
  • Steps can directly write shell script programs You can reference gh cli, current environment variables (e.g., to get PR numbers), and directly perform the desired actions.

GitHub Actions — Workflow — Job — Reuse Action Step

  • You can directly reuse existing work steps packaged by various experts on the Marketplace For example: Comment content to PR.
  • You can also package a series of work task Steps into an Action GitHub Repo for others to reuse directly
  • 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 you wish, whether it be shell script, Java, PHP, etc.
  • JavaScript/TypeScript Action — Directly uses 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 above. 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 dispatches 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 Job-1 of Action A complete, and then Job-1 of Action B will run next, rather than waiting for all Jobs of Action A to finish 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:

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

Image list from June 2025

  • You can click to see what is pre-installed on the Runner: 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 into the Job run-on to use that Runner for task execution
  • Public Repos are completely free for unlimited use
  • Private Repos have a free quota: Free quota (varies by account; for GitHub Free, for example): Usage: 2,000 free minutes per month Storage: 500 MB
  • ⚠️️Private Repo billing method: After exceeding the free quota, usage-based billing begins (with limits and notifications), and prices vary 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

As you can see, 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 a bit; our focus is on Self-hosted Runners.

Self-hosted Runner on In-house Server

  • Use your own machine as a Runner
  • A single physical machine can run multiple Runners concurrently to handle tasks
  • Free unlimited usage You only incur the cost of purchasing the machine, and you can use it indefinitely! For example, if a 32G RAM M4 Mini (costing NT$40,900) would cost $500 USD per month using GitHub Hosted Runners; setting up one machine would pay off in just over three months!
  • Setting up a Runner only requires 5 steps (within 10 minutes) to get online and start accepting tasks
  • Supports Windows, macOS, Linux (x64/ARM/ARM64)
  • ⚠️Currently: actions/cache, actions/upload-artifact, actions/download-artifact only support GitHub cloud services, meaning these contents will still be uploaded to GitHub servers and counted towards storage fees. You can set up a shared directory on your own machine instead.

Learn by Doing GitHub Actions — Case Implementation

“Better to act than to talk.” The explanations of terms and the architectural overview above may have left you a bit confused. Next, I will provide three functional examples to guide you through hands-on practice while explaining the concepts encountered, allowing you to learn what GitHub Actions truly is.

Case — 1

Automatically label File Changes Size after creating a Pull Request to facilitate the Reviewer’s work.

Result Image

Operation Process

  • User opens PR, reopens PR, or pushes new commits to PR
  • Triggers GitHub Actions Workflow
  • Shell script retrieves the number of file changes
  • Determines the number and labels the PR accordingly
  • Completed

Hands-on

Repo → Actions → New workflow → set up a workflow yourself.

File Name: Automation-PullRequest.yml

Action Workflows can be independent files for each task, or you can aggregate them in the same file based on trigger events and purposes. Since multiple Jobs execute concurrently, and because GitHub Actions currently does not support directory structures, having fewer files and using hierarchical naming will make management easier.

Here, all PR-related Actions 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
# Workflow (Action) name
name: Pull Request Automation

# Trigger events
on:
  # PR events
  pull_request:
    # When PR is opened, reopened, or has new Push Commits
    types: [opened, synchronize, reopened]


# The same Concurrency Group will cancel any running Jobs if a new one is triggered
# For example, if a task triggered by a Push Commit hasn't 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 tasks
# Jobs will execute concurrently
jobs:
  # Job ID
  label-pr-by-file-count:
    # Job name (optional; setting it makes logs easier to read)
    name: Label PR by changes file count
    # Runner Label - use GitHub Hosted Runner ubuntu-latest to execute the task
    # If it's a Private Repo, usage will be counted, which may incur costs
    runs-on: ubuntu-latest

    # Steps
    # Steps will execute in order
    steps:
      # Step name
      - name: Get changed file count and apply label
        # Step ID (optional; if there are no subsequent Steps referencing Output, this doesn't need to be set)
        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 (with github-actions identity), no need to set it in Secrets, it has some GitHub Repo API Scopes permissions
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        # Shell script
        # GitHub Hosted Runners have gh cli pre-installed, so you can use it directly without installation
        run: |
          # ${{ github }} is a variable reference automatically injected during GitHub Actions execution, which can be used to obtain runtime information
          
          # 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"
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
# 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 add the label
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 a runner to take over.

Execution Result

Once completed successfully, the PR will automatically be labeled with the corresponding label! The record will show that it was labeled by github-actions.

Complete Code: Automation-PullRequest.yml

Directly Use Pre-packaged Action Steps: pascalgn/size-label-action

As mentioned earlier, you can directly use pre-packaged actions. The task of labeling PR Size has a ready-made solution available; the above is just for educational purposes, and you don’t need to reinvent the wheel.

You can simply use it directly in the Action Workflow Job Step to complete the task:

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

# Trigger Events
on:
  # PR Events
  pull_request:
    # PR - When opened, reopened, or when there is a new push commit
    types: [opened, synchronize, reopened]

# If there is a new job in the same concurrency group, it will cancel the running one
# For example, if a task triggered by a push commit has not yet executed 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, better for readability in logs)
    name: Label PR by changes file count
    # 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 outputs to reference)
        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 can be referenced here: https://github.com/pascalgn/size-label-action/tree/main
        env:
          # secrets.GITHUB_TOKEN is a token automatically generated during GitHub Actions execution (with github-actions identity), no need to set it in Secrets, it has some GitHub Repo API Scopes permissions
          GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

This pre-packaged action is a JavaScript Action, and you can refer to the actual code execution in the following file: dist/index.js.

Case Study — 2

After creating a Pull Request, if there is no assignee, it will automatically assign the author to themselves and comment with a notification. (This only executes when the PR is first created)

Result Image

Workflow

  • User opens a PR
  • Triggers GitHub Actions Workflow
  • GitHub script retrieves assignees
  • If there are no assignees, it assigns the author of the PR & comments a message
  • Completed

Implementation

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

# Trigger Events
on:
  # PR Events
  pull_request:
    # PR - When opened, reopened, or when there is a new push commit
    types: [opened, synchronize, reopened]

# If there is a new job in the same concurrency group, it will cancel the running one
# For example, if a task triggered by a push commit has not yet executed 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 text, omitted....
  # ---------
  assign-self-if-no-assignee:
    name: Automatically assign to self if no assignee is specified
    # Since this is a shared trigger event, we will determine in the job itself that it only executes when the Pull Request is opened (first created), otherwise it will be skipped
    if: github.event_name == 'pull_request' && github.event.action == 'opened'
    
    # 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 in Shell Script, this is more convenient and cleaner
        # No need to inject environment variables or GITHUB_TOKEN
        uses: actions/github-script@v7
        with:
          script: |
            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 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, which offers more flexibility and better syntax.

Of course, if you want to follow my previous suggestion of having one file per task, 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 - 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 the file to the main branch of the repo, opening a new PR will automatically trigger GitHub Actions:

Now there are two jobs to execute!

Execution Result

Once completed successfully, if the PR has no assignees, it will automatically assign the PR author and comment with a message. (All actions are performed under the github-actions identity)

Complete Code: Automation-PullRequest.yml

Test 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, refer to: pozil/auto-assign-issue.

Case Study — 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 more than 3 months.

Result Image

  • The Slack workgroup receives a report automatically 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 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
  • Completed

Implementation

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

# Trigger Events
on:
  # Scheduled to run automatically
  # https://crontab.guru/
  # UTC Time
  schedule:
    # 01:00 UTC = 09:00 UTC+8 every day
    - cron: '0 1 * * *'
  # Manual trigger
  workflow_dispatch:

# Job Items
# Jobs will run concurrently
jobs:
  # Job ID
  calculate-pr-status:
    # Job Name (optional, better for readability in logs)
    name: Calculate PR Status
    # 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 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 output, only accepts String
            core.setOutput('pr_list', JSON.stringify(result));
  # ----
  send-pr-summary-message-to-slack:
    name: Send PR Summary Message to Slack
    # Job defaults to concurrent; using needs can force the current job to wait until the needed job is completed
    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 one 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 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'
              });
1
2
3
4
5
6
7
8
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 files to the main branch of the Repo, return to Actions and manually trigger it:

Actions → Pull Request Automation — Daily Checker → Run workflow → Branch: main → Run workflow.

After execution, you can click to check the execution status:

Due to the needs constraint, the Jobs process will first complete Calculate PR Status before concurrently executing Auto Close Old PRs and Send PR Summary Message to Slack.

Execution Results

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

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 work ideas. You can come up with your own workflows (please be sure to refer to trigger events), and then write scripts to execute them; also remember to check the Marketplace for existing steps that you can use directly.

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 view GitHub Actions usage and execution performance in Insights:

Building and Switching to Self-hosted Runner

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

GitHub Hosted Runner has a free quota of 2,000 minutes per month (starting), running such small automation tasks does not take much time, and since it runs on Linux machines, the cost is very low, you may not even reach the free limit; it is not necessary to switch to Self-hosted Runner, changing the Runner also requires ensuring the Runner environment is correct (for example, GitHub Hosted Runner comes with gh cli, self-hosted Runner needs to be installed properly), this article purely demonstrates the switch for educational purposes.

If it is used for executing CI/CD tasks, then it is necessary to use Self-hosted Runner.

Adding Self-hosted Runner

This article takes 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 in your settings page to get 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 have Group functionality.
  • 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 desired Runner label, you can add custom labels for convenience later As mentioned, 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 tasks, so it’s safest to customize one. Here, 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 configuration 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 will continue to receive tasks as long as it is not closed.

🚀🚀🚀 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: 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 files to the main branch of the Repo, reopen the PR to trigger and verify the Actions.

Returning to the Runner Terminal, you can see that a new task has come in, executing and showing 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, trigger the execution again:

Success! Now this task is completely executed on our own computer, without using GitHub Hosted Runner, and without incurring charges.

We can click into the Action Log to see which Runner and machine the task was executed 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 self-hosted, macOS, app three labels to match and take on tasks.

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

Runners currently do not support:

- OR selection of Runners

- Runner weight settings

- Automatic scaling of Runners

Registering Runner as a Service

You can refer to the official documentation “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 needing to keep the Terminal open) and automatically start after boot.

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

I have a pending issue to research regarding iOS, which is that after switching to background Service, I encountered errors during Archive (suspected to be related to keychain permissions). 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 method, 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>

Conclusion

Now you should have a certain level of understanding of GitHub Actions + Self-hosted Runner. In the next article, I will start with App (iOS) CI/CD as a case study, building the entire process step by step.

Next Article:

[In Progress, Stay Tuned] CI/CD Practical Guide (Part 3): Implementing CI and CD Workflows for App Projects Using GitHub Actions

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

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