GitHub Actions can enforce branch protection rules, but they don’t replace GitHub’s built-in branch protection. Instead, they enhance it by adding custom logic that GitHub’s native rules can’t express.

Let’s say you have a repository with a main branch that you want to protect. You’ve already set up GitHub’s built-in branch protection rules: require status checks to pass, require pull request reviews, etc. But you also want to add a custom check: ensure that any pull request targeting main also has a specific label applied, say ready-for-merge. GitHub’s native rules don’t have a "require label" option.

This is where GitHub Actions comes in. You can create a workflow that triggers on pull_request events. This workflow will inspect the pull request, check for the presence of the ready-for-merge label, and then, crucially, report a status back to GitHub. This status check will then be visible in the pull request UI and can be incorporated into your existing branch protection rules.

Here’s a workflow that accomplishes this. We’ll create a file named .github/workflows/branch-protection-checks.yml:

name: Custom Branch Protection Checks

on:
  pull_request:
    types: [opened, synchronize, labeled, unlabeled]

jobs:
  check_label:
    runs-on: ubuntu-latest
    steps:
      - name: Check for 'ready-for-merge' label
        uses: actions/github-script@v6
        id: check_label
        with:
          script: |
            const requiredLabel = 'ready-for-merge';
            const labels = github.event.pull_request.labels;
            const hasRequiredLabel = labels.some(label => label.name === requiredLabel);

            if (hasRequiredLabel) {
              console.log(`Pull request has the '${requiredLabel}' label.`);
              github.rest.repos.createCommitStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                sha: context.sha,
                state: 'success',
                description: `Label '${requiredLabel}' is present.`
              });
            } else {
              console.log(`Pull request is missing the '${requiredLabel}' label.`);
              github.rest.repos.createCommitStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                sha: context.sha,
                state: 'failure',
                description: `Missing required label: '${requiredLabel}'. Add it to proceed.`
              });
            }

Let’s break down what’s happening here.

  • on: pull_request: types: [opened, synchronize, labeled, unlabeled]: This tells the workflow to run whenever a pull request is opened, new commits are pushed to it, or when a label is added or removed. We need labeled and unlabeled so that the status updates correctly if the label is added or removed after the PR is opened.
  • jobs: check_label:: This defines a single job named check_label.
  • runs-on: ubuntu-latest: The job will run on a fresh Ubuntu virtual machine.
  • steps:: This lists the steps within the job.
  • uses: actions/github-script@v6: This is a powerful action that allows you to run arbitrary JavaScript code directly within your workflow, with access to the GitHub API and context.
  • id: check_label: Assigns an ID to this step so we can reference its outputs later if needed.
  • with: script: | ...: This is where the JavaScript code lives.
    • const requiredLabel = 'ready-for-merge';: Defines the label we’re looking for.
    • const labels = github.event.pull_request.labels;: Accesses the list of labels currently applied to the pull request from the event payload.
    • const hasRequiredLabel = labels.some(label => label.name === requiredLabel);: Checks if any of the labels match our requiredLabel. The .some() array method is efficient here.
    • if (hasRequiredLabel): If the label is present:
      • github.rest.repos.createCommitStatus({...}): This is the key API call. We’re using the GitHub REST API via the github object provided by actions/github-script.
      • owner: context.repo.owner, repo: context.repo.repo, sha: context.sha: These fields identify the repository and the specific commit SHA that the status check should be associated with. context.sha is the SHA of the latest commit on the PR branch.
      • state: 'success': This reports a green checkmark.
      • description: ...: A human-readable message for the status.
    • else: If the label is not present:
      • state: 'failure': This reports a red X.
      • description: ...: Explains why it failed.

Now, to make this enforce anything, you need to link this action’s status check to your branch protection rules.

  1. Go to your repository’s Settings > Branches.
  2. Under "Branch protection rules," click Add rule.
  3. Enter main (or your target branch name) in the "Branch name pattern" field.
  4. Check the box for Require status checks to pass before merging.
  5. Click Search all status checks.
  6. You should see an entry for your workflow, likely named something like Custom Branch Protection Checks / check_label. Select it.
  7. Optionally, check Require branches to be up to date before merging.
  8. Save the changes.

Now, when a pull request is opened against main, this Action will run. If the ready-for-merge label isn’t present, the status check will fail, and the merge button will be disabled because of your branch protection rule. A user will have to add the label to make the status check pass and enable merging.

The most surprising thing about this setup is how the Action doesn’t directly block merging. It reports a status, and it’s GitHub’s built-in branch protection rule that consumes that status and then blocks the merge. You’re essentially using the Action as a custom status check provider, extending the capabilities of GitHub’s native protection.

This pattern is incredibly flexible. You could check for:

  • The presence of specific commit messages (e.g., "fix:", "feat:")
  • The existence of certain files in the commit diff
  • The output of a custom linter or code analysis tool that doesn’t have a direct GitHub App integration.
  • Whether a specific user or team has approved the PR (though this is often better handled by required reviewers).

The actions/github-script action is your gateway to the GitHub API, allowing you to inspect PRs, commits, users, issues, and more, and then report back a status that your branch protection rules can act upon.

One crucial detail often overlooked is the context.sha used in createCommitStatus. This must be the SHA of the latest commit on the pull request branch. If you accidentally use github.event.pull_request.head.sha (which might be outdated if the PR has been updated) or github.sha (which refers to the commit running the workflow, not the PR head), your status checks might not appear correctly or might be associated with the wrong commit. Always ensure you’re reporting the status against the head commit of the PR.

The next thing you’ll likely want to tackle is adding more complex logic, perhaps requiring multiple labels or ensuring a specific file has been modified by an authorized user before allowing a merge.

Want structured learning?

Take the full Github-actions course →