Too much work per run
Separate release and deploy from CI on pull requests
By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026
A developer opens a PR. The workflow builds the app, runs tests, pushes a Docker image to the registry, and deploys to staging, all before anyone reviews the code. Every PR update repeats the entire pipeline. You're billed for deploy minutes that produce nothing useful, and you risk accidental publishes to production registries. This is fixable by gating deploy and publish steps to main with a few lines of conditional logic.
Symptoms
How to tell if deploy steps are running on PRs
Open your Actions tab and look at the job list on any PR run. If you see deploy, publish, or release jobs executing, you have this problem.
-
Deploy jobs on PR runs. Your PR workflow shows jobs named deploy, publish, or release that execute on every push to a feature branch. These jobs succeed but their output is never used because nobody is reviewing a staging deploy from an unreviewed PR.
-
Docker images pushed from feature branches. Your container registry fills with images tagged from PR branches like myapp:feature-login-fix and myapp:dependabot-bump-react that no one will ever pull. Worse, if you tag with latest, PR builds overwrite your production image.
-
Duplicate build-then-deploy work. The same application is built twice in one pipeline: once in a build job for testing, and again inside the deploy job because it doesn't reuse artifacts. On PRs this doubles the wasted minutes.
Metrics
Quantify the waste from PR deploys
Deploy steps typically add 3–8 minutes per run. When they execute on every PR push across a team, the cost is straightforward to calculate. Here's a typical scenario for a team running 30 PR-triggered workflows per day on Linux:
Before: deploy on every PR
At $0.006/min (Linux 2-core)
After: deploy only on main
Save $19.80/mo · $238/year · per workflow
That's one workflow on Linux runners. If the deploy job includes a Docker build step on a larger runner, say an 8-core at $0.022/min, the same 3,300 minutes become $72.60/mo wasted. On macOS at $0.062/min, it's $204.60/mo. The deploy minutes go to zero in all cases once you gate to main.
Fix 1
Add conditional guards to deploy jobs
The simplest fix is an if: condition on the deploy job. GitHub evaluates this before the job starts, and if the condition is false, the job is skipped entirely and consumes zero minutes.
Check both the branch ref and the event type. A push to main means merged code ready for deployment. A pull_request event means unreviewed code that should only be tested.
jobs: test: runs-on: ubuntu-latest steps: - run: npm test deploy: needs: test runs-on: ubuntu-latest # No condition - runs on PRs too steps: - run: ./deploy.sh
jobs: test: runs-on: ubuntu-latest steps: - run: npm test deploy: needs: test if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest steps: - run: ./deploy.sh
The same pattern works at the step level. If you want the Docker image to be built on PRs (to validate the Dockerfile) but not pushed, use a conditional on the push flag:
- name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: myorg/myapp:latest
On PRs, the image builds (catching Dockerfile errors) but is not pushed. On merge to main, it builds and pushes. This is the pattern recommended by Docker's own GitHub Actions documentation.
Fix 2
Split CI and deploy into separate workflows
Instead of conditional logic inside one file, create two workflow files. The CI workflow runs on PRs. The deploy workflow runs only on pushes to main. No conditionals are needed because the trigger scope handles everything.
name: CI on: pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run build
name: Deploy on: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test deploy: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./deploy.sh
This is the cleanest separation. PR authors see only the CI workflow in their checks. Deploy runs only appear on main after merge. The tradeoff: you may duplicate some job definitions across files. If that bothers you, extract shared steps into a reusable workflow with workflow_call. See speeding up CI pipelines for more patterns.
One caveat: the deploy workflow above re-runs tests on main. If you trust your PR CI and want to skip redundant testing, remove the test job from the deploy workflow and use artifacts from the CI run instead (see Fix 3). For more on reducing full CI on branch pushes, see the dedicated guide.
Fix 3
Build once and deploy from artifacts
A common pattern is to rebuild the application inside the deploy job. This means the same code compiles twice in one pipeline: once for testing, once for deploying. On main, build once, share artifacts, upload the output, and download it in the deploy job. This eliminates the duplicate build and guarantees the deployed artifact is identical to what was tested.
name: Deploy on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - uses: actions/upload-artifact@v4 with: name: build-output path: dist/ deploy-staging: needs: build runs-on: ubuntu-latest environment: staging steps: - uses: actions/download-artifact@v4 with: name: build-output - run: ./deploy.sh staging deploy-production: needs: deploy-staging runs-on: ubuntu-latest environment: production steps: - uses: actions/download-artifact@v4 with: name: build-output - run: ./deploy.sh production
The build runs once. Both staging and production deploy the exact same artifact. GitHub Actions v4 artifacts include SHA256 digest verification, so the integrity of what you tested is preserved through deployment.
One caveat: artifacts are tied to a workflow run. If you need to deploy from a different workflow (e.g., a separate deploy workflow triggered by workflow_run), you'll need to use actions/download-artifact@v4 with the run-id and github-token inputs, and the calling workflow needs actions: read permission.
Fix 4
Use environment protection rules as a safety net
Even with if: conditions in your YAML, someone can remove them. GitHub's deployment environments provide a server-side safety net. Configure the production environment in your repository settings to restrict deployments to the main branch. Even if a workflow job references the environment, GitHub blocks it unless the branch matches.
jobs: deploy: needs: build runs-on: ubuntu-latest environment: production # Branch restriction enforced server-side steps: - uses: actions/checkout@v4 - run: ./deploy.sh
Enterprise Cloud Required reviewers and wait timers for environment protection require GitHub Enterprise Cloud on private repositories. Deployment branch restrictions are available on Pro and Team plans for private repos. Public repositories get all environment features on every plan.
Environment protection rules give you three things beyond YAML conditions: branch restrictions (only main or tagged refs can deploy), required reviewers (a human must approve before the deploy job starts), and wait timers (a delay between approval and execution). Even if someone accidentally removes the if: condition from a workflow, the environment rule still blocks the deploy.
Environment secrets are also scoped, so deploy credentials stored in the production environment are not available to jobs that don't reference that environment. This means PR runs can't access your deploy keys even if the workflow YAML is misconfigured.
Reference
Tag-based release triggers for package publishing
For npm, PyPI, gem, or container publishing, the safest trigger is a version tag. Releases happen only when you explicitly push a tag like v1.2.3. There is no possibility of an accidental PR-triggered publish.
name: Publish on: push: tags: - 'v*.*.*' jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org - run: npm ci && npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
This workflow only fires when a tag matching v*.*.* is pushed. Normal development pushes, PR updates, and merges to main do not trigger it. The publish is an intentional act that requires someone to run git tag v1.2.3 && git push --tags.
Reference
When should deploy steps run?
Use this table to decide which trigger is appropriate for each type of deploy-related step. The goal: PR runs should only build and test. Everything else waits.
| Step type | On PR | On main | On tag |
|---|---|---|---|
| Build / compile | yes | yes | yes |
| Unit / integration tests | yes | yes | optional |
| Docker build (no push) | yes | yes | yes |
| Docker push to registry | no | yes | yes |
| Deploy to staging | no | yes | optional |
| Deploy to production | no | optional | yes |
| npm / PyPI / gem publish | no | no | yes |
The pattern is consistent: anything that only validates code belongs on PRs. Anything that publishes or deploys belongs on main or a version tag. Blurring this line means paying for deploy minutes on every PR push and risking accidental releases from unreviewed code.
Related guides
Build Once, Share Artifacts
Eliminate redundant builds with artifacts and caching to cut minutes 40-60%.
Speed Up Slow CI Pipelines
Identify slow jobs, add caching, right-size parallelism, and reorder for fast failure.
Reduce Full CI on Branch Pushes
Scope triggers to PRs and main to cut non-PR CI minutes 60-80%.
E2E Tests Running Too Often
Gate Playwright and Cypress suites by risk and defer full runs to merge queue.