# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npm test # pipeline stops here if this fails
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
deploy:
needs: build-and-test # only runs if the job above succeeded
if: github.ref == 'refs/heads/main' # never deploy from a PR, only from main
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: npm run deploy
Each stage is a gate: npm test failing stops the deploy job from ever running, because of needs: build-and-test. This is the entire point of CI/CD — a broken build or a failing test physically cannot reach production, because the pipeline itself refuses to proceed. Note that the on: block runs this workflow on both pushes to main and pull requests targeting it — the tests and build run on every PR (giving reviewers a pass/fail signal before merge), but the if: condition on deploy ensures only an actual push to main (i.e., a merged PR) ever triggers a real deployment, never a pull request from an untrusted branch.