GitHub Actions
How to automate tests, catch compatibility problems, and create a safer pull request workflow.
There is a familiar moment in software development:
“It works on my machine.”
The code runs locally. The pull request looks reasonable. The tests pass on one developer’s laptop. Then someone else checks out the branch and gets a different result.
A dependency is missing. The Node.js version is different. A build step was never executed. A test that should have run was skipped.
The problem is not always bad code. Often, the problem is that the project has no consistent way to verify changes.
GitHub Actions can provide that verification.
But GitHub Actions is not just a collection of YAML files. A good workflow gives the team a reliable answer before code is merged:
Does this change still work in a clean environment?
That answer is the real value of continuous integration.
Start with one useful workflow
GitHub Actions workflows are stored in .github/workflows/. A file such as ci.yml can run automatically when a pull request is opened or when code is pushed to the main branch.
Here is a practical starting point for a Node.js project:
name: Continuous integrationon:
push:
branches:
- main
pull_request:permissions:
contents: readconcurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: truejobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10 strategy:
fail-fast: false
matrix:
node-version: [20, 22] steps:
- name: Check out the repository
uses: actions/checkout@v4 - name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm - name: Install dependencies
run: npm ci - name: Run tests
run: npm test
This workflow checks every pull request and every push to main, tests two supported Node.js versions, installs dependencies from the lockfile, reuses npm's cache, limits the workflow token to read-only access, cancels outdated runs, and stops jobs that run for too long.
None of these steps is particularly complicated. Their value comes from running them consistently.
The clean runner is the point
A GitHub-hosted runner starts with a clean environment. That is one of the main reasons CI works.
Your local machine contains accumulated state: global packages, cached dependencies, environment variables, multiple runtime versions, and tools installed for unrelated projects. That state can hide problems.
A clean runner removes most of that noise. If the project only works because a developer has a particular package installed globally, CI will expose the problem quickly.
A CI workflow should install what it needs instead of depending on the runner’s existing state.
Use the lockfile as a contract
For npm projects, npm ci is usually a better fit for CI than npm install.
npm ci installs the versions recorded in package-lock.json. It also fails when the lockfile and package.json are inconsistent.
That failure is useful. CI should not silently modify the dependency graph. It should tell the team that the repository needs attention.
The same principle applies to other ecosystems:
# Python
pip install -r requirements.txt# Go
go test ./...# Java
mvn -B verify# Ruby
bundle install --jobs 4
The command changes, but the goal stays the same: make the build depend on files committed to the repository, not on configuration hidden inside one developer’s computer.
Use caching carefully
A clean runner has a cost: dependencies must be downloaded again.
For npm, actions/setup-node supports caching directly:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npmThe cache is associated with the dependency lockfile. When the lockfile changes, GitHub can create a new cache key.
Caching improves speed, but it should not change correctness. A cache miss should make the workflow slower, not cause it to use untracked or outdated dependencies.
Do not store credentials, access tokens, or private configuration files in a dependency cache.
Permissions should start at zero
Every workflow receives a GITHUB_TOKEN. For a test-only workflow, this is usually enough:
permissions:
contents: readIf another job needs to write a pull request comment, publish a package, or create a release, grant the additional permission explicitly:
permissions:
contents: read
pull-requests: writeWorkflows execute code and may use third-party actions, shell scripts, package managers, and project dependencies. If something is compromised, unnecessary permissions increase the damage it can cause.
Pull requests from forks are untrusted input
A pull request is code that has not necessarily been reviewed yet. A pull request from a fork deserves extra care.
Get Alberto Ortiz Becerril’s stories in your inbox
Join Medium for free to get updates from this writer.
For normal testing, use:
on:
pull_request:Be cautious with:
on:
pull_request_target:pull_request_target runs in the context of the base repository and can have access to different permissions and secrets. A dangerous pattern is to use it and then check out and execute the pull request's code.
Never expose deployment credentials to code that has not been trusted. Keep deployment workflows separate from ordinary pull request validation whenever possible.
Secrets belong at the edge
A test workflow should not need production credentials. When a deployment really does require a secret, pass it explicitly:
- name: Deploy
run: ./scripts/deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Do not put secrets in source code, workflow files, command-line arguments, or artifacts.
For cloud deployments, OpenID Connect can often replace long-lived credentials. The workflow requests short-lived credentials after authenticating through a trusted relationship.
Third-party actions are dependencies too
An action is executable code. Depending on its permissions, it may read files, access the GitHub token, or interact with configured secrets.
Before adding an action, check who maintains it, what permissions it needs, and whether it is actively maintained. For stricter supply-chain controls, pin an action to a full commit SHA:
- uses: actions/checkout@<full-commit-sha>A tag such as @v4 is easier to read, but it can move. A SHA identifies one exact revision. Teams often combine SHA pinning with Dependabot or another update process.
Add AI without giving it the keys
An additional workflow can ask an AI model to review a pull request. The safest first version is advisory: the model writes a report, but it cannot merge code, deploy anything, or mark the check as successful by itself.
Create a prompt file at .github/prompts/review.md:
You are reviewing a pull request as a cautious software engineer.Analyze only the diff provided by the workflow. Do not invent files, behavior, tests, or vulnerabilities that are not supported by the diff.Look for:
- bugs and likely regressions
- missing or misleading tests
- security problems
- breaking changes
- unnecessary complexityFor every finding, include:
1. Severity: high, medium, or low
2. File and line, when available
3. Why it matters
4. A concrete recommendationIf you find no actionable issue, say so clearly. Finish with a short summary and a reminder that a human must make the final decision.
Then add a separate workflow such as .github/workflows/ai-review.yml:
name: AI pull request reviewon:
pull_request:permissions:
contents: readjobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 10 steps:
- name: Check out the repository
uses: actions/checkout@v6 - name: Set up Node.js
uses: actions/setup-node@v6 - name: Install GitHub Copilot CLI
run: npm install --global @github/copilot - name: Create the pull request diff
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: git diff "$BASE_SHA...HEAD" > pull-request.diff - name: Ask AI for a review
id: review
uses: actions/ai-inference@v1
with:
provider: copilot
model: gpt-4.1
system-prompt-file: .github/prompts/review.md
prompt: |
Review the pull request diff stored in pull-request.diff.
Return a concise report in Markdown.
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Add the report to the workflow summary
env:
AI_REVIEW: ${{ steps.review.outputs.response }}
run: |
{
echo "## AI review"
echo
printf '%s\n' "$AI_REVIEW"
} >> "$GITHUB_STEP_SUMMARY"
This example requires GitHub Copilot access and a repository secret named COPILOT_GITHUB_TOKEN with the permission required by the Copilot CLI. It does not use an OpenAI API key or GitHub Models.
For pull requests from forks, that secret is normally unavailable. Keep this workflow limited to trusted branches or redesign the flow so that untrusted code is never executed with access to a credential. Do not solve the problem by switching blindly to pull_request_target and checking out arbitrary pull request code.
The important design decision is that the AI output is a review aid. It should not receive write permissions, merge pull requests, deploy applications, or be treated as proof that the code is correct. Tests and human review remain the gate.
The prompt is part of the repository, so it can be reviewed and versioned like code. That makes the behavior of the AI workflow easier to improve over time.
Do not confuse CI with deployment
A common mistake is trying to make the first workflow do everything: run tests, build the project, publish a package, deploy to production, send notifications, and clean up resources.
Start with CI:
- Install dependencies.
- Run static checks.
- Run tests.
- Build the application if the project has a build step.
Add deployment as a separate workflow, usually with environments, approvals, restricted permissions, and production-specific secrets.
A failed test should not be able to deploy anything.
What a good first workflow looks like
A useful baseline should run on pull requests and pushes to the main branch, start from a clean environment, install locked dependencies, test supported runtime versions, cache dependencies without storing secrets, use the smallest practical token permissions, cancel obsolete runs, and keep deployment credentials away from untrusted code.
GitHub Actions does not replace tests, code review, or engineering judgment. It makes those practices repeatable.
That is the difference between automation that merely runs commands and automation that developers can trust.







