Github Actions Pipeline with sonarqube quality gate manual approval and helm rollback

devops-cicd

The deploy job kept running straight after tests, ignoring the quality gate entirely. Turns out I never wired the needs keyword correctly, so all three jobs fired in parallel and a broken build hit production before I’d even read the test output.

Here’s the workflow I ended up with after fixing that. It chains four jobs: quality check, manual approval, deploy, and a conditional rollback. Each one only starts if the previous one passed, and the rollback only runs on failure. The whole thing lives in .github/workflows/deploy-with-gates.yml.

name: deploy-with-gates

on:
  push:
    branches: [main]

jobs:
  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests and coverage
        run: |
          npm ci
          npm test -- --coverage --coverageThreshold='{"global":{"lines":80}}'

      - name: SonarQube scan
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
        run: |
          npx sonar-scanner -Dsonar.projectKey=myapp
          STATUS=$(curl -s -u "$SONAR_TOKEN:" \
            "https://sonar.example.com/api/qualitygates/project_status?projectKey=myapp" \
            | jq -r '.projectStatus.status')
          echo "Gate status: $STATUS"
          [ "$STATUS" = "OK" ] || exit 1

  manual-approval:
    needs: quality-gate
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Approval checkpoint
        run: echo "Approved by ${{ github.actor }} at $(date -u)"

  deploy:
    needs: manual-approval
    runs-on: ubuntu-latest
    outputs:
      previous_revision: ${{ steps.save-rev.outputs.rev }}
    steps:
      - name: Save current Helm revision before deploy
        id: save-rev
        run: |
          REV=$(helm history myapp -n prod --max 1 --output json | jq -r '.[0].revision')
          echo "rev=$REV" >> "$GITHUB_OUTPUT"

      - name: Deploy
        run: helm upgrade --install myapp ./chart -n prod --atomic --timeout 5m

  rollback:
    needs: deploy
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - name: Roll back to previous revision
        run: |
          PREV=${{ needs.deploy.outputs.previous_revision }}
          echo "Rolling back to revision $PREV"
          helm rollback myapp "$PREV" -n prod --wait
          kubectl rollout status deployment/myapp -n prod

how the quality-gate job actually blocks things

The exit code is what matters.

After the SonarQube scanner finishes, the step hits GET /api/qualitygates/project_status?projectKey=myapp and pulls the .projectStatus.status field out of the JSON response. That field comes back as OK, WARN, or ERROR. The one-liner [ "$STATUS" = "OK" ] || exit 1 fails the step with a non-zero exit code if the gate isn’t clean, which kills the job, which stops every downstream job that has needs: quality-gate in it. GitHub Actions won’t run a job whose dependency failed. That’s the whole mechanism. No custom action needed, no extra API calls.

One thing that burned me: I renamed the job from sonar-check to quality-gate midway through, and the branch protection rule still referenced the old name. Every push after that got blocked with HttpError: Required status check "quality-gate" is not passing even when the gate was green. Go into Settings โ†’ Branches โ†’ your protection rule and update the required status check name to match whatever the job is actually called now.

environment: production and the approval timeout

The manual-approval job does almost nothing by itself.

The blocking behavior comes entirely from the environment: production key. When a job references an environment that has required reviewers configured in the repo settings, GitHub holds the job in a pending state until someone approves it. By default it’ll wait 30 days before timing out, which sounds fine until you realize a forgotten approval silently blocks every queued run behind it. Set timeout-minutes on that job to something sane for your team. I use 1440 (24 hours). After that the job fails and the queue clears.

The deploy job saves the current Helm revision before it runs the upgrade, not after. That ordering matters. If the upgrade fails mid-way, the output still has the last good revision number and the rollback job can use it. Saving it after a failed deploy would give you nothing useful. The revision comes from helm history myapp -n prod --max 1 --output json piped through jq, and it’s written to $GITHUB_OUTPUT so the rollback job can read it via needs.deploy.outputs.previous_revision.

helm rollback and what kubectl rollout status actually confirms

Helm’s --atomic flag on the upgrade will auto-rollback if the release fails its own health checks, but I still want an explicit rollback job for cases where the deploy appears to succeed and then falls over. The rollback job runs helm rollback myapp "$PREV" -n prod --wait, which tells Helm to restore the previous ReplicaSet and block until it’s healthy. The kubectl rollout status deployment/myapp -n prod line after it is a second confirmation from Kubernetes directly, separate from Helm’s own reporting.

If you’re on Argo Rollouts instead of plain Helm, kubectl argo rollouts undo myapp does the equivalent without needing to track revision numbers yourself. For the canary case specifically, you can run the quality gate against metrics at 10% traffic before promoting to 100%, which is a cleaner place to catch problems than post-deploy rollback anyway.

official docs

Leave a Reply

Your email address will not be published. Required fields are marked *

โ˜• Support us ยท ๐Ÿ’ณ Monobank