Advanced CI/CD — Branch Protection, Dependabot, Secrets Scoping, and Releases

A mature CI/CD pipeline goes beyond “run tests and deploy” — it enforces code quality standards automatically, manages secrets safely across environments, uses branch strategies to control what reaches production, and enables the team to move fast with confidence. This lesson covers the advanced pipeline features that separate a professional CI/CD setup from a basic one: branch protection rules, environment promotion strategies, secrets scoping, dependency update automation, and the complete pipeline visualised as a development workflow.

Branch Strategy and Pipeline Mapping

Branch Purpose Pipeline Trigger Deploys To
feature/xyz Feature development CI (lint + test) on push and PR Nowhere (or preview env)
develop Integration branch — all features merged here CI + auto-deploy on push Staging (automatic)
main Production-ready code — only merged from develop via PR CI + deploy on push Production (with approval)
hotfix/xyz Emergency production fix CI on push, manual deploy Production (expedited)
v1.2.3 (tag) Release tag Docker build + push + release notes Production (version tag)

Secrets Scoping Strategy

Secret Type Scope Example
Repository secret All workflows in the repo NPM_TOKEN, SNYK_TOKEN
Environment secret Only workflows targeting that environment production.JWT_SECRET, staging.MONGO_URI
Organisation secret All repos in the organisation SLACK_BOT_TOKEN, DOCKERHUB_TOKEN
Variable (not secret) Repository or environment — not encrypted API_BASE_URL, SENTRY_DSN
Note: GitHub Branch Protection Rules (Settings → Branches → Add Rule) enforce CI requirements before merging. Configure: “Require status checks to pass before merging” (select your CI jobs), “Require branches to be up to date before merging” (prevents stale PRs), “Require pull request reviews before merging” (minimum 1 review), and “Dismiss stale reviews when new commits are pushed” (re-review required after changes). These rules prevent anyone — including repository admins — from bypassing CI to push broken code to main.
Tip: Use Dependabot (configured in .github/dependabot.yml) to automatically open PRs for dependency updates. With CI running on every PR and auto-merge configured for patch updates that pass CI, your dependencies stay up-to-date with minimal manual effort. Set open-pull-requests-limit: 10 to avoid being overwhelmed, and group updates with groups: to batch minor updates into single PRs.
Warning: Pipeline secrets with broad scope are a security risk. A compromised workflow file (from a malicious dependency or a PRs from a contributor) could exfiltrate all repository-level secrets. Use the most narrow scope possible: environment secrets for deployment credentials, organisation secrets only for truly shared infrastructure tokens. Enable “Prevent secrets from being passed to workflows triggered by forks” in repository settings.

Complete Advanced Pipeline Configuration

# .github/workflows/pr-checks.yml — PR quality gate with auto-assignment
name: PR Checks

on:
    pull_request:
        branches: [main, develop]
    pull_request_review:
        types: [submitted]

jobs:
    size-check:
        name:    PR Size Check
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4
              with: { fetch-depth: 0 }

            - name: Check PR size
              uses: actions/github-script@v7
              with:
                  script: |
                      const { data: pr } = await github.rest.pulls.get({
                          owner:        context.repo.owner,
                          repo:         context.repo.repo,
                          pull_number:  context.issue.number,
                      });
                      const additions = pr.additions;
                      const deletions = pr.deletions;
                      const total     = additions + deletions;

                      let label = total > 500 ? 'size/xl' :
                                  total > 200 ? 'size/large' :
                                  total > 50  ? 'size/medium' : 'size/small';

                      github.rest.issues.addLabels({
                          owner:       context.repo.owner,
                          repo:        context.repo.repo,
                          issue_number:context.issue.number,
                          labels:      [label],
                      });

                      if (total > 800) {
                          core.warning(`This PR changes ${total} lines. Consider breaking it into smaller PRs.`);
                      }

    # ── Dependency updates with Dependabot ────────────────────────────────
    # .github/dependabot.yml
    # version: 2
    # updates:
    #   - package-ecosystem: npm
    #     directory: /api
    #     schedule: { interval: weekly }
    #     groups:
    #       prod-deps:
    #         patterns: ['*']
    #         update-types: ['minor', 'patch']
    #   - package-ecosystem: npm
    #     directory: /client
    #     schedule: { interval: weekly }
    #   - package-ecosystem: docker
    #     directory: /
    #     schedule: { interval: monthly }

    # ── Auto-merge Dependabot patch/minor PRs that pass CI ────────────────
    auto-merge-dependabot:
        name:    Auto-merge Dependabot
        runs-on: ubuntu-latest
        if: |
            github.actor == 'dependabot[bot]' &&
            github.event_name == 'pull_request'
        steps:
            - uses: actions/checkout@v4

            - name: Auto-merge patch and minor updates
              uses: actions/github-script@v7
              with:
                  github-token: ${{ secrets.GITHUB_TOKEN }}
                  script: |
                      const { data: pr } = await github.rest.pulls.get({
                          owner:       context.repo.owner,
                          repo:        context.repo.repo,
                          pull_number: context.issue.number,
                      });

                      // Only auto-merge minor and patch updates
                      const updateType = pr.title.match(/bump .+ from .+ to/i);
                      if (!updateType) return;
                      if (pr.title.includes('major')) return;  // skip major updates

                      await github.rest.pulls.merge({
                          owner:       context.repo.owner,
                          repo:        context.repo.repo,
                          pull_number: context.issue.number,
                          merge_method:'squash',
                      });

# ── Release workflow ───────────────────────────────────────────────────────
# .github/workflows/release.yml
name: Release

on:
    push:
        tags: ['v*.*.*']

jobs:
    create-release:
        name:    Create GitHub Release
        runs-on: ubuntu-latest
        permissions:
            contents: write
        steps:
            - uses: actions/checkout@v4
              with: { fetch-depth: 0 }

            - name: Generate changelog
              id:   changelog
              uses: orhun/git-cliff-action@v3
              with:
                  config:  cliff.toml
                  args:    --latest --strip header

            - name: Create release
              uses: actions/github-script@v7
              with:
                  script: |
                      github.rest.repos.createRelease({
                          owner:      context.repo.owner,
                          repo:       context.repo.repo,
                          tag_name:   context.ref.replace('refs/tags/', ''),
                          name:       context.ref.replace('refs/tags/', ''),
                          body:       `${{ steps.changelog.outputs.content }}`,
                          draft:      false,
                          prerelease: context.ref.includes('-rc') || context.ref.includes('-beta'),
                      });
# .github/dependabot.yml — Automated dependency updates
version: 2
updates:
    # API dependencies
    - package-ecosystem: npm
      directory:         /api
      schedule:
          interval: weekly
          day:      monday
          time:     "09:00"
          timezone: "Europe/London"
      open-pull-requests-limit: 10
      groups:
          api-prod-deps:
              patterns:     ['*']
              update-types: ['minor', 'patch']
          api-dev-deps:
              patterns:     ['*']
              dependency-type: development
              update-types: ['minor', 'patch']
      commit-message:
          prefix: 'chore(api)'

    # Angular dependencies
    - package-ecosystem: npm
      directory:         /client
      schedule:
          interval: weekly
          day:      monday
      groups:
          angular-core:
              patterns: ['@angular/*']
          client-deps:
              patterns: ['*']
      commit-message:
          prefix: 'chore(client)'

    # Docker base images
    - package-ecosystem: docker
      directory:         /api
      schedule:
          interval: monthly
      commit-message:
          prefix: 'chore(docker)'

    # GitHub Actions
    - package-ecosystem: github-actions
      directory:         /
      schedule:
          interval: monthly
      commit-message:
          prefix: 'chore(ci)'

How It Works

Step 1 — Branch Protection Rules Make CI Non-Bypassable

Branch protection rules configured in GitHub Settings enforce that every PR to main must pass all required status checks before merging — regardless of who created the PR, even the repository owner. Required status checks list specific job names from CI workflows. If any listed check fails or is skipped, the merge button is disabled. This closes the loophole of pushing directly to main to bypass CI.

Step 2 — Dependabot PRs Keep Dependencies Fresh Without Toil

Dependabot scans dependency manifests on the configured schedule, compares installed versions against the registry, and opens PRs for outdated packages. Each PR includes a changelog summary from the package’s release notes. With CI running on every PR, you can verify that a dependency update does not break anything before merging. Grouping related updates (all Angular packages together) reduces PR noise.

Step 3 — Auto-merge Eliminates Manual Approval for Safe Updates

Patch and minor updates are generally safe — they fix bugs and add backward-compatible features. Automating merges for these (after CI passes) eliminates the manual effort of reviewing and approving dozens of small dependency update PRs. Major version updates (which may have breaking changes) are excluded from auto-merge and require human review. This keeps dependencies current with minimal effort while maintaining oversight for risky changes.

Step 4 — Conventional Commits Enable Automated Changelogs

Tools like git-cliff or conventional-changelog parse commit messages formatted as type(scope): description (feat(tasks): add due date filtering, fix(auth): handle expired refresh token) to automatically generate changelogs. The release workflow generates the changelog from commits since the last tag and creates a GitHub Release with it. This removes manual changelog maintenance while producing meaningful, user-readable release notes.

Step 5 — Environment Secrets Prevent Secret Cross-Contamination

Staging and production have different secrets: different MongoDB URIs, different JWT secrets, different payment API keys. By storing each environment’s secrets under the corresponding GitHub Environment (not as repo-level secrets), you prevent a staging workflow from accidentally using production credentials — and prevent production credentials from being accessible in staging workflow logs. A deployment job only receives secrets from its targeted environment.

Common Mistakes

Mistake 1 — No branch protection rules — CI can be bypassed

❌ Wrong — developers can push directly to main without CI:

git push origin main  # bypasses CI entirely without branch protection

✅ Correct — configure branch protection in GitHub Settings:

# Settings → Branches → Add rule:
# Branch name pattern: main
# ✅ Require status checks: lint, test-api, test-angular
# ✅ Require branches to be up to date
# ✅ Include administrators (nobody bypasses)

Mistake 2 — Using repo-level secrets for environment-specific credentials

❌ Wrong — staging workflow can access production JWT secret:

# Repository secret: JWT_SECRET (same for staging and production)
env:
    JWT_SECRET: ${{ secrets.JWT_SECRET }}  # same value in both environments!

✅ Correct — environment secrets with different values per env:

# staging environment secret: JWT_SECRET = staging-value
# production environment secret: JWT_SECRET = production-value
environment: production
env:
    JWT_SECRET: ${{ secrets.JWT_SECRET }}  # resolves to production value only in production job

Mistake 3 — Ignoring major dependency updates from Dependabot

❌ Wrong — major version PRs pile up, never merged, dependencies fall years behind:

# .github/dependabot.yml:
ignore:
    - dependency-name: '*'
      update-types: ['version-update:semver-major']  # all major updates ignored forever!

✅ Correct — schedule regular major version review sprints, don’t permanently ignore:

# Monthly team process: review and address major version Dependabot PRs
# Do not auto-merge, but do not permanently ignore — migrate periodically

Quick Reference

Task Where / How
Require CI before merge Settings → Branches → Require status checks
Prevent force push to main Settings → Branches → Do not allow force pushes
Dependabot config .github/dependabot.yml
Auto-merge Dependabot github.rest.pulls.merge() in github-script
Env-scoped secrets Settings → Environments → [env name] → Add secret
Release changelog orhun/git-cliff-action on tag push
PR size labelling github-script checking pr.additions + pr.deletions
Hotfix deploy workflow_dispatch with image-tag input targeting production

🧠 Test Yourself

A workflow deploys to staging with environment: staging and production with environment: production. Both use secrets.JWT_SECRET. Each environment has a different JWT_SECRET value configured. Which value does the production job use?