Fix Your GitHub Actions CI Pipeline

Fix a broken CI pipeline and protect main with branch rules.

Introduction

30 Second Summary

When a CI/CD pipeline breaks, the whole team grinds to a halt. Nobody can merge their work until someone figures out why the automated checks are failing.

In this project, you will fix a broken GitHub Actions CI pipeline by adding the missing environment variables it needs. You will then push a verified fix via pull request and configure branch protection rules on main to prevent broken code from landing there in the future.

What You'll Build

You will troubleshoot and repair a GitHub Actions workflow for a FastAPI/PostgreSQL backend application, then lock down the main branch with protection rules.

By the end of this project, you'll have:

  • A working .github/workflows/backend-ci.yml with properly-scoped environment variables that pass all 9 CI steps.
  • A clear mental model of when to use hardcoded env: values vs. GitHub Repository Secrets in CI workflows.
  • Branch protection rules on main requiring passing CI checks and at least one approving review before merging.
  • Secret Mission: Add a job summary step that outputs a markdown status table to every PR.

Are there any prerequisites?

You need a GitHub repository that already has a .github/workflows/backend-ci.yml file with a Postgres service container configured. You also need push access to a non-main branch on that repository.

Understand the Tradeoff and Plan the Fix

Your CI pipeline is failing because the migration step cannot find two environment variables. Before jumping in to fix it, you need to decide where those variables should live.

GitHub Actions offers two options: hardcoding values in the workflow's env: block, or storing them as Repository Secrets. Making the right choice prevents both security risks and unnecessary complexity.

In this step, get ready to:
  • Learn the decision framework for secrets vs. hardcoded environment variables.
  • Identify which scope (workflow, job, or step) your variables need.
  • Determine the exact values for both missing variables.
Secrets vs. hardcoded env: the decision framework

The key question is: should DATABASE_URL and JWT_SECRET_KEY go in GitHub Repository Secrets or directly in the workflow's env: block?

  • Ask yourself: would this value be dangerous if it leaked in a log or to a fork's PR?
  • If yes, use a Repository Secret (stored under Settings > Secrets and variables > Actions).
  • If the value is a throwaway that only exists for the CI run and protects nothing real, hardcode it in the workflow.

Apply this framework to your two missing variables:

  • DATABASE_URL points to an ephemeral Postgres service container that exists only during the workflow run. The credentials (vitalai:vitalai) grant access to nothing outside that container's lifetime. Verdict: hardcode in env:.
  • JWT_SECRET_KEY in CI is a dummy value. It signs nothing sensitive because there are no real users in CI. It just needs to be non-empty so the config validator passes. Verdict: hardcode in env:.

When would you use Repository Secrets instead?

Use secrets when the value grants access to something persistent or sensitive:

  • Production database credentials.
  • Third-party API keys (OpenAI, Stripe, etc.).
  • Credentials that grant access to persistent resources.
  • Anything you would not want visible if the repo were made public or forked.

You reference secrets in a workflow with the syntax ${{ secrets.SECRET_NAME }}.

Identify where in the workflow the env vars are needed
  • Open your .github/workflows/backend-ci.yml file.
  • Look for the step named "Run migrations" (the one that runs alembic upgrade head). This is the step that currently fails because it imports your app's settings module, which validates that DATABASE_URL and JWT_SECRET_KEY are set.
  • Also note the pytest step. It connects to the same Postgres database, so it needs DATABASE_URL too.

Since multiple steps need these variables, you need to decide the right scope for the env: block.

What are the three env scopes?

  • Workflow-level env: (top of file): available to ALL jobs and steps. Broadest scope.
  • Job-level env: (under jobs.<job_id>.env): available to all steps in that specific job.
  • Step-level env: (under a specific step): available only to that single step.

Since both the migration step and the pytest step need DATABASE_URL, and both live in the same job, job-level env: is the right scope. It avoids duplication without leaking variables to unrelated jobs.

Determine the exact values

The values must match the Postgres service container already configured in your workflow. Look at the services: block in your file to confirm.

  • DATABASE_URL: postgresql+asyncpg://vitalai:vitalai@localhost:5432/vitalai.
  • JWT_SECRET_KEY: ci-test-secret-key-not-for-production.

Why does DATABASE_URL use localhost?

Your workflow uses ports: - 5432:5432 on the service container without a container: job image. This means the Postgres container's port is mapped to the runner's localhost. The URL must connect via localhost:5432 to reach it.

The username (vitalai), password (vitalai), and database name (vitalai) all match the POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB values in your service container's env: block.

Why is the JWT key so obviously named?

Naming it ci-test-secret-key-not-for-production makes the intent crystal clear to anyone reading the workflow. This value signs nothing sensitive. If a teammate later considers reusing it in production, the name itself serves as a warning.

You have a clear plan. Next up, you will edit the workflow file to add these variables and get your pipeline passing.

Edit the Workflow File

You now know exactly which variables to add and why they belong in a hardcoded env: block instead of repository secrets.

In this step, you will edit your GitHub Actions workflow file to add the missing environment variables at job level, document the decision with comments for your teammates, and verify the YAML indentation is correct.

In this step, get ready to:
  • Add a job-level env block with both required environment variables.
  • Document the rationale with inline comments for your team.
  • Verify the YAML indentation is correct.
Add the job-level env block

The env: block needs to sit at job level so that every step in the job (including both the migrations step and pytest) can access the variables.

  • Open .github/workflows/backend-ci.yml in your editor.
  • You can also edit directly on GitHub by navigating to the file and clicking the pencil icon in the upper right corner of the file view.
  • Locate your job definition (jobs: then test:).
  • Find the end of the services: block (after the options: section for Postgres) and add the following env: block below it, above steps::
    env:
      DATABASE_URL: postgresql+asyncpg://vitalai:vitalai@localhost:5432/vitalai
      JWT_SECRET_KEY: ci-test-secret-key-not-for-production

What does this code do?

This env: block is placed at job level, meaning every step in the test job inherits both variables.

  • DATABASE_URL points to the ephemeral Postgres service container that exists only during the workflow run. The credentials (vitalai:vitalai) match the POSTGRES_USER and POSTGRES_PASSWORD defined in the service container above.
  • JWT_SECRET_KEY is a dummy value that satisfies the app's config validator. It signs nothing sensitive because there are no real users in CI.

Why job-level instead of step-level?

If you placed env: under a single step, only that step would see the variables. Both the "Run migrations" step and the "Run pytest" step need DATABASE_URL to connect to the test database. Job-level avoids duplicating the block.

You will not see a change yet. The next substep adds documentation before we verify the full file.

Add a comment explaining the decision

Good CI workflows make it obvious which values are safe to hardcode and which should be repository secrets. A short comment block prevents a future teammate from moving these values to secrets unnecessarily.

  • In .github/workflows/backend-ci.yml, find the env: block you just added.
  • Add these comment lines directly above it:
    # CI-only environment variables. These are NOT secrets:
    # - DATABASE_URL targets the ephemeral Postgres service container above
    # - JWT_SECRET_KEY is a dummy value for config validation only
    # For real secrets (API keys, production creds), use GitHub Repository Secrets:
    # Settings > Secrets and variables > Actions > New repository secret
    # Reference in workflow as: ${{ secrets.SECRET_NAME }}
    env:
      DATABASE_URL: postgresql+asyncpg://vitalai:vitalai@localhost:5432/vitalai
      JWT_SECRET_KEY: ci-test-secret-key-not-for-production

What does this comment do?

The comment accomplishes three things for anyone reading this workflow in the future:

  • It explains why these values are hardcoded (ephemeral container, dummy validation value).
  • It names the exact UI path for adding real secrets when the team needs them later.
  • It shows the syntax for referencing secrets in a workflow (${{ secrets.SECRET_NAME }}).
Verify the YAML is valid

YAML is whitespace-sensitive. An indentation error is the most common cause of workflow parse failures.

  • Check that your env: line is at the same indentation level as runs-on:, services:, and steps: (4 spaces in from the job name).
  • Check that DATABASE_URL: and JWT_SECRET_KEY: are indented 2 more spaces under env: (6 spaces total from the job name).
  • If your editor supports YAML language mode, enable it for automatic indentation highlighting.
  • Save .github/workflows/backend-ci.yml.

Quick indentation check

Scan the left edge of your file. The keywords runs-on:, services:, env:, and steps: should all line up vertically. If one sticks out further or less than the others, fix it before saving.

Seeing a YAML parse error?

  • Make sure you are using spaces, not tabs. YAML does not allow tabs for indentation.
  • Verify there are no trailing spaces after values on any line.
  • Confirm the comment lines (starting with #) are at the same indentation as env: (4 spaces).

Help me fix a YAML indentation error in my GitHub Actions workflow file.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is what your complete .github/workflows/backend-ci.yml file should look like at this point:

name: Backend CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: vitalai
          POSTGRES_PASSWORD: vitalai
          POSTGRES_DB: vitalai
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    # CI-only environment variables. These are NOT secrets:
    # - DATABASE_URL targets the ephemeral Postgres service container above
    # - JWT_SECRET_KEY is a dummy value for config validation only
    # For real secrets (API keys, production creds), use GitHub Repository Secrets:
    # Settings > Secrets and variables > Actions > New repository secret
    # Reference in workflow as: ${{ secrets.SECRET_NAME }}
    env:
      DATABASE_URL: postgresql+asyncpg://vitalai:vitalai@localhost:5432/vitalai
      JWT_SECRET_KEY: ci-test-secret-key-not-for-production

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Ruff lint
        run: ruff check .

      - name: Ruff format check
        run: ruff format --check .

      - name: Mypy type check
        run: mypy .

      - name: Run migrations
        run: alembic upgrade head

      - name: Run pytest
        run: pytest

Your workflow file is ready. Next up, you will push this fix via a pull request and watch the CI checks turn green.

Push a PR and Verify CI Passes

Your workflow file is fixed and saved locally. Now it is time to prove the fix works by pushing it through the actual CI pipeline.

You will commit your change to a feature branch, open a pull request targeting main, and watch all 9 CI steps turn green. Once verified, you will merge the PR so the fix lands in main permanently.

In this step, get ready to:
  • Commit and push your fix to a feature branch.
  • Open a PR and confirm all CI checks pass.
  • Get the PR reviewed and merged.
Commit and push to a feature branch

Creating a dedicated branch keeps your fix isolated until CI proves it works. This is the standard workflow for team projects: branch, push, open a PR, verify, merge.

  • In your terminal, create a new branch, stage your workflow file, commit, and push by running these commands:
git checkout -b fix/ci-env-vars
git add .github/workflows/backend-ci.yml
git commit -m "fix(ci): add DATABASE_URL and JWT_SECRET_KEY to workflow env

The migrations step requires these environment variables to be set.
DATABASE_URL points to the ephemeral Postgres service container.
JWT_SECRET_KEY is a dummy value for config validation in CI."
git push origin fix/ci-env-vars

What do these commands do?

  • git checkout -b fix/ci-env-vars creates and switches to a new branch named fix/ci-env-vars.
  • git add stages only the workflow file you changed.
  • git commit -m saves your change with a descriptive message explaining why the env vars were added.
  • git push origin fix/ci-env-vars uploads the branch to GitHub so you can open a PR.
  • After the push completes, you should see a URL in your terminal output pointing to the new branch on GitHub.

Push rejected or authentication failed?

  • If you see fatal: Authentication failed: your Git credentials may have expired. Re-authenticate using gh auth login (GitHub CLI) or update your personal access token.
  • If you see Permission denied (publickey): your SSH key is not configured for this repository. Check your SSH setup in GitHub Settings under SSH and GPG keys.
  • If you see a branch already exists error: someone else may have used the same branch name. Try git checkout -b fix/ci-env-vars-2 instead.

Help me troubleshoot my git push error

Open a PR and watch the checks

Now that your branch is on GitHub, you will open a pull request so the CI pipeline runs against your changes.

  • Go to your repository on GitHub in your browser.
  • You should see a yellow banner saying your branch fix/ci-env-vars had recent pushes. Click Compare & pull request.
  • Confirm the base branch is main and the compare branch is fix/ci-env-vars.
  • Click Create pull request.

Once the PR is created, GitHub Actions automatically triggers your CI workflow.

  • Click the Actions tab in your repository (or scroll down on the PR page to see the checks section).
  • Wait for all steps to complete. You should see green checkmarks on every step:
  • Checkout.
  • Set up Python 3.13.
  • Install dependencies.
  • Ruff lint.
  • Ruff format check.
  • Mypy type check.
  • Run migrations (this was the failing step before your fix).
  • Run pytest.

Migration step still failing?

  • If the error still mentions the config validator requiring a variable: your env: block indentation is wrong. It must be at the same level as runs-on:, services:, and steps:.
  • If you see a database connection error: verify the DATABASE_URL value matches the service container credentials exactly. The username, password, and database name must all be vitalai.
  • If you see a YAML parse error: check for tabs instead of spaces, or inconsistent indentation. YAML requires spaces only.

Help me debug why my CI migration step is still failing

Get the PR reviewed and merged

With all checks green, your fix is proven. Now get it into main so the whole team benefits.

  • On your PR page, click Reviewers in the right sidebar.
  • Select your team leader as the reviewer.
  • Once they approve, click Merge pull request.
  • Click Confirm merge.

Why not merge without a review?

Even though the checks pass, getting a teammate's eyes on CI changes is good practice. Workflow files have broad permissions and subtle YAML mistakes can break the pipeline for everyone.

In the next step, you will formalize this by setting up branch protection rules that require reviews before merging.

✔️ Awesome, I've got everything!

Great. Double check that your PR is merged and the branch has been integrated into main.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/backend-ci.yml file as it should look in main after merging:

name: Backend CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: vitalai
          POSTGRES_PASSWORD: vitalai
          POSTGRES_DB: vitalai
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    # CI-only environment variables. These are NOT secrets:
    # - DATABASE_URL targets the ephemeral Postgres service container above
    # - JWT_SECRET_KEY is a dummy value for config validation only
    # For real secrets (API keys, production creds), use GitHub Repository Secrets:
    # Settings > Secrets and variables > Actions > New repository secret
    # Reference in workflow as: ${{ secrets.SECRET_NAME }}
    env:
      DATABASE_URL: postgresql+asyncpg://vitalai:vitalai@localhost:5432/vitalai
      JWT_SECRET_KEY: ci-test-secret-key-not-for-production

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Ruff lint
        run: ruff check .

      - name: Ruff format check
        run: ruff format --check .

      - name: Mypy type check
        run: mypy .

      - name: Run migrations
        run: alembic upgrade head

      - name: Run pytest
        run: pytest

Your CI pipeline is fully green. Next up, you will lock down main with branch protection rules so broken code can never skip the pipeline again.

Configure Branch Protection on Main

Your CI pipeline is fully green and the fix has been merged into main. But there is nothing stopping someone from pushing broken code directly to main in the future.

Branch protection rules solve this problem. They block merges into a protected branch unless conditions like passing CI checks and an approving code review are met.

In this step, get ready to:
  • Request branch protection settings from your repo admin.
  • Enable required status checks so CI must pass before merging.
  • Enable required reviews so at least one teammate approves changes.

Why do I need admin access for this?

Branch protection rules require admin or owner permissions on the repository. Since you do not own this repo, this step documents what to request from your team leader (the repo admin).

You can walk through this together in person, screen-share, or send them the instructions below.

Navigate to branch protection settings
  • Ask your team leader to open the repository on GitHub.
  • Click Settings in the top navigation bar of the repository.
  • In the left sidebar, click Branches.
  • Click Add branch protection rule (or edit the existing rule if one already exists for main).
  • In the Branch name pattern field, enter main.
Enable required status checks

Required status checks prevent a PR from being merged until the CI job passes. This ensures broken code never lands on main.

  • Check the box labeled Require status checks to pass before merging.
  • In the search box that appears, search for your CI job name (test) and select it.
  • Check Require branches to be up to date before merging (strict mode).

What does strict mode do?

Strict mode means a PR must be tested against the latest main before it can merge. If another PR merges first and changes main, your PR must re-run CI against the updated branch.

This prevents the "it passed on my branch but broke on main" problem that happens when two PRs conflict.

Why doesn't the CI job appear in the search?

The check must have run at least once in the past 7 days for GitHub to show it in the search results. Your just-merged PR satisfies this requirement.

Enable required reviews

Required reviews ensure at least one teammate reads and approves the code before it merges. This catches issues that automated checks miss.

  • Check the box labeled Require a pull request before merging.
  • Check Require approvals.
  • In the Required number of approvals before merging dropdown, select 1.
  • Check Dismiss stale pull request approvals when new commits are pushed.
  • Click Create (or Save changes) to apply the protection rule.

Why dismiss stale approvals?

Without this setting, a teammate could approve your PR, then you push new unreviewed code, and the PR would still be mergeable with the old approval. Dismissing stale approvals forces a fresh review whenever the code changes after approval.

If you prefer, copy and paste this message to your team leader instead of walking through the steps together:

Hey, can you set up branch protection on main? We need:
1. Require status checks to pass - select our CI job name (test) from the search
2. Require branches to be up to date before merging
3. Require 1 approving review before merging
4. Dismiss stale approvals when new commits are pushed
Path: Settings > Branches > Add rule > branch pattern: main

What does this message do?

This gives your admin the exact settings and navigation path in one copy-paste block. It saves a back-and-forth conversation about which checkboxes to enable.

Once your admin applies the rule, attempting to merge a PR into main without passing CI checks or an approving review will be blocked by GitHub.

Protection rule not working?

Make sure the branch name pattern is exactly main (no spaces, no wildcard). A typo in the pattern means the rule will not match.

If the CI check does not appear in the search, verify that your merged PR ran the workflow within the past 7 days.

Help me troubleshoot branch protection

Secret mission

Add a Job Summary with Status Table

Your CI pipeline works, but when a teammate opens the Actions tab, they see a wall of logs. What if every PR automatically showed a clean markdown table summarizing which checks passed and which failed? In this mission, you will add a GitHub Actions job summary that renders directly on the PR.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on GitHub's free tier, so there are no ongoing costs. Decide whether to keep your resources as they are, or revert the changes if you no longer need them.

Resources you used:

  • Branch protection rule on main (configured by your admin).
  • Feature branch fix/ci-env-vars (likely already deleted after merge).
  • Modified .github/workflows/backend-ci.yml with job-level env vars and Job Summary step.

Keep everything running

No action needed. Choose this if your team is actively using this CI pipeline and branch protection setup.

  • Your workflow will continue running on every push to main and on every PR targeting main.
  • Branch protection remains active, requiring passing CI and one approving review before merging.
  • GitHub Actions minutes are free for public repositories. For private repositories, your free-tier minutes cover typical CI usage.

Pause - I'll come back to this later

There is nothing to pause in this project. GitHub Actions workflows only run when triggered by a push or PR, so they consume no resources between runs.

  • If you want to temporarily stop the workflow from running, you can disable it from the Actions tab in your repository. Click the workflow name, then click the three-dot menu and select Disable workflow.
  • Branch protection stays in place until an admin removes it. No ongoing cost.

Delete - I don't want to use this again

Remove all project resources and revert to the state before this project.

  • Delete the feature branch (if it still exists) by running:
git push origin --delete fix/ci-env-vars
  • To remove branch protection, ask your admin to go to Settings > Branches, click Edit on the main rule, scroll to the bottom, and click Delete this rule.
  • To revert the workflow file changes, open a new PR that removes the env: block, the id: fields, and the Job Summary step from .github/workflows/backend-ci.yml. Alternatively, use git revert on the merge commit.

Nice Work!

Nice Work!

Nice work! You just fixed a broken CI pipeline, shipped the fix through a proper PR workflow, and locked down main so broken code can never sneak through again.

You've learned how to:

  • Fixed a failing GitHub Actions CI pipeline by adding job-level environment variables (DATABASE_URL and JWT_SECRET_KEY) and verified all 9 steps pass green.
  • Applied the principle of least privilege to CI secrets by distinguishing throwaway env: values from GitHub Repository Secrets meant for real sensitive data.
  • Configured branch protection on main requiring passing CI checks and at least one approving review before merging.
  • Secret Mission: Added a job summary step that outputs a markdown status table to every PR using $GITHUB_STEP_SUMMARY.

Ready to quiz yourself?