Skip to content

guitarrapc/githubactions-lab

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

auto doc auto dump context actionlint (reviewdog) dotnet build dotnet lint

githubactions-lab

GitHub Actions research and test laboratory.

Table of Contents

Not yet support

View

  • Workflow overview status view
    • There are no view for workflow status overview. Jenkins provides view for Job status which allow user to understand current status in 1 step.
    • Workaround: None.
  • GitHub Actions workflow view grouping
    • Group GitHub Actions workflows.
    • Workaround: None.
  • Test Insight view

YAML syntax

Functionarity

  • Workflow level timeout-minutes
    • Currently timeout-minutes can set to jobs and steps, but workflow cannot change from default 360min.
    • Workaround: None. Please set timeout-minutes to every job.
  • Workflow concurrency control customization
    • Currently concurrency control can handle with key and cancel-in-progress option, it will terminate action when at least 1 pending job is exsits. However you cannot customize how many pending actions are allowed, do not cancel pending job.
    • Workaround: None.
  • SSH Debug
  • Dynamic Config
    • Like CircleCI provides.
    • Workaround: Reusable Workflow / Composite Actions with inputs parameter.

Functionality limitation

Relax the limit for GitHub Team Plan is my strong expectation.

  • Environment > Deployment protection rules is not allowed in GitHub team Plan. You cannot use Required reviewers (Approvabl) and Wait timer.

Difference from other CI

CI Migration

There are several documents for migration.

Also you may consider migrate from GitHub Actions.

Fork handling

GitHub Actions support handling fork PR.

  • ✔️: GitHub Actions support fork PR to be trigger workflow and accessing secret. However allowing public fork to be access secret is not recommended, and there are some practical way to allow accessing secret.
  • ⚠️: CircleCI support fork PR to be trigger workflow and accessing secret. However handling fork PR in YAML is limited by branch naming rule like /pull\/[0-9]+/. Also allowing public fork to be access secret is not recommended, and there are no easy way to handle it.
  • ✔️: Azure Pipeline supports fork PR to be trigger job and accessing secret. However allowing public fork to be access secret is not recommended.
  • ❌: Jenkins normally not recommended to use for Public CI, it means fork PR won't consider to be important for Jenkins.

Git Checkout

GitHub Actions support checkout by actions and supports variety of checkout options include sparse checkout.

  • ✔️: GitHub Actions actions/checkout support ssh or https protocol, submodule, shallow-clone, sparse checkout and lfs. actions/checkout is default shallow-clone (depth 1).
  • ⚠️: CircleCI checkout support ssh or https protocol. It missing submodule, shallow-clone, sparse-checkout and lfs support. checkout is default full clone.
  • ✔️: Azure Pipeline checkout support ssh or https protocol, submodule, shallow-clone and lfs. It missing sparse-checkout support. checkout is default shallow-clone (depth 1) for new pipeline created after the September 2022.
  • ✔️: Jenkins GitSCM support ssh or https protocol, submodule, shallow-clone, sparse checkout and lfs. GitSCM is default full clone.

Job and workflow

All CI has yaml definitions.

  • ✔️: GitHub Actions can define jobs inside workflow. Can trigger both Push and PR.
name: workflow name
on:
  push:
    branches: [main]

jobs:
  Job_Name:
    runs-on: ubuntu-latest
    steps:
      - run: echo foo
  • ✔️: CircleCI can define jobs and conbinate them in workflow. Can not trigger both Push and PR.
version: 2.1

jobs:
  Job_Name:
    docker:
      - image: circleci/<language>:<version TAG>
    steps:
      - run: echo foo
workflows:
  commit:
    jobs:
      - Job_Name
  • ✔️: Azure Pipeline can define jobs and conbinate them in stage. Can trigger both Push and PR.
trigger:
  - main

stages:
  - stage: StageName
    jobs:
      - job: Job_Name

jobs:
  - job: Job_Name
    pool:
      vmImage: "ubuntu-latest"
    steps:
      - bash: echo "foo"
  • ⚠️: Jenkins use Declaretive Pipeline. Trigger needs to be defined outside pipeline, means you need create job to trigger pipeline. Also Declaretive Pipeline is not yaml, it is groovy.
pipeline {
  agent any
  triggers {
    pollSCM('')
  }
  stages {
    stage('Stage_Name') {
      steps {
        sh 'echo foo'
      }
    }
  }
}

Hosted Runner sizing

Every CI offer you to configure runner sizing for SelfHosted Runner, but some CI has limitation for sizing Hosted Runner.

  • ✔️: GitHub Actions offers larger runners to run faster and static IP addresses.
  • ✔️: CircleCI offer resource class to run faster.
  • ❌: Azure Pipeline not offer hosted runner sizing. Hosted runner is limited to spec 2Core CPU, 7GB RAM and 14GB SSD Disk.
  • ❌: Jenkins is self hosted solution. Hosted runner sizing is not avaiable.

Job Approval

This functionality enables you to stop next job until manually approved.

  • ⚠️: GitHub Actions supports Approval on Environment. However Environment cannot use in GitHub Team pricing.
  • ✔️: CircleCI supports Approval.
  • ✔️: Azure Pipeline supports Approval.
  • ✔️: Jenkins supports Approval.

Meta values and JobId

GitHub Actions has Context concept, you can access job specific info via github. for example, github.run_id is A unique number for each run within a repository. Also you can access default environment variables like GITHUB_RUN_ID.

Path filter

GitHub Actions can use on.<event>.paths-ignore: and on.<event>.paths: by default.

paths - Workflow syntax for GitHub Actions - GitHub Help

  • ✔️: GitHub Actions CAN set path-filter.
  • ❌: CircleCI can not set path-filter.
  • ✔️: Azure Pipeline can set path-filter.
  • ❌: Jenkins can not set path-filter. User should prepare by theirself.

Redundant build cancellation

GitHub Actions not support exact functionality as CircleCI provide, but you can do via concurrency control. Another option is community actions like rokroskar/workflow-run-cleanup-action, fauguste/auto-cancellation-running-action and yellowmegaman/gh-build-canceller.

  • ✔️: GitHub Actions has concurrency control and it can cancel in progress build. Or your can use community Actions.
  • ✔️: CircleCI support cancel redundant build.
  • ❌: Azure Pipeline not support cancel redundant build.
  • ❌: Jenkins not support cancel redundant build, you need cancel it from parallel job.

Rerun failed workflow

  • ✔️: GitHub Actions support Re-run jobs. You can re-run for whole workflow, single job and failed job.
  • ✔️: CircleCI support Re-run jobs. You can re-run whole workflow or failed job again.
  • ⚠️: Azure Pipeline not support Re-run stage but you can not re-run failed job only.
  • ✔️: Jenkins Declarative Pipeline support Re-run jobs. You can re-run Job or Stage again. But you may find it is unstable.

Reusable job and workflow

Write script is better than directly write on the step, so that we can reuse same execution from other workflows or jobs.

  • ✔️: GitHub Actions can reuse yaml via Reusable workflow, Composite Actions and Organization workflow.
  • ✔️: CircleCI can reuse job, and also YAML anchor is useul.
  • ✔️: Azure Pipeline has template to refer stage, job and step from other yaml.
  • ⚠️: Jenkins pipeline could refer other pipeline. However a lot case you would prefer define job step in script and reuse it. Reusing pipeline easily make it complex with Jenkins.

Set Environment variables

Define Environment varialbes in each job step, then reuse it later step is common pattern.

  • ✔️: GitHub Actions use redirect to special Environment variable $GITHUB_ENV via echo "{environment_variable_name}={value}" >> $GITHUB_ENV (Linux) or "{environment_variable_name}={value}" >> $env:GITHUB_ENV (Windows) syntax.
  • ::set-env syntax has been deprecated for security reason.
  • ✔️: CircleCI use redirect to special Environment variable $BASH_ENV via echo "export GIT_SHA1=$CIRCLE_SHA1" >> $BASH_ENV syntax.
  • ✔️: Azure Pipeline use task.setvariable via echo "##vso[task.setvariable variable=NAME]VALUE" syntax.
  • ✔️: Jenkins use Env..

Set Output

Define output in each job step, then reuse it later step is less side-effect than environment variable. Also it can pass value between job via job output , and it can't achieve with environment variable pattern.

  • ✔️: GitHub Actions use redirect to special Environment variable $GITHUB_OUTPUT via echo "{name}={value}" >> "$GITHUB_OUTPUT" (Linux) or "{name}=value" >> $env:GITHUB_OUTPUT (Windows) syntax.
  • ⚠️: CircleCI has no equivalent but use Environment Variables.
  • ✔️: Azure Pipeline use task.setvariable via echo "##vso[task.setvariable variable=NAME;isoutput=true]VALUE" syntax.
  • ⚠️: Jenkins has no equivalent but use Environment Variables.

Info GitHub Actions ::set-output syntax has been deprecated for security reason.

Set PATH Environment variables

  • ✔️: GitHub Actions use redirect to special Environment variable $GITHUB_PATH via echo "{path}" >> "$GITHUB_PATH" or echo "{path}" | tee -a "$GITHUB_PATH" syntax.
  • ::add-path syntax has been deprecated for security reason.
  • ✔️: CircleCI use redirect to special Environment variable $BASH_ENV wiht name PATH via echo "export PATH=$GOPATH/bin:$PATH" >> $BASH_ENV syntax.
  • ✔️: Azure Pipeline use task.setvariable via echo '##vso[task.setvariable variable=path]$(PATH):/dir/to/whatever' syntax.
  • ✔️: Jenkins use Env..

Set Secrets for Job

GitHub ACtions offer Secrets for each repository and Organization. Secrets will be masked on the log, and also you can mask desired output in log.

  • ✔️: GitHub Actions use Secrets and Environment Secrets.
  • ✔️: CircleCI offer Environment Variables and Context.
  • ✔️: Azure Pipeline has Environment Variables and Paramter.
  • ✔️: Jenkins has Credential Provider.

GitHub Actions supports "Organization Secrets", "Repository Secrets" and "Environment Secrets".

  • You can set secrets for Organization and filter to selected repository with Organization > Settings > Secrets.
  • You can set secrets for each repository with Repository > Settings > Secrets.
  • You can set Environment secrets for each repository with Repository > Environment > Secrets.

If same secrets key is exists, winner is Environment Secrets > Repository Secrets > Organization Secrets.

If you want spread your secrets with personal account, you need set each repository secrets or use google/secrets-sync-action.

Skip CI and commit message

GitHub Actions support when HEAD commit contains key word like other ci.

  • ✔️: GitHub Actions can skip workflow via [skip ci], [ci skip], [no ci], [skip actions] or [actions skip]. If PR last commit message contains [skip ci], then merge commit also skip.
  • ✔️: CircleCI can skip job via [skip ci] or [ci skip]. If PR last commit message contains [skip ci], then merge commit also skip.
  • ✔️: Azure Pipeline can skip job via ***NO_CI***, [skip ci] or [ci skip], or others.
  • ❌: Jenkins not support skip ci on default, but there are plugins to support [skip ci] or any expression w/pipeline like SCM Skip | Jenkins plugin.

Store Build Artifacts

GitHub Actions use Build artifacts to share files between jobs in a workflow and also download artifacts from completed workflows.

  • ✔️: GitHub Actions can store build artifacts via actions/upload-artifact and actions/download-artifact. You can specify retention period for upload artifact.
  • ⚠️: CircleCI can store build artifacts with store_artifacts step, however you need call API to download stored artifacts. There are not retention period for upload artifact.
  • ✔️: Azure Pipeline store build artifacts with PublishPipelineArtifact task, and download via DownloadPipelineArtifact task. There are not retention period for upload artifact.
  • ⚠️: Jenkins can store build artifacts with archiveArtifacts step, however you need call API to download stored artifacts. There are not retention period for upload artifact.

Basic - Fundamentables

Dump context metadata

Use Context to retrive job id, name and others system info. Make sure you can not refer gitHub context in script.

see: Context and expression syntax for GitHub Actions - GitHub Help

# .github/workflows/context-github.yaml

name: "context github"
on:
  issue_comment:
    types: [created]
  push:
    branches: ["main"]
    tags: ["*"]
  pull_request:
    branches: ["main"]
    types: [opened, synchronize, reopened, closed]
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:

jobs:
  context:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: job
        run: echo ${{ github.job }}
      - name: ref
        run: echo ${{ github.ref }}
      - name: sha
        run: echo ${{ github.sha }}
      - name: repository
        run: echo ${{ github.repository }}
      - name: repository_owner
        run: echo ${{ github.repository_owner }}
      - name: actor
        run: echo ${{ github.actor }}
      - name: run_id
        run: echo ${{ github.run_id }}
      - name: workflow
        run: echo ${{ github.workflow }}
      - name: event_name
        run: echo ${{ github.event_name }}
      - name: event.ref
        run: echo ${{ github.event.ref }}
      - name: action
        run: echo ${{ github.action }}

JSON output

Use toJson(<CONTEXT>) To show context values in json.

# .github/workflows/dump-context.yaml

name: dump context
on:
  issue_comment:
    types: [created]
  push:
    branches: ["main"]
    tags: ["*"]
  pull_request:
    branches: ["main"]
    types: [opened, synchronize, reopened, closed]
  pull_request_target:
    branches: ["main"]
    types: [opened, synchronize, reopened, closed]
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:

jobs:
  dump:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - name: Dump environment
        run: env
      - name: Dump GitHub context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(github) }}
      - name: Dump job context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(job) }}
      - name: Dump steps context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(steps) }}
      - name: Dump runner context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(runner) }}
      - name: Dump strategy context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(strategy) }}
      - name: Dump matrix context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(matrix) }}

Environment variables in script

set environment variables for next step explains how to set environment variables for next step. This syntax can be write in the script, let's see .github/scripts/setenv.sh.

# .github/scripts/setenv.sh

#!/bin/bash
set -eux

while [ $# -gt 0 ]; do
    case $1 in
        --ref) GITHUB_REF=$2; shift 2; ;;
        *) shift ;;
    esac
done

echo BRANCH_SCRIPT=${GITHUB_REF} | tee -a "$GITHUB_ENV"
echo branch=${GITHUB_REF} | tee -a "$GITHUB_OUTPUT"

Call this script from workflow.

# .github/workflows/env-with-script.yaml

name: env with script
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

env:
  BRANCH_NAME: ${{ startsWith(github.event_name, 'pull_request') && github.head_ref || github.ref_name }}

jobs:
  bash:
    strategy:
      matrix:
        runs-on: [ubuntu-24.04, windows-latest]
    runs-on: ${{ matrix.runs-on }}
    timeout-minutes: 3
    defaults:
      run:
        shell: bash
    steps:
      - uses: actions/checkout@v4
      - name: Add ENV and OUTPUT by shell
        id: shell
        run: |
          echo "BRANCH=${{ env.BRANCH_NAME }}" | tee -a "$GITHUB_ENV"
          echo "branch=${{ env.BRANCH_NAME }}" | tee -a "$GITHUB_OUTPUT"
      - name: Show ENV and OUTPUT
        run: |
          echo ${{ env.BRANCH }}
          echo ${{ steps.shell.outputs.branch }}
      - name: Add ENV and OUTPUT by Script
        id: script
        run: bash ./.github/scripts/setenv.sh --ref "${{ env.BRANCH_NAME }}"
      - name: Show Script  ENV and OUTPUT
        run: |
          echo ${{ env.BRANCH_SCRIPT }}
          echo ${{ steps.script.outputs.branch }}
      - name: Add PATH
        run: echo "$HOME/foo/bar" | tee -a "$GITHUB_PATH"
      - name: Show PATH
        run: echo "$PATH"

  powershell:
    strategy:
      matrix:
        runs-on: [ubuntu-24.04, windows-latest]
    runs-on: ${{ matrix.runs-on }}
    timeout-minutes: 3
    defaults:
      run:
        shell: pwsh
    steps:
      - uses: actions/checkout@v4
      - name: Add ENV and OUTPUT by shell
        id: shell
        run: |
          echo "BRANCH=${{ env.BRANCH_NAME }}" | Tee-Object -Append -FilePath "${env:GITHUB_ENV}"
          echo "branch=${{ env.BRANCH_NAME }}" | Tee-Object -Append -FilePath "${env:GITHUB_OUTPUT}"
      - name: Show ENV and OUTPUT
        run: |
          echo "${{ env.BRANCH }}"
          echo "${{ steps.shell.outputs.branch }}"
      - name: Add ENV and OUTPUT by Script
        id: script
        run: ./.github/scripts/setenv.ps1 -Ref "${{ env.BRANCH_NAME }}"
      - name: Show Script ENV and OUTPUT
        run: |
          echo "${{ env.BRANCH_SCRIPT }}"
          echo "${{ steps.script.outputs.branch }}"
      - name: Add PATH
        run: echo "$HOME/foo/bar" | Tee-Object -Append -FilePath "${env:GITHUB_PATH}"
      - name: Show PATH
        run: echo "${env:PATH}"

  cmd:
    strategy:
      matrix:
        runs-on: [windows-latest]
    runs-on: ${{ matrix.runs-on }}
    timeout-minutes: 3
    defaults:
      run:
        shell: cmd
    steps:
      - uses: actions/checkout@v4
      # cmd must not use quotes!!
      - name: Add ENV and OUTPUT by shell
        id: shell
        run: |
          echo BRANCH=${{ env.BRANCH_NAME }} >> %GITHUB_ENV%
          echo branch=${{ env.BRANCH_NAME }} >> %GITHUB_OUTPUT%
      - name: Show ENV and OUTPUT
        run: |
          echo ${{ env.BRANCH }}
          echo ${{ steps.shell.outputs.branch }}
      - name: Add ENV and OUTPUT by Script
        id: script
        run: .github/scripts/setenv.bat --ref "${{ env.BRANCH_NAME }}"
      - name: Show Script ENV and OUTPUT
        run: |
          echo ${{ env.BRANCH_SCRIPT }}
          echo ${{ steps.script.outputs.branch }}
      - name: Add PATH
        run: echo "%UserProfile%\foo\bar" >> %GITHUB_PATH%
      - name: Show PATH
        run: echo %PATH%

If and context reference

GitHub Actions allow if condition for step. You can refer any context inside if condition. You don't need add ${{}} to context reference. but I do recomment add it for easier read.

NOTE: matrix cannot refer with job.if.

Solved: What is the correct if condition syntax for checki/././. /- GitHub Community Forum

# .github/workflows/if-and-context.yaml

name: if and context reference
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  matrix_reference:
    strategy:
      matrix:
        sample: ["hoge", "fuga"]
    env:
      APP: hoge
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      # env context reference
      - run: echo "this is env if for hoge"
        if: ${{ env.APP == matrix.sample }}
      - run: echo "this is env if for fuga"
        if: ${{ env.APP == matrix.sample }}
      # github context reference
      - run: echo "this is github if event push"
        if: ${{ github.event_name == 'push' }}
      # matrix context reference
      - run: echo "this is matrix if for hoge"
        if: ${{ matrix.sample == 'hoge' }}
      - run: echo "this is matrix if for fuga"
        if: ${{ matrix.sample == 'fuga' }}

Job needs and dependency

You can handle Job dependency with needs.

Basic usage is needs: <job_name>. Let's check official example.

Requiring successful dependent jobs

Following example shows require successful dependent jobs.job2 will run after job1 is success, and job3 will run after job1 and job2 are success. It means job2 & job3 never run when job1 failed, job3 never run when job2 failed. In result, jobs are run seqientially in order of job1 -> job2 -> job3.

jobs:
  job1:
  job2:
    needs: job1
  job3:
    needs: [job1, job2]

See actual sample.

# .github/workflows/needs-require-success.yaml

name: needs require success

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:

jobs:
  A:
    runs-on: ubuntu-24.04
    steps:
      - run: echo "a"

  B:
    needs: [A]
    runs-on: ubuntu-24.04
    steps:
      - run: echo "b"

  # Run only if A and B success
  C:
    needs: [A, B]
    runs-on: ubuntu-24.04
    steps:
      - run: echo "c"

Not requiring successful dependent jobs

job3 uses the always() conditional expression. So thatjob3 will run regardless of job1 and job2 job result is success or failure. Because of needs section, jobs are run seqientially in order of job1 -> job2 -> job3.

jobs:
  job1:
  job2:
    needs: job1
  job3:
    if: ${{ always() }}
    needs: [job1, job2]

See actual sample.

# .github/workflows/needs-not-require-success.yaml

name: needs not require success

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:

jobs:
  A:
    runs-on: ubuntu-24.04
    steps:
      - run: echo "a"

  B:
    needs: [A]
    runs-on: ubuntu-24.04
    steps:
      - run: echo "b"

  # always run without A and B result
  C:
    needs: [A, B]
    if: ${{ always() }}
    runs-on: ubuntu-24.04
    steps:
      - run: echo "c"

Job skip handling

Job needs can be used for skip handling. However skipping dependent job cause trouble.

Following workflow expected to run D when C is invoked. But skipping A and B cause D skip.

# .github/workflows/needs-skip-no-handling.yaml

name: needs skip no handling

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:

jobs:
  A:
    if: ${{ false }}
    runs-on: ubuntu-24.04
    steps:
      - run: echo "a"

  B:
    if: ${{ false }}
    runs-on: ubuntu-24.04
    steps:
      - run: echo "b"

  C:
    runs-on: ubuntu-24.04
    steps:
      - run: echo "c"

  # D will always skip because A and B is skipped
  D:
    needs: [A, B, C]
    runs-on: ubuntu-24.04
    steps:
      - run: echo "d"

To handle D to run when C is invoked, you need to add if condition to D. Also handle when no conditional C invokation, A, B and C is success, then D must run.

# .github/workflows/needs-skip-handling.yaml

name: needs skip handling

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:
    inputs:
      only-c:
        description: 'Run only Job C'
        required: false
        default: false
        type: boolean

jobs:
  A:
    if: ${{ !inputs.only-c }}
    runs-on: ubuntu-24.04
    steps:
      - run: echo "a"

  B:
    if: ${{ !inputs.only-c }}
    runs-on: ubuntu-24.04
    steps:
      - run: echo "b"

  C:
    runs-on: ubuntu-24.04
    steps:
      - run: echo "c"

  # D will run when "C is success" or "all the jobs are success".
  D:
    needs: [A, B, C]
    if: ${{ inputs.only-c && needs.C.result == 'success' || success() }}
    runs-on: ubuntu-24.04
    steps:
      - run: echo "d"

Permissions

GitHub Actions supports specify permissions for each job or workflow.

You can turn all permission off with permissions: {}.

Workflow permission can be done with root permissions:.

# .github/workflows/permissions-workflow.yaml

name: permissions
on:
  pull_request:
    branches: ["main"]
permissions:
  # actions: write
  # checks: write
  contents: read
  # deployments: write
  # discussions: write
  # id-token: write
  # issues: write
  # packages: write
  # pages: write
  # pull-requests: write
  # repository-projects: write
  # security-events: write
  # statuses: write
jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

job permission can be done with job.<job_name>.permissions.

# .github/workflows/permissions-job.yaml

name: permissions job
on:
  pull_request:
    branches: ["main"]
jobs:
  job:
    permissions:
      # actions: write
      # checks: write
      contents: read
      # deployments: write
      # discussions: write
      # id-token: write
      # issues: write
      # packages: write
      # pages: write
      # pull-requests: write
      # repository-projects: write
      # security-events: write
      # statuses: write
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4

The most important permission is id-tokens: write. It enables job to use OIDC like AWS, Azure and GCP.

Reusable actions written in yaml - composite

To reuse local job, create local composite action is easiest way to do, this is calls composite actions. Create yaml file inside local action path, then declare using: "composite" in local action.yaml.

  • step1. Place your yaml to .github/actions/YOUR_DIR/action.yaml
  • step2. Write your composite actions yaml.
# .github/actions/local-composite-actions/action.yaml

name: YOUR ACTION NAME
description: |
  Desctiption of your action
inputs:
  foo:
    description: thi is foo input
    default: FOO
    required: false
runs:
  using: "composite" # this is key point
  steps:
    - name: THIS IS STEP1
      shell: bash # this is key point
      run: echo ${{ inputs.foo }}
  • step3. Use actions from your workflow.
# .github/workflows/reuse-local-actions.yaml

name: reuse local action
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - name: use local action
        uses: ./.github/actions/local-composite-actions
        with:
          foo: BAR

Reusable actions written in node - node12

To reuse local job, create local node action is another way to do, this is calls node actions. Create yaml file inside local action path, then declare using: "node12" in local action.yaml. Next place your Node.js source files inside actions directory, you may require index.js for entrypoint.

TIPS: You may find it is useful when you are running on GHE and copy GitHub Actions to your local.

  • step1. Place your ation.yaml to .github/actions/YOUR_DIR/actions.yaml
  • step2. Write your node actions yaml.
# .github/actions/local-node-actions/action.yaml

name: "Hello World"
description: |
  Desctiption of your action
runs:
  using: "node20"
  main: "index.js"
  • step3. Write your source code to .github/actions/YOUR_DIR/*.js.
// .github/actions/local-node-actions/index.js

console.log("Hello, World!");
  • step4. Use actions from your workflow.
# .github/workflows/reuse-local-actions-node.yaml

name: reuse local action node
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - name: use local action
        uses: ./.github/actions/local-node-actions

Reusable workflow

GitHub Actions allow call workflow from workflow. You can call local workflow of the same repository (Private repository), and remote workflow of the Public repository.

detail: Reusing workflows - GitHub Docs

Caller Limitations

There are limitations on Reusable workflow caller.

  1. Private repo can call same repo's reusable workflow, but can not call other private repo's workflow.
  2. Caller cannot use ${{ env.FOO }} for with inputs.
    jobs:
      bad:
        runs-on: ubuntu-latest
        steps:
          uses: ./.github/workflows/dummy.yaml
          with:
            value: ${{ env.FOO }} # caller can not use `env.` in with block.
          secrets: inherit

Callee Limitations

  1. Callee workflow must place under .github/workflows/. Otherwise caller treated as calling public workflow.
    $ ls -l ./.github/workflows/
  2. Callee cannot refer Caller's Environment Variable.
    env:
      FOO: foo # Reusable workflow callee cannot refer this env.
    jobs:
      bad:
        runs-on: ubuntu-latest
        steps:
          uses: ./.github/workflows/dummy.yaml

Reusable workflow definition sample

Place Reusable workflow yaml file under .github/workflows/ then set on.workflow_call trigger, you are ready for reusable workflow. Any inputs, secrets and outputs should define onder on.workflow_call.

# .github/workflows/_reusable-workflow-called.yaml

name: _reusable workflow called
on:
  workflow_call:
    inputs:
      username:
        required: true
        description: username to show
        type: string
      is-valid:
        required: true
        description: username to show
        type: boolean
    outputs:
      firstword:
        description: "The first output string"
        value: ${{ jobs.reusable_workflow_job.outputs.output1 }}
      secondword:
        description: "The second output string"
        value: ${{ jobs.reusable_workflow_job.outputs.output2 }}
env:
  FOO: foo
jobs:
  reusable_workflow_job:
    runs-on: ubuntu-24.04
    outputs:
      output1: ${{ steps.step1.outputs.firstword }}
      output2: ${{ steps.step2.outputs.secondword }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || '' }} # checkout PR HEAD commit instead of merge commit
      - name: (Limitation) Callee can not refer caller environment variable.
        run: echo "caller environment. ${{ env.CALLER_VALUE }}"
      - name: called username
        run: echo "called username. ${{ inputs.username }}"
      - name: called is-valid
        run: echo "called is-valid. ${{ inputs.is-valid }}"
      - name: called secret
        run: echo "called secret. ${{ secrets.APPLES }}"
      - name: called env (global)
        run: echo "called global env. ${{ env.FOO }}"
      - name: set variable (GITHUB_ENV)
        run: echo "IS_VALID=${{ inputs.is-valid }}" >> "$GITHUB_ENV"
      - name: called env (GITHUB_ENV)
        run: echo "called env. ${{ env.IS_VALID }}"
      - name: output step1
        id: step1
        run: echo "firstword=hello" >> "$GITHUB_OUTPUT"
      - name: output step2
        id: step2
        run: echo "secondword=world" >> "$GITHUB_OUTPUT"

Call repository's reusable workflow

To call Reusable workflow, use uses: ./.github/workflows/xxxx.yaml.

When you want pass boolean type of input from workflow_dispatch to workflow_call, use fromJson(inputs.YOUR_BOOLEAN_PARAMETER). See Type converter with fromJson for the detail.

# .github/workflows/reusable-workflow-caller-internal.yaml

name: reusable workflow caller (internal)
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:

# (Limitation) Callee can not refer caller environment variable.
env:
  CALLER_VALUE: caller
jobs:
  call-workflow-passing-data:
    uses: ./.github/workflows/_reusable-workflow-called.yaml
    with:
      username: "foo"
      is-valid: true
    secrets: inherit

  job2:
    runs-on: ubuntu-24.04
    needs: call-workflow-passing-data
    steps:
      - run: echo ${{ needs.call-workflow-passing-data.outputs.firstword }} ${{ needs.call-workflow-passing-data.outputs.secondword }}

Call public repository's reusable workflow

Yo call public repository's reusable workflow, use uses: GITHUB_OWNER/REPOSITORY/.github/workflows/xxxx.yaml@<ref>.

Warning: To call private repository's reusable workflow, you must use absolute path of self repository.

# .github/workflows/reusable-workflow-public-caller.yaml

name: reusable workflow caller (public)
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:

jobs:
  call-workflow-passing-data:
    uses: guitarrapc/githubactions-lab/.github/workflows/_reusable-workflow-called.yaml@main
    with:
      username: foo
      is-valid: true
    secrets: inherit

  job2:
    runs-on: ubuntu-24.04
    needs: call-workflow-passing-data
    steps:
      - run: echo ${{ needs.call-workflow-passing-data.outputs.firstword }} ${{ needs.call-workflow-passing-data.outputs.secondword }}

Call reusable workflow with matrix

Reusable Workflow caller cannot use matrix, but callee can use matrix. (see limitation.)

# .github/workflows/reusable-workflow-caller-matrix.yaml

name: reusable workflow caller (matrix)
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch:

jobs:
  call-matrix-workflow:
    strategy:
      matrix:
        username: [foo, bar]
    uses: ./.github/workflows/_reusable-workflow-called.yaml
    with:
      username: ${{ matrix.username }}
      is-valid: true
    secrets: inherit

Nest reusable workflow

Reusalbe workflow can call other reusable workflow, it means nested call is supported.

# .github/workflows/_reusable-workflow-nest.yaml

name: _reusable workflow nest
on:
  workflow_call:
    inputs:
      username:
        required: true
        description: username to show
        type: string
      is-valid:
        required: true
        description: username to show
        type: boolean

# nested call is supported
jobs:
  call-workflow-passing-data:
    uses: ./.github/workflows/_reusable-workflow-called.yaml
    with:
      username: ${{ inputs.username }}
      is-valid: ${{ inputs.is-valid }}
    secrets: inherit

Run when previous job is success

to accomplish sequential job run inside workflow, use needs: for which you want the job to depends on.

this enforce job to be run when only previous job is success.

# .github/workflows/sequential-run.yaml

name: sequential jobs
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  build:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo "$COMMIT_MESSAGES"
        env:
          COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }}
  publish:
    needs: [build]
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo run when only build success

Run when previous step status is specific

job-status-check-functions /- Context and expression syntax for GitHub Actions /- GitHub Help

use if: you want set step to be run on particular status.

# .github/workflows/status-step.yaml

name: status step
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo "$COMMIT_MESSAGES"
        env:
          COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }}
      - run: echo "success() run when none of previous steps  have failed or been canceled"
        if: ${{ success() }}
      - run: echo "always() run even cancelled. it runs only when critical failure prevents the task."
        if: ${{ always() }}
      - run: echo "cancelled() run when Workflow cancelled."
        if: ${{ cancelled() }}
      - run: echo "failure() run when any previous step of a job fails."
        if: ${{ failure() }}

Run write Multiline code

There are many place to support multiline.

run

Use run: | to write run statement in multiline.

# .github/workflows/multiline-run.yaml

name: multiline run
on:
  workflow_dispatch:
  push:
    branches: ["main"]
jobs:
  push:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - run: |
          echo "foo"
          echo "bar"

if

Use if: >- to write if statement in multiline.

# .github/workflows/multiline-if.yaml

name: multiline if
on:
  workflow_dispatch:
  push:
    branches: ["main"]
jobs:
  push:
    if: >-
      github.event_name == 'push' || github.event.forced == false
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - run: echo "push"
  workflow_dispatch:
    if: >-
      github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - run: echo "workflow_dispatch"

Strategy matrix and secret dereference

matrix cannot reference secret context, so pass secret key in matrix then dereference secret with secrets[matrix.SECRET_KEY].

let's set secrets in settings.

image

# .github/workflows/matrix-secret.yaml

name: matrix secret
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
env:
  fruit: APPLES
jobs:
  dereference:
    strategy:
      matrix:
        org: [apples, bananas, carrots] #Array of org mnemonics to use below
        include:
          # includes a new variable for each org (this is effectively a switch statement)
          - org: apples
            secret: APPLES
          - org: bananas
            secret: BANANAS
          - org: carrots
            secret: CARROTS
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo "org:${{ matrix.org }} secret:${{ secrets[matrix.secret] }}"
      - run: echo "org:${{ matrix.org }} secret:${{ secrets[env.secret] }}"
        env:
          secret: ${{ matrix.secret }}
      - run: echo "env:${{ env.fruit }} secret:${{ secrets[env.fruit] }}"

Strategy matrix and environment variables

you can refer matrix in job's env: section before steps. However you cannot use expression, you must evaluate in step.

# .github/workflows/matrix-envvar.yaml

name: matrix envvar
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  echo:
    strategy:
      matrix:
        org: [apples, bananas, carrots]
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    env:
      ORG: ${{ matrix.org }}
      # you can not use expression. do it on step.
      # output on step is -> ci-`date '+%Y%m%d-%H%M%S'`+${GITHUB_SHA:0:6}
      # GIT_TAG: "ci-`date '+%Y%m%d-%H%M%S'`+${GITHUB_SHA:0:6}"
    steps:
      - run: echo "${ORG}"
      - run: echo "${NEW_ORG}"
        env:
          NEW_ORG: new-${{ env.ORG }}

Timeout settings

You can set timeout for both job and steps.

default timeout is 360min. (6hours)

It is better set much more shorten timeout like 15min or 30min to prevent spending a lot build time.

# .github/workflows/timeout.yaml

name: timeout
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  my-job:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - run: echo "done before timeout"
        timeout-minutes: 1 # each step

Workflow Concurrency Control

GitHub Actions built in concurrency control prevent you to run CI at same time. This help you achieve serial build pipeline control.

You can use build context like github.head_ref or others. This means you can control with commit, branch, workflow and any.

# .github/workflows/concurrency-control.yaml

name: "concurrency control"

# only ${{ github }} context is available
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}

on:
  workflow_dispatch:

jobs:
  long_job:
    runs-on: ubuntu-24.04
    steps:
      - run: sleep 60s

Specify cancel-in-progress: true will cancel parallel build.

# .github/workflows/concurrency-control-cancel-in-progress.yaml

name: "concurrency control cancel in progress"

# only ${{ github }} context is available
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

on:
  workflow_dispatch:

jobs:
  long_job:
    runs-on: ubuntu-24.04
    steps:
      - run: sleep 60s

Workflow dispatch and passing input

GitHub Actions offer workflow_dispatch event to execute workflow manually from Web UI. Also you can use action inputs to specify value trigger on manual trigger.

# .github/workflows/manual-trigger.yaml

name: manual trigger
on:
  workflow_dispatch:
    inputs:
      branch:
        description: "branch name to clone"
        required: true
        default: "main"
      logLevel:
        description: "Log level"
        required: true
        default: "warning"
      tags:
        description: "Test scenario tags"
        required: false
jobs:
  printInputs:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    env:
      BRANCH: ${{ inputs.branch }}
      LOGLEVEL: ${{ inputs.logLevel }}
      TAGS: ${{ inputs.tags }}
    steps:
      - run: echo ${{ env.BRANCH }} ${{ env.LOGLEVEL }} ${{ env.TAGS }}
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.branch }}
      - name: dump github context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(github) }}
      - name: dump inputs context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(github.event.inputs) }}
      - name: Show Input value
        run: |
          echo "Log level: ${{ inputs.logLevel }}"
          echo "Tags: ${{ inputs.tags }}"
      - name: INPUT_ is not generated automatcally
        run: |
          echo "${INPUT_TEST_VAR}"
          echo "${TEST_VAR}"
      - name: Add PATH
        run: echo "/path/to/dir" | tee -a "$GITHUB_PATH"
      - name: Set inputs to Environment Variables
        run: |
          echo "INPUT_LOGLEVEL=${{ inputs.logLevel }}" | tee -a "$GITHUB_ENV"
          echo "INPUT_TAGS=${{ inputs.tags }}" | tee -a "$GITHUB_ENV"
      - name: Show Input value
        run: |
          echo "Log level: ${{ env.INPUT_LOGLEVEL }}"
          echo "Tags: ${{ env.INPUT_TAGS }}"
      - name: Show Environment Variables
        run: env

Even if you specify action inputs, input value will not store as ENV var INPUT_{INPUTS_ID} as usual.

Workflow dispatch with mixed input type

Workflow dispatch supported input type.

  • boolean: true or false and Web UI offers checkbox.
  • choice: enum options and Web UI offers selection box.
  • environment: enum GitHub Environments and Web UI offers selection box.
# .github/workflows/workflow-dispatch-mixed-inputs.yaml

name: workflow dispatch mixed inputs
on:
  workflow_dispatch:
    inputs:
      name:
        type: choice
        description: "name: Who to greet"
        required: true
        options:
          - monalisa
          - cschleiden
      message:
        description: "mnessage: add message"
        required: true
      use-emoji:
        type: boolean
        description: "use-emoji: Include 🎉🤣 emojis"
        required: true
      environment:
        type: environment
        description: "environment: Select environment"
        required: true
jobs:
  greet:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: Send greeting (github.event.inputs)
        run: |
          echo "message: ${{ github.event.inputs.message }}"
          echo "name: ${{ github.event.inputs.name }}"
          echo "use-emoji (string): ${{ github.event.inputs.use-emoji == 'true' }}"
          echo "use-emoji (bool): ${{ github.event.inputs.use-emoji == true }}"
      - name: Send greeting (inputs)
        run: |
          echo "message: ${{ inputs.message }}"
          echo "name: ${{ inputs.name }}"
          echo "use-emoji (string): ${{ inputs.use-emoji == 'true' }}"
          echo "use-emoji (bool): ${{ inputs.use-emoji == true }}"
      - name: Emoji
        run: echo "🥳 😊"

Workflow Redundant Control

Note Consider using Workflow Concurrency control instead of redundant control.

Creating PR emmits two events, push and pull_request/synchronize. This means duplicate build began and wastes build time. Redundant build may trouble when you are runnning Private Repository, bacause there are build time limits. In other words, you don't need mind build comsume time when repo is Public.

Avoid push on pull_request trigger on same repo

In this example push will trigger only when main, default branch. This means push will not run when pull_request synchronize event was emmited. Simple enough for almost usage.

# .github/workflows/push-and-pr-avoid-redundant.yaml

name: push and pull_request avoid redundant
on:
  # prevent push run on pull_request
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  my-job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo push and pull_request trigger

redundant build cancel

Cancel duplicate workflow and mark CI failure.

# .github/workflows/cancel-redundantbuild.yaml

name: cancel redundant build
# when pull_request, both push and pull_request (synchronize) will trigger.
# this action sample will prevent duplicate run, but run only 1 of them.
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  cancel:
    runs-on: ubuntu-24.04
    steps:
      # no check for main and tag
      - uses: rokroskar/[email protected]
        if: ${{ !startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/heads/main' }}
        env:
          GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

Basic - Commit, Branch and Tag handling

Create release

Better using manual gh.

[TBD]

Detect file changed

You can detect which file was changed with push or pull_request by GitHub actions. This is useful when you want to use path-filter, but require further file handling. Following 3 actions are available and can use same way.

Recommented

  • tj-actions/changed-files is still actively developed. Usage is simple and output is static.
# .github/workflows/file-change-detect-tj.yaml

name: file change detect tj
on:
  workflow_dispatch:
  pull_request:
    branches: ["main"]
  push:
    branches: ["main"]

jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2 # push required 2 or 0 to detect last commit change
      # see: https://github.com/tj-actions/changed-files
      - id: changed-files
        uses: tj-actions/changed-files@v45 # this action force fetch base branch and compare.
        with:
          separator: "," # default ' '
      - name: Changed file list
        run: echo "${{ steps.changed-files.outputs.all_modified_files }}"
      - name: Is changed files include .github/workflows?
        run: echo "${{ contains(steps.changed-files.outputs.all_modified_files, '.github/workflows')}}"
      - name: Is changed files include .github/dummy?
        run: echo "${{ contains(steps.changed-files.outputs.all_modified_files, '.github/dummy')}}"
      # space separated
      - id: changed-files2
        uses: tj-actions/changed-files@v45
        if: ${{ github.event.pull_request.changed_files < 100 }} # when changed files less than 100
      - name: List all changed files
        env:
          CHANGED_FILES: ${{ steps.changed-files2.outputs.all_changed_files }}
        run: |
          for file in ${CHANGED_FILES}; do
            echo "$file was changed"
          done
      # json separated
      - id: changed-files3
        uses: tj-actions/changed-files@v45 # this action force fetch base branch and compare.
        with:
          json: "true"
      - name: Changed file list
        run: echo "${{ steps.changed-files3.outputs.all_modified_files }}"

Not recommended

dorny/paths-filter is still actively developed. However it's output is quite dynamic and hard to handle static lint like actionlint.

# .github/workflows/file-change-detect-dorny.yaml

name: file change detect dorny
on:
  pull_request:
    branches: ["main"]
  push:
    branches: ["main"]

jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      # see: https://github.com/dorny/paths-filter/blob/master/README.md
      - id: changed-files
        uses: dorny/paths-filter@v3
        with:
          base: ${{ github.event_name == 'push' && github.ref || '' }}
          list-files: csv # default 'none'. Disables listing of matching files.
          filters: |
            foo:
              - '**'
      - name: Is any change happen on some filters?
        run: echo "${{ steps.changed-files.outputs.changes }}"
      - name: Is change happen on foo filter?
        run: echo "${{ steps.changed-files.outputs.foo }}"
      - name: Changed file list for foo filter
        run: echo "${{ steps.changed-files.outputs.foo_files }}"
      - name: Is foo filter changed files include .github/workflows?
        run: echo "${{ contains(steps.changed-files.outputs.foo_files, '.github/workflows')}}"
      - name: Is foo filter changed files include .github/dummy?
        run: echo "${{ contains(steps.changed-files.outputs.foo_files, '.github/dummy')}}"
      # space separated
      - id: changed-files2
        uses: dorny/paths-filter@v3
        if: ${{ github.event.pull_request.changed_files < 100 }} # when changed files less than 100
        with:
          base: ${{ github.event_name == 'push' && github.ref || '' }}
          list-files: shell
          filters: |
            foo:
              - '**'
      - name: List all changed files
        env:
          CHANGED_FILES: ${{ steps.changed-files2.outputs.foo_files }}
        run: |
          for file in ${CHANGED_FILES}; do
            echo "$file was changed"
          done
      # json separated
      - id: changed-files3
        uses: dorny/paths-filter@v3
        with:
          base: ${{ github.event_name == 'push' && github.ref || '' }}
          list-files: json
          filters: |
            foo:
              - '**'
      - name: Changed file list for foo filter
        run: echo "${{ steps.changed-files3.outputs.foo_files }}"

trilom/file-changes-action stopped development, so I will quit using it.

# .github/workflows/file-change-detect-dorny.yaml

name: file change detect dorny
on:
  pull_request:
    branches: ["main"]
  push:
    branches: ["main"]

jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      # see: https://github.com/dorny/paths-filter/blob/master/README.md
      - id: changed-files
        uses: dorny/paths-filter@v3
        with:
          base: ${{ github.event_name == 'push' && github.ref || '' }}
          list-files: csv # default 'none'. Disables listing of matching files.
          filters: |
            foo:
              - '**'
      - name: Is any change happen on some filters?
        run: echo "${{ steps.changed-files.outputs.changes }}"
      - name: Is change happen on foo filter?
        run: echo "${{ steps.changed-files.outputs.foo }}"
      - name: Changed file list for foo filter
        run: echo "${{ steps.changed-files.outputs.foo_files }}"
      - name: Is foo filter changed files include .github/workflows?
        run: echo "${{ contains(steps.changed-files.outputs.foo_files, '.github/workflows')}}"
      - name: Is foo filter changed files include .github/dummy?
        run: echo "${{ contains(steps.changed-files.outputs.foo_files, '.github/dummy')}}"
      # space separated
      - id: changed-files2
        uses: dorny/paths-filter@v3
        if: ${{ github.event.pull_request.changed_files < 100 }} # when changed files less than 100
        with:
          base: ${{ github.event_name == 'push' && github.ref || '' }}
          list-files: shell
          filters: |
            foo:
              - '**'
      - name: List all changed files
        env:
          CHANGED_FILES: ${{ steps.changed-files2.outputs.foo_files }}
        run: |
          for file in ${CHANGED_FILES}; do
            echo "$file was changed"
          done
      # json separated
      - id: changed-files3
        uses: dorny/paths-filter@v3
        with:
          base: ${{ github.event_name == 'push' && github.ref || '' }}
          list-files: json
          filters: |
            foo:
              - '**'
      - name: Changed file list for foo filter
        run: echo "${{ steps.changed-files3.outputs.foo_files }}"

Schedule job on non-default branch

Schedule job will offer Last commit on default branch.

ref: Events that trigger workflows /- GitHub Help

schedule workflow should merge to default branch to apply workflow change.

Pass branch info when you want run checkout on non-default branch. Don't forget pretend refs/heads/ to your branch.

  • good: refs/heads/some-branch
  • bad: some-branch
# .github/workflows/schedule-job.yaml

name: schedule job
on:
  schedule:
    - cron: "0 0 * * *"
jobs:
  job:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: Dump GitHub context
        run: echo "$CONTEXT"
        env:
          CONTEXT: ${{ toJson(github) }}
      - uses: actions/checkout@v4
        with:
          ref: refs/heads/some-branch

You can create release and upload assets through GitHub Actions. Multiple assets upload is supported by running running actions/upload-release-asset for each asset.

# .github/workflows/create-release.yaml

name: create release
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}

on:
  push:
    tags:
      - "[0-9]+.[0-9]+.[0-9]+*"
  pull_request:
    branches: ["main"]
  workflow_dispatch:
    inputs:
      tag:
        description: "tag: git tag you want create. (1.0.0)"
        required: true
      delete-release:
        description: "delete-release: delete release after creation. (true/false)"
        required: false
        default: false
        type: boolean

env:
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  GH_REPO: ${{ github.repository }}

jobs:
  create-release:
    if: ${{ github.actor != 'dependabot[bot]' }}
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - name: Setup tag
        id: tag
        run: echo "value=${{ inputs.tag || (github.event_name == 'pull_request' && '0.1.0-test' || github.ref_name) }}" | tee -a "$GITHUB_OUTPUT"
      # Create Tag
      - uses: actions/checkout@v4
      - name: Create Tag and push if not exists
        run: |
          if ! git ls-remote --tags | grep ${{ steps.tag.outputs.value }}; then
            git tag ${{ steps.tag.outputs.value }}
            git push origin ${{ steps.tag.outputs.value }}
            git ls-remote --tags
          fi
      # set release tag(*.*.*) to version string
      - run: echo "foo" > "foo.${{ steps.tag.outputs.value }}.txt"
      - run: echo "hoge" > "hoge.${{ steps.tag.outputs.value }}.txt"
      - run: echo "fuga" > "fuga.${{ steps.tag.outputs.value }}.txt"
      - run: ls -l
      # Create Releases
      - name: Create Release
        run: gh release create ${{ steps.tag.outputs.value }} --draft --verify-tag --title "Ver.${{ steps.tag.outputs.value }}" --generate-notes
      - name: Upload file to release
        run: gh release upload ${{ steps.tag.outputs.value }} hoge.${{ steps.tag.outputs.value }}.txt fuga.${{ steps.tag.outputs.value }}.txt
      - name: Upload additional file to release
        run: gh release upload ${{ steps.tag.outputs.value }} foo.${{ steps.tag.outputs.value }}.txt
      # Clean up
      - name: Clean up (Wait 60s and delete Release)
        run: |
          if gh release list | grep Draft | grep ${{ steps.tag.outputs.value }}; then
            sleep 60
            gh release delete ${{ steps.tag.outputs.value }} --yes --cleanup-tag
          fi
        if: ${{ failure() || inputs.delete-release || (github.event_name == 'pull_request' || github.event_name == 'push') }}

Trigger branch push only but skip on tag push

If you want run job only when push to branch, and not for tag push.

# .github/workflows/branch-push-only.yaml

name: branch push only
on:
  push:
    branches: ["main"]
    tags:
      - "!*" # not a tag push
jobs:
  aws:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo not run on tag

Trigger commit message

# .github/workflows/trigger-ci.yaml

name: trigger ci commit
on:
  push:
    branches: ["main"]
jobs:
  job:
    if: ${{ contains(toJSON(github.event.commits.*.message), '[build]') }}
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo "$COMMIT_MESSAGES"
        env:
          COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }}

Trigger tag push only but skip on branch push

If you want run job only when push to tag, and not for branch push.

# .github/workflows/tag-push-only.yaml

name: tag push only
on:
  push:
    tags:
      - "**" # only tag
jobs:
  job:
    runs-on: ubuntu-24.04
    steps:
      - run: echo not run on branch push

Trigger for specific tag pattern

You can use pattern on on.push.tags, but you can't on step.if. This pattern will match following.

  • 0.0.1
  • 1.0.0+preview
  • 0.0.3-20200421-preview+abcd123408534

not for below.

  • v0.0.1
  • release
# .github/workflows/tag-push-only-pattern.yaml

name: tag push only pattern
on:
  push:
    tags:
      - "[0-9]+.[0-9]+.[0-9]+*" # only tag with pattern match
jobs:
  job:
    runs-on: ubuntu-24.04
    steps:
      - run: echo not run on branch push

Basic - Issue and Pull Request handling

Detect labels on pull request

pull_request event contains tags and you can use it to filter step execution. ${{ contains(github.event.pull_request.labels.*.name, 'hoge') }} will return true if tag contains hoge.

# .github/workflows/pr-label-get.yaml

name: pr label get
on:
  pull_request:
    types:
      - labeled
      - opened
      - reopened
      - synchronize
jobs:
  changes:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    env:
      IS_HOGE: "false"
    steps:
      - run: echo "${{ toJson(github.event.pull_request.labels.*.name) }}"
      - run: echo "IS_HOGE=${{ contains(github.event.pull_request.labels.*.name, 'hoge') }}" >> "$GITHUB_ENV"
      - run: echo "${IS_HOGE}"
      - run: echo "run!"
        if: ${{ env.IS_HOGE == 'true' }}

Skip ci on pull request title

original pull_request event will invoke when activity type is opened, synchronize, or reopened.

Events that trigger workflows /- GitHub Help

# .github/workflows/skip-ci-pr-title.yaml

name: skip ci pr title
on: ["pull_request"]
jobs:
  skip:
    if: ${{ !(contains(github.event.pull_request.title, '[skip ci]') || contains(github.event.pull_request.title, '[ci skip]')) }}
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo "$GITHUB_CONTEXT"
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
      - run: echo "$TITLE"
        env:
          TITLE: ${{ toJson(github.event.pull_request.title) }}
  build:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    needs: skip
    steps:
      - run: echo run when not skipped

Skip pr from fork repo

default pull_request event trigger from even fork repository, however fork pr could not read secrets and may fail PR checks. To control job to be skip from fork but run on self pr or push, use if conditions.

# .github/workflows/skip-pr-from-fork.yaml

name: skip pr from fork
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
    types:
      - opened
      - synchronize
jobs:
  build:
    # push & my repo will trigger
    # pull_request on my repo will trigger
    if: ${{ (github.event_name == 'push' && github.repository_owner == 'guitarrapc') || startsWith(github.event.pull_request.head.label, 'guitarrapc:') }}
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - run: echo build

Skip job when Draft PR

You can skip job and steps if Pull Request is Draft. Unfortunately GitHub Webhook v3 event not provide draft pr type, but event.pull_request.draft shows true when PR is draft.

# .github/workflows/skip-draft-pr.yaml

name: skip draft pr
on:
  pull_request:
  push:
    branches: ["main"]
jobs:
  job:
    if: ${{ ! github.event.pull_request.draft }}
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4

You can control behaviour with PR label.

# .github/workflows/skip-draft-but-label-pr.yaml

name: skip draft pr but label
on:
  pull_request:
    types:
      - synchronize
      - opened
      - reopened
      - ready_for_review
jobs:
  build:
    # RUN WHEN
    # 1. PR has label 'draft_but_ci'
    # 2. Not draft, `push` and `non-draft pr`.
    if: ${{ (contains(github.event.pull_request.labels.*.name, 'draft_but_ci')) || !(github.event.pull_request.draft) }}
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4

Basic - BAD PATTERN

Env refer env

You cannot use ${{ env. }} in env: section. Following is invalid with error.

The workflow is not valid. .github/workflows/env-refer-env.yaml (Line: 12, Col: 16): Unrecognized named-value: 'env'. Located at position 1 within expression: env.global_env

name: you can not refer env in env

on: ["push"]

env:
  global_env: global

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      job_env: ${{ env.global_env }}
    steps:
      - run: echo "${{ env.global_env }}"
      - run: echo "${{ env.job_env }}"

Advanced

Advanced tips.

Automatic Actions version update via Dependabot

You can use Dependabot to update GitHub Actions version automatically. Dependabot create pull request to keep your actions up to date, and you can merge it manually or automatically.

To enable Dependabot for GitHub Actions update, add .github/dependabot.yml to your repository.

# .github/dependabot.yaml

# ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly" # Check for updates to GitHub Actions every week

Customize dependabot.yaml

There are some configuration options for dependabot.yaml file.

Accessing secrets on dependabot action

When a Dependabot event triggers a workflow, the only secrets available to the workflow are Dependabot secrets. GitHub Actions secrets are not available.

ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#accessing-secrets

Therefore I recommend not to use secret for Dependabot triggered workflows. If you need secrets, then put same named secret to Dependabot secret.

Build Artifacts

GitHub Actions actions/upload-artifact and actions/download-artifact offer artifact handling between jobs. You can upload and download artifact to/from GitHub Actions.

file

# .github/workflows/artifacts-file.yaml

name: artifacts (file)

on:
  workflow_dispatch:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  # single file
  upload-file:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: output
        run: |
          echo "hoge" > ./hoge.txt
      - uses: actions/upload-artifact@v4
        with:
          name: hoge.txt
          path: ./hoge.txt
          retention-days: 1

  download-file:
    needs: [upload-file]
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: hoge.txt
          path: .
      - name: ls
        run: ls -lR
      - name: cat hoge.txt
        run: cat hoge.txt

directory

# .github/workflows/artifacts-directory.yaml

name: artifacts (directory)

on:
  workflow_dispatch:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  # directory
  upload-directory:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: output
        run: |
          mkdir -p ./directory/bin
          echo "hoge" > ./directory/hoge.txt
          echo "fuga" > ./directory/fuga.txt
          echo "foo" > ./directory/bin/foo.txt
          echo "bar" > ./directory/bin/bar.txt
      - uses: actions/upload-artifact@v4
        with:
          name: directory
          path: ./directory/
          retention-days: 1
  download-directory:
    needs: [upload-directory]
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: directory
          path: ./directory
      - name: ls
        run: ls -lR
      - name: cat hoge.txt
        run: cat directory/hoge.txt

.tar.gz

# .github/workflows/artifacts-targz.yaml

name: artifacts (tar.gz)

on:
  workflow_dispatch:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  # tar.gz
  upload-targz:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: output
        run: |
          mkdir -p ./output/bin
          echo "hoge" > ./output/hoge.txt
          echo "fuga" > ./output/fuga.txt
          echo "foo" > ./output/bin/foo.txt
          echo "bar" > ./output/bin/bar.txt
          tar -zcvf output.tar.gz ./output/
      - uses: actions/upload-artifact@v4
        with:
          name: output.tar.gz
          path: ./output.tar.gz
          retention-days: 1

  download-targz:
    needs: [upload-targz]
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      # specify path: . to download tar.gz to current directory
      - uses: actions/download-artifact@v4
        with:
          name: output.tar.gz
          path: .
      - name: ls
        run: ls -lR
      - name: expand
        run: tar -zxvf output.tar.gz
      - name: ls
        run: ls -lR
      - name: cat hoge.txt
        run: cat ./output/hoge.txt
      - name: cat foo.txt
        run: cat ./output/bin/foo.txt

Checkout faster with Git sparse-checkout

actions/checkout supports both shallow-clone and sparse checkout which is quite useful for monorepository. Typically, monorepository contains many folders and files, but you may want to checkout only specific folder or files.

  • shallow-clone offers faster checkout by limiting depth of clone to latest 1 or N commits.
  • sparse checkout offers faster checkout by limiting checkout files and folders.

Note: actions/checkout supports git sparse-checkout, since 2023/June.

Let's see what is difference between shallow-clone and sparse-checkout.

Shallow clone

Shallow clones use the --depth=<N> parameter in git clone to truncate the commit history. Typically, --depth=1 signifies that we only care about the most recent commits. This drastically reduces the amount of data that needs to be fetched, leading to faster clones and less storage of shallow history.

ref: https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/

Sparse checkout

Sparse checkout use the git sparse-checkout set <PATH> before git clone to truncate the checkout files and folders. This amazingly reduces the amount of data that needs to be fetched, leading to faster checkout and less storage of limited paths.

ref: https://github.blog/2020-01-17-bring-your-monorepo-down-to-size-with-sparse-checkout/

Sparce checkout has 2 modes, git sparse-checkout and git sparse-checkout --cone. You can specify cone or not with sparse-checkout-cone-mode option. So what the difference between cone and not cone? Normally sparse-checkout-cone-mode: true is faster than sparse-checkout-cone-mode: false. But cone mode has some limitation, you cannot exclude specific folder. So you need to choose which mode is better for you.

  • sparse checkout: src & sparse-checkout-cone-mode: true, checkout src folder and root files.
  • sparse checkout: src/* & sparse-checkout-cone-mode: false, checkout src folder only.
  • sparse checkout: !src & sparse-checkout-cone-mode: true, you can not use sparse-checkout-cone-mode: true with exclude folder.
  • sparse checkout: !src/* & sparse-checkout-cone-mode: false, you can exlude src folder from checkout, but you need specify which folder you want to checkout.

Sparse checkout

Checkout "src/*" and root files, but not checkout any not specified folders.

# .github/workflows/git-sparsecheckout.yaml

name: git sparsecheckout
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  sparse-checkout:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
        with:
          sparse-checkout: |
            src
      - name: list root folders
        run: ls -la
      - name: list src folders
        run: ls -laR ./src

Result is selected src folder and root files will checkout.

$ ls -la
total 104
drwxr-xr-x 4 runner docker  4096 Jun 14 10:23 .
drwxr-xr-x 3 runner docker  4096 Jun 14 10:23 ..
-rw-r--r-- 1 runner docker  3557 Jun 14 10:23 .editorconfig
drwxr-xr-x 8 runner docker  4096 Jun 14 10:23 .git
-rw-r--r-- 1 runner docker   103 Jun 14 10:23 .gitattributes
-rw-r--r-- 1 runner docker     5 Jun 14 10:23 .gitignore
-rw-r--r-- 1 runner docker  1083 Jun 14 10:23 LICENSE.md
-rw-r--r-- 1 runner docker 70249 Jun 14 10:23 README.md
drwxr-xr-x 8 runner docker  4096 Jun 14 10:23 src

$ ls -laR ./src
./src:
total 32
drwxr-xr-x 8 runner docker 4096 Jun 14 10:23 .
drwxr-xr-x 4 runner docker 4096 Jun 14 10:23 ..
drwxr-xr-x 5 runner docker 4096 Jun 14 10:23 dotnet
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 json
drwxr-xr-x 6 runner docker 4096 Jun 14 10:23 k8s
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 mermaid
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 shellscript
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 txt

.... others

Sparse checkout and specify which file to checkout

Checkout only "src/*" path. All other files and folders will not checkout.

# .github/workflows/git-sparsecheckout-nocorn.yaml

name: git sparsecheckout (no corn)
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  sparse-checkout:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
        with:
          sparse-checkout: |
            src/*
          sparse-checkout-cone-mode: false # required for ! entry to work
      - name: list root folders
        run: ls -la
      - name: list src folders
        run: ls -laR ./src

Result is selected src folder and root files will checkout.

$ ls -la
total 16
drwxr-xr-x 4 runner docker 4096 Jun 14 10:23 .
drwxr-xr-x 3 runner docker 4096 Jun 14 10:23 ..
drwxr-xr-x 8 runner docker 4096 Jun 14 10:23 .git
drwxr-xr-x 8 runner docker 4096 Jun 14 10:23 src

$ ls -laR ./src
./src:
total 32
drwxr-xr-x 8 runner docker 4096 Jun 14 10:23 .
drwxr-xr-x 4 runner docker 4096 Jun 14 10:23 ..
drwxr-xr-x 5 runner docker 4096 Jun 14 10:23 dotnet
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 json
drwxr-xr-x 6 runner docker 4096 Jun 14 10:23 k8s
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 mermaid
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 shellscript
drwxr-xr-x 2 runner docker 4096 Jun 14 10:23 txt

.... others

Sparse checkout exclude path

Checkout except "src/_" path. All other files and folders will checkout by /_.

# .github/workflows/git-sparsecheckout-exclude.yaml

name: git sparsecheckout nocorn
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
jobs:
  sparse-checkout:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
        with:
          sparse-checkout: |
            !src/*
            /*
          sparse-checkout-cone-mode: false # required for ! entry to work
      - name: list root folders
        run: ls -la
      - name: list .github folders
        run: ls -laR ./.github

Result is exclude src folder and all other files are checkout.

$ ls -la
total 108
drwxr-xr-x 5 runner docker  4096 Jun 14 10:23 .
drwxr-xr-x 3 runner docker  4096 Jun 14 10:23 ..
-rw-r--r-- 1 runner docker  3557 Jun 14 10:23 .editorconfig
drwxr-xr-x 8 runner docker  4096 Jun 14 10:23 .git
-rw-r--r-- 1 runner docker   103 Jun 14 10:23 .gitattributes
drwxr-xr-x 5 runner docker  4096 Jun 14 10:23 .github
-rw-r--r-- 1 runner docker     5 Jun 14 10:23 .gitignore
-rw-r--r-- 1 runner docker  1083 Jun 14 10:23 LICENSE.md
-rw-r--r-- 1 runner docker 70249 Jun 14 10:23 README.md
drwxr-xr-x 5 runner docker  4096 Jun 14 10:23 samples

$ ls -laR ./.github
./.github:
total 24
drwxr-xr-x  5 runner docker 4096 Jun 14 10:23 .
drwxr-xr-x  5 runner docker 4096 Jun 14 10:23 ..
drwxr-xr-x 12 runner docker 4096 Jun 14 10:23 actions
-rw-r--r--  1 runner docker  117 Jun 14 10:23 ban-words.txt
drwxr-xr-x  2 runner docker 4096 Jun 14 10:23 scripts
drwxr-xr-x  3 runner docker 4096 Jun 14 10:23 workflows

.... others

Dispatch other repo workflow

You can dispatch this repository to other repository via calling GitHub workflow_dispatch event API. You don't need use repository_dispatch event API anymore.

Target repository workflow

Here's target repo testtest workflow test.yaml.

name: test
on:
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

Dispatcher workflow

This repo will dispatch event with Workflow Dispatch Action actions.

# .github/workflows/dispatch-changes-actions.yaml

name: dispatch changes actions
on:
  workflow_dispatch:
jobs:
  dispatch:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    strategy:
      matrix:
        repo: [guitarrapc/testtest] #Array of target repos
        include:
          - repo: guitarrapc/testtest
            ref: main
            workflow: test
    steps:
      - name: dispatch ${{ matrix.repo }}
        uses: benc-uk/[email protected]
        with:
          repo: ${{ matrix.repo }}
          ref: ${{ matrix.ref }}
          workflow: ${{ matrix.workflow }}
          token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }}

Fork user workflow change prevention

One of GitHub's vulnerable point is Workflow. Editting Workflow shoulbe be requirement when using secrets and authenticate some service on workflow.

Easiest and simple way is use pull_request target and path filter, then detect PR is fork or not. There might be many ways to prevent file change. xalvarez/prevent-file-change-action can guard change in the step. Using tj-actions/changed-files, dorny/paths-filter, or others will be flexible way to detect change and do what you want.

# .github/workflows/prevent-file-change1.yaml

name: prevent file change 1
on:
  pull_request:
    branches: ["main"]
    paths:
      - .github/**/*.yaml

jobs:
  detect:
    if: ${{ github.event.pull_request.head.repo.fork }} # is Fork
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: "Prevent file change"
        run: exit 1
# .github/workflows/prevent-file-change2.yaml

name: prevent file change 2
on:
  pull_request:
    branches: ["main"]
    paths:
      - .github/**/*.yaml

permissions:
  pull-requests: read # only read required

jobs:
  detect:
    if: ${{ github.actor != 'dependabot[bot]' }}
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: Prevent file change for github YAML files.
        uses: xalvarez/prevent-file-change-action@v1
        with:
          githubToken: ${{ secrets.GITHUB_TOKEN }}
          pattern: ^\.github\/.*.y[a]?ml$ # -> .github/**/*.yaml
          trustedAuthors: ${{ github.repository_owner }} # , separated. allow repository owner to change
# .github/workflows/prevent-file-change3.yaml

name: prevent file change 3
on:
  pull_request:
    branches: ["main"]
    paths:
      - .github/**/*.yaml

jobs:
  detect:
    if: ${{ github.event.pull_request.head.repo.fork }} # is Fork
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2 # To retrieve the preceding commit.
      - name: Get changed files in the .github folder
        id: changed-files
        uses: tj-actions/changed-files@v45
        with:
          files: .github/**/*.{yml,yaml}
      - name: Run step if any file(s) in the .github folder change
        if: ${{ steps.changed-files.outputs.any_changed == 'true' }}
        run: |
          echo "One or more files has changed."
          echo "List all the files that have changed: ${{ steps.changed-files.outputs.all_changed_files }}"
          exit 1
# .github/workflows/prevent-file-change4.yaml

name: prevent file change 4
on:
  pull_request:
    branches: ["main"]
    paths:
      - .github/**/*.yaml

jobs:
  detect:
    if: ${{ github.event.pull_request.head.repo.fork }} # is Fork
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: Get changed files in the .github folder
        uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            src:
              - .github/**/*.yaml
      - name: Run step if any file(s) in the .github folder change
        if: ${{ steps.changes.outputs.src == 'true' }}
        run: |
          echo "One or more files has changed."
          echo "List all the files that have changed: ${{ steps.changes.outputs.changes }}"
          exit 1

Lint GitHub Actions workflow itself

You can lint GitHub Actions yaml via actionlint. If you don't need automated PR review, run actionlint is enough.

# .github/workflows/actionlint.yaml

name: actionlint
on:
  workflow_dispatch:
  pull_request:
    branches: ["main"]
    paths:
      - ".github/workflows/**"
  schedule:
    - cron: "0 0 * * *"

jobs:
  actionlint:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - name: Install actionlint
        run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
      - name: Run actionlint
        run: ./actionlint -color -oneline

If you need automated PR review, run actionlint with reviewdog.

# .github/workflows/actionlint-reviewdog.yaml

name: actionlint (reviewdog)
on:
  workflow_dispatch:
  pull_request:
    branches: ["main"]
    paths:
      - ".github/workflows/**"

jobs:
  actionlint:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - name: actionlint
        uses: reviewdog/action-actionlint@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          reporter: github-pr-review
          fail_on_error: true # workflow will fail when actionlint detect warning.

PR info from Merge Commit

You have two choice.

  1. Use Git cli. Retrieve 1st and 3rd line of merge commit.
  2. Use some action to retrieve PR info from merge commit.

Below use jwalton/gh-find-current-pr to retrieve PR info from merge commit.

# .github/workflows/pr-from-merge-commit.yaml

name: pr from merge commit
on:
  push:
    branches: ["main"]
jobs:
  get:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - uses: jwalton/gh-find-current-pr@v1
        id: pr
        with:
          state: closed
      - if: ${{ success() && steps.pr.outputs.number }}
        run: |
          echo "PR #${PR_NUMBER}"
          echo "PR Title: ${PR_TITLE}"
        env:
          PR_NUMBER: ${{ steps.pr.outputs.number }}
          PR_TITLE: ${{ steps.pr.outputs.title }}

Telemetry for GitHub Workflow execution

GitHub Actions runforesight/workflow-telemetry-action offers workflow telemetry. Telemetry indicate which step consume much Execution Time, CPU, Memory and Network I/O. Default settings post telemetry result to PR comment and JOB Summary.

To enable telemetry, set runforesight/workflow-telemetry-action@v1 on the first step of your job, then action collect telemetry for later steps.

# .github/workflows/actions-telemetry.yaml

name: actions telemetry
on:
  workflow_dispatch:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  dotnet:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: Collect actions workflow telemetry
        uses: runforesight/workflow-telemetry-action@v2
        with:
          theme: dark # or light. dark generate charts compatible with Github dark mode.
          comment_on_pr: false # post telemetry to PR comment. It won't override existing comment, therefore too noisy for PR.
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x
      - name: dotnet build
        run: dotnet build ./src/dotnet -c Debug
      - name: dotnet test
        run: dotnet test ./src/dotnet -c Debug --logger GitHubActions --logger "console;verbosity=normal"
      - name: dotnet publish
        run: dotnet publish ./src/dotnet/ -c Debug

Telemetry is posted to Job Summary.

image

Also if workflow ran with pull_request trigger, then you can enable PR comment by default or set comment_on_pr: true.

image

Cheat Sheet

GitHub Actions cheet sheet.

Actions naming

Follow to setup-foo style. Use Hyphen - instead of Underscore _.

  • ✔️: setup-foo
  • ❌: setup_foo

action folder naming also follow this rule.

  • ✔️: .github/actions/setup-foo
  • ❌: .github/actions/setup_foo

Get Branch

github.ref context will return branch name, however it is unsafe to directly reference in ref. It is recommended to use through env.

  • pull_request: ${{ github.event.pull_request.head.ref }}
  • push and others: ${{ github.ref }}
# .github/workflows/_reusable-dump-context.yaml#L20-L22

# PR should checkout HEAD ref instead of merge commit.                        -> github.head.ref
# PR close delete branch, so it should checkout BASE ref instead of HEAD ref. -> github.base_ref
# Tag ref is tag version, let's checkout default branch instead of ref.       -> github.event.repository.default_branch

Get Tag

Trigger push with tag, then you have 2 choice.

  1. echo "${{ github.ref_name }}"
  2. echo "${GITHUB_REF##*/}"
  • refs/heads/xxxxx -> xxxxx
  • refs/tags/v1.0.0 -> v1.0.0
# .github/workflows/tag-push-only-context.yaml

name: tag push context
on:
  push:
    tags:
      - "**" # only tag
jobs:
  ref:
    runs-on: ubuntu-24.04
    steps:
      - name: Use GITHUB_REF and GITHUB_OUTPUT
        run: echo "GIT_TAG=${GITHUB_REF##*/}" >> "$GITHUB_OUTPUT"
        id: CI_TAG
      - name: Use GITHUB_REF and GITHUB_ENV
        run: echo "GIT_TAG=${GITHUB_REF##*/}" >> "$GITHUB_ENV"
      - name: Show tag value by GITHUB_REF
        run: |
          echo "${{ steps.CI_TAG.outputs.GIT_TAG }}"
          echo "${{ env.GIT_TAG }}"
      - name: Show tag value by github.ref_name
        run: |
          echo "${{ github.ref_name }}"

Get Workflow Name

${{ github.workflow }}

Get Workflow Url

${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

GitHub Actions commit icon

Use following Git config to commit as GitHub Actions icon.

git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com

Type converter with fromJson

There are some cases you want convert string to other type. Consider you want use boolean input is-valid with workflow_dispatch, then pass it to workflow_call as boolean.

github.event.inputs context treat all value as string, so github.event.inputs.is-valid isn't boolean any more. fromJson expression is the trick to convert type from string to boolean.

${{ github.event.inputs.foobar == "true" }} # true. type is string
${{ fromJson(github.event.inputs.foobar) == true }} # true. string convert to boolean

Other way is use inputs.foobar context. inputs have type information and pass exactly as is to other workflow calls.

${{ inputs.foobar == 'true' }} # false. type is not string
${{ inputs.foobar == true }} # true. type is boolean

Detect PullRequest (PR) is Fork or not

There are several way to achieve it. Most simple and easy to understand is fork boolean.

  1. Check fork boolean.
# Fork
if: ${{ github.event.pull_request.head.repo.fork }}

# Not Fork
if: ${{ ! github.event.pull_request.head.repo.fork }}
  1. Check full_name is match to repo.
# Fork
if: ${{ github.event.pull_request.head.repo.full_name != 'org/repo' }}

# Not Fork
if: ${{ github.event.pull_request.head.repo.full_name == 'org/repo' }}
  1. Check label is match to owner. Org member commit label is match to owner.
# Fork
if: ${{ ! startsWith(github.event.pull_request.head.label, format('{0}:', github.repository_owner)) }}

# Not Fork
if: ${{ startsWith(github.event.pull_request.head.label, format('{0}:', github.repository_owner)) }}