Our workflow from the previous article already runs, but it's still pretty basic: one job, one Node version, and no concept yet of storing credentials safely. In this article we upgrade three things: matrix builds, GitHub Secrets, and a separate build job that produces an artifact.
Matrix Build — Testing Against Multiple Node Versions at Once
Sometimes we need to make sure our code works across several Node versions — for example, because part of the team is still on Node 18 while others have moved to Node 20. Instead of manually creating separate jobs, GitHub Actions has a feature called a matrix.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
With strategy.matrix, this single job definition automatically gets "cloned" into two jobs that run in parallel: one on Node 18, one on Node 20. If either one fails, we immediately know which Node version has the problem.
One important note: since the test job is now split into two separate checks, if you already set up branch protection in the previous article, go back to Settings → Branches and update it so both status checks (test (18) and test (20)) are required to pass.
GitHub Secrets — Storing Credentials Safely
Now imagine you need an API key to connect to an external service — a payment gateway, an email service, anything. Never hardcode credentials in your code or commit them to the repo, even if the repo is private. The solution: GitHub Secrets.
The steps:
DEMO_API_KEY, value: any value for the demo (e.g. sk_demo_12345).This secret is encrypted, never appears in your code, and can only be accessed from inside a workflow through the secrets context.
Add a new step to the build job (covered in the next section):
- name: Demo of using a secret (with automatic masking)
env:
DEMO_API_KEY: ${{ secrets.DEMO_API_KEY }}
run: |
echo "Trying to print it directly (GitHub will auto-mask this): $DEMO_API_KEY"
echo "DEMO_API_KEY is set, length: ${#DEMO_API_KEY} characters"
Pay close attention to the resulting log: even though we try to echo the secret's value directly, GitHub automatically replaces its display with *** in the log. This is a built-in security feature so secrets don't leak through logs, even if a developer accidentally prints them.
But don't rely on this masking as your only protection. Best practice: avoid printing secrets at all. A safer way to just verify a secret is set is to check its character length, like in the example above.
The build Job — Separating Test from Packaging with needs
Now let's separate concerns. The test job focuses on making sure the code is correct. A new job, build, focuses on preparing a deploy-ready package — this is what becomes the "artifact" whose concept was already covered in the pipeline anatomy article.
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install production dependencies
run: npm ci --omit=dev
- name: Demo of using a secret (with automatic masking)
env:
DEMO_API_KEY: ${{ secrets.DEMO_API_KEY }}
run: |
echo "DEMO_API_KEY is set, length: ${#DEMO_API_KEY} characters"
- name: Package the app into an artifact
run: zip -r release.zip . -x "tests/*" -x ".git/*" -x "*.test.js" -x "coverage/*"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: task-tracker-api-build
path: release.zip
retention-days: 7
Key points to understand:
needs: test — the build job only starts once ALL jobs in the test matrix succeed. This is what makes jobs run sequentially, unlike the parallel default.npm ci --omit=dev — for packaging, we don't need dev dependencies like Jest, so we only install what's needed at runtime.zip -r release.zip ... — creates a single compressed file containing the deploy-ready code, excluding files we don't need (tests, coverage, .git).actions/upload-artifact@v4 — uploads release.zip so it can be downloaded from the run page, or used by another job later on (we'll reuse this concept when we get to deployment).Once the run finishes, scroll to the bottom of the run page and you'll see the task-tracker-api-build artifact, downloadable directly. Try downloading and extracting it — the contents are exactly our deploy-ready app code, with no test files.
Why Does This Matter?
Why bother separating build from test, and using an artifact? Because once code is built once and passes tests, we don't need to rebuild from scratch every time we deploy. The same, already-tested artifact is what we carry all the way to production — the "build once, deploy everywhere" principle. This also prevents the classic "it worked in staging but behaves differently in production" case, because the artifact is identical.
Summary
build job that produces an artifact via needs, so the ordering is correct.