Ren's blog

Metaphysics, tunes, and code

04-13-2026

Shielding Repos on a Budget

Consultants wait. It's part of the job. Contracts dry up. The bench fills. For a stretch you're just a developer with time and no fire to put out. Automation makes it easier. Less to remember.

I had a situation come up last week. My employer didn't have the budget for a GitHub Enterprise account. No enterprise means no branch protection. No branch protection means anyone can merge directly to main. I had time, so I got creative.

The goal was straightforward. No one merges their own pull request. Every merge needs at least one review. GitHub won't enforce that without enterprise, but GitHub Actions will run on a pull request, and Husky will run before a push. That's enough to work with.

It's not airtight. Someone determined enough can get around it. But most problems aren't caused by determination. They're caused by habit and convenience. Raise the floor, and you handle most of it.

The Plan

a flowchart of the implemented safeguards

Making it harder to commit to main

Git lets you attach scripts that run automatically before a commit is saved. These are called pre-commit hooks. If the script throws an error, Git cancels the commit completely and nothing gets recorded. Husky is a tool that manages these scripts in JavaScript projects, setting them up automatically when developers install dependencies.

#!/bin/sh
# .husky/pre-commit: block direct commits to main (or master)

BRANCH=$(git symbolic-ref HEAD 2>/dev/null | sed 's|refs/heads/||')
PROTECTED_BRANCHES="main master"

for protected in $PROTECTED_BRANCHES; do
  if [ "$BRANCH" = "$protected" ]; then
    echo ""
    echo "  🚫  Direct commits to '$BRANCH' are not allowed."
    echo "      Please create a feature branch and open a pull request."
    echo ""
    exit 1
  fi
done

exit 0

Once we successfully commit and push to the origin, every pull request triggers two automated checks. The first builds the project and runs the linter, catching any code that fails to compile or breaks formatting rules. The second runs the full test suite and measures code coverage. If coverage drops below 80%, the check fails. Both checks must pass before the pull request can be merged.

GitHub Actions is built into GitHub. When a pull request is opened or updated, it runs automatically. One of those checks reads the pull request description and looks for a valid Miro URL. No link, or an @todo placeholder still sitting there, and the action fails. The pull request doesn't go anywhere. Every pull request needs context. If the Miro link is missing, the intention behind the change is too.

GitHub Actions also manages a ready-to-merge label that controls whether a pull request can be merged. When a reviewer who is not the pull request author approves the changes, an action adds the label automatically. If the review is dismissed or new commits are pushed, the label is removed immediately and the process starts over. Humans cannot add the label themselves. If someone tries, an action detects it and removes it straight away. A final check reads whether the label is present before allowing the merge to go through.

name: Manage Ready-to-Merge Label

on:
  pull_request:
    types: [labeled, synchronize]
  pull_request_review:
    types: [submitted, dismissed]

jobs:
  enforce-bot-only-label:
    runs-on: ubuntu-latest
    if: |
      github.event.label.name == 'ready-to-merge' &&
      github.event.sender.login != 'github-actions[bot]'
    permissions:
      issues: write
      pull-requests: write
    steps:
      - name: Remove unauthorized label
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          curl -sf -X DELETE \
            -H "Authorization: token $GITHUB_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels/ready-to-merge"

  remove-label-on-review-dismissal:
    runs-on: ubuntu-latest
    if: |
      github.event_name == 'pull_request_review' &&
      github.event.review.state == 'dismissed'
    permissions:
      issues: write
      pull-requests: write
    steps:
      - name: Remove label if no approvals remain
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          approvals=$(curl -sf \
            -H "Authorization: token $GITHUB_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/$REPO/pulls/$PR_NUMBER/reviews" \
            | jq '[.[] | select(.state == "APPROVED")] | length')

          if [ "$approvals" -eq 0 ]; then
            curl -sf -X DELETE \
              -H "Authorization: token $GITHUB_TOKEN" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels/ready-to-merge" || true
          fi

  remove-label-on-push:
    runs-on: ubuntu-latest
    if: |
      github.event_name == 'pull_request' &&
      github.event.action == 'synchronize'
    permissions:
      issues: write
      pull-requests: write
    steps:
      - name: Remove label on push
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          labels=$(curl -sf \
            -H "Authorization: token $GITHUB_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels" \
            | jq -r '.[].name')

          if echo "$labels" | grep -q "^ready-to-merge$"; then
            curl -sf -X DELETE \
              -H "Authorization: token $GITHUB_TOKEN" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels/ready-to-merge" || true
          fi

  add-label-on-approval:
    runs-on: ubuntu-latest
    if: |
      github.event_name == 'pull_request_review' &&
      github.event.review.state == 'approved' &&
      github.event.review.user.login != github.event.pull_request.user.login
    permissions:
      issues: write
      pull-requests: write
    steps:
      - name: Add label on approval
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          curl -sf -X POST \
            -H "Authorization: token $GITHUB_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/labels" \
            -d '{"labels":["ready-to-merge"]}'

The Miro link check is simpler. It just reads the pull request description and looks for a valid URL.

on:
  pull_request:
    types: [opened, edited, reopened, synchronize]

jobs:
  check-miro-link:
    runs-on: ubuntu-latest
    steps:
      - name: Check for Miro link
        env:
          PR_BODY: ${{ github.event.pull_request.body }}
        run: |
          if echo "$PR_BODY" | grep -qi '@todo'; then
            echo "❌ '@todo' placeholder found. Replace with a Miro link."
            exit 1
          fi

          if ! echo "$PR_BODY" | grep -qiE 'https?://(www\.)?miro\.com/\S+'; then
            echo "❌ Miro link missing."
            exit 1
          fi

          echo "✅ Miro link found."

The label has to be earned fresh every time.

I got pulled back in eventually. New contract, new team. But the repo stayed clean. That's the thing about time on the bench. If you use it right, you build something that keeps working after you stop thinking about it.