Build a Multi-Stage GitHub Actions Pipeline
Write 3 GitHub Actions workflows covering jobs, matrix builds, and deploys.
Introduction
30 Second Summary
Every team depends on CI/CD pipelines to test and deploy code before it reaches users. Reading the YAML files behind those pipelines and writing them from scratch are two very different skills.
In this project, you will write 3 GitHub Actions workflow files from scratch. Each file introduces progressively advanced YAML syntax, giving you hands-on practice with the keywords you need to write your own workflows.
What You'll Build
A GitHub repository with a small Node.js project and 3 workflow YAML files that cover increasingly advanced GitHub Actions syntax.
By the end of this project, you'll have:
- A multi-job workflow with job dependencies, environment variables, and branch filters.
- A matrix build that tests your code across multiple Node.js versions and operating systems in parallel.
- A manual deploy workflow with custom inputs, conditionals, and secrets.
- Secret Mission: Add a scheduled cron trigger and concurrency controls to your pipeline.
Are there any prerequisites?
This project assumes you have basic Git knowledge (push, pull, branches) and prior experience with a simple CI workflow. You will also need a GitHub account and Node.js installed on your machine.
Before We Start
Before We Start...
Set Up Your Project Repository
Before you can write any GitHub Actions workflows, you need a repository with real code for those workflows to run against. The workflows you build in the next steps will lint, test, and deploy the code you set up here.
In this step, you'll create a GitHub repository, clone it to your machine, and add a small Node.js project with source code, tests, and a lint script.
In this step, get ready to:
- Create a GitHub repository and clone it locally.
- Add source code, test, and lint files to the project.
- Push everything to GitHub with the workflows directory ready.
Create a GitHub repository and clone it
You need a repository on GitHub to host your code and run workflows. You'll create a new public repository, clone it to your Desktop, and initialize it as a Node.js project.
- Sign in to GitHub at github.com.
- Click the + icon in the top-right corner.
- Click New repository.
- Enter actions-pipeline in the Repository name field.
- Select Public for visibility.
- Leave all other settings as their defaults.
- Click Create repository.
GitHub shows your new empty repository page with quick setup instructions.
Why a public repo?
Public repositories get unlimited free GitHub Actions minutes. Private repos have a monthly limit on free-tier minutes, so public keeps things simple for learning.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running this command:
git clone [[REPO_URL="https://github.com/your-username/actions-pipeline.git"]]
Where do I find the clone URL?
On your new repository page, click the green Code button. Copy the HTTPS URL shown in the dropdown.
- Move into your project folder by running this command:
cd actions-pipeline
- Initialize a Node.js project by running this command:
npm init -y
What does this command do?
`npm init -y` creates a package.json file with default values. The -y flag accepts all defaults so you skip the interactive prompts.
- Confirm package.json was created by running:
ls
You should see package.json listed in the output.
Don't see package.json?
- Make sure you're inside the actions-pipeline folder. Run pwd to check your current directory.
- If npm isn't recognized, Node.js might not be installed. Run node --version to check.
- Help me troubleshoot why npm init isn't working.
Add your project files
Your workflows need source code to lint and test. You'll create four files: a main module, a test file, a lint script, and an updated package.json with the right npm scripts.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the actions-pipeline folder on your Desktop, then click Open.
- In the VS Code Explorer sidebar, hover over the ACTIONS-PIPELINE heading and click the New File icon (a page with a plus sign).
- Type index.js and press Enter.
- Copy the following code into index.js:
// Greet a user by name, or throw an error if no name is given
function greet(name) {
if (!name) {
throw new Error('Name is required');
}
return `Hello, ${name}! Welcome to GitHub Actions.`;
}
// Add two numbers together
function add(a, b) {
return a + b;
}
// Export both functions so other files can use them
module.exports = { greet, add };
What does this code do?
- greet(name) returns a welcome message, or throws an error if no name is given.
- add(a, b) returns the sum of two numbers.
- module.exports makes both functions available to other files that require('./index').
- Save index.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
You won't see a visible change yet. The next file, test.js, will use these functions.
- Create another new file by clicking the New File icon in the Explorer sidebar.
- Type test.js and press Enter.
- Copy the following code into test.js:
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { greet, add } = require('./index');
// Test the greet function
describe('greet', () => {
it('should return a greeting with the name', () => {
const result = greet('Learner');
assert.strictEqual(result, 'Hello, Learner! Welcome to GitHub Actions.');
});
it('should throw an error if no name is provided', () => {
assert.throws(() => greet(), { message: 'Name is required' });
});
});
// Test the add function
describe('add', () => {
it('should add two numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
it('should handle negative numbers', () => {
assert.strictEqual(add(-1, 1), 0);
});
});
What does this code do?
This file uses Node.js's built-in test runner (node:test) to test both functions from index.js.
- describe() groups related tests together under a label.
- it() defines a single test case with a description.
- assert.strictEqual() checks that two values are exactly equal.
- assert.throws() checks that a function throws the expected error.
- Save test.js.
You won't see a visible change yet. You need to update package.json with the correct test script before you can run these tests.
The package.json created by npm init -y has placeholder scripts that won't work for your project. You need to replace its contents with the correct test and lint scripts.
- Open package.json in VS Code by clicking on it in the Explorer sidebar.
- Select all the existing content by pressing Cmd+A (macOS) or Ctrl+A (Windows).
- Replace it with the following:
{
"name": "actions-pipeline",
"version": "1.0.0",
"description": "A project for learning GitHub Actions workflow YAML syntax",
"main": "index.js",
"scripts": {
"test": "node --test test.js",
"lint": "node lint-check.js"
}
}
What changed in package.json?
- The test script now runs node --test test.js, which uses Node.js's built-in test runner on your test file.
- The lint script runs node lint-check.js, which is the custom lint script you'll create next.
- Save package.json.
- Switch to your terminal and run your tests by running this command:
npm test
You should see all 4 tests pass, with output like tests 4 and pass 4.
Tests failing or command not found?
- If you see ERR_MODULE_NOT_FOUND, make sure index.js exists and is saved in the same folder as test.js.
- If you see Missing script: "test", make sure you saved package.json with the updated scripts.
- Make sure your terminal is inside the actions-pipeline folder. Run pwd to check.
- Help me debug my failing tests.
Now you need a lint script for your workflows to run. Instead of installing a full linter like ESLint, you'll write a lightweight script that checks for basic issues.
- Create a new file called lint-check.js by clicking the New File icon in the Explorer sidebar.
- Type lint-check.js and press Enter.
- Copy the following code into lint-check.js:
const fs = require('fs');
const path = require('path');
// List the files we want to check
const filesToCheck = ['index.js', 'test.js'];
let hasErrors = false;
// Loop through each file and run checks
filesToCheck.forEach((file) => {
const filePath = path.join(__dirname, file);
// Check if the file exists
if (!fs.existsSync(filePath)) {
console.error(`Missing file: ${file}`);
hasErrors = true;
return;
}
const content = fs.readFileSync(filePath, 'utf8');
// Warn about console.log statements
if (content.includes('console.log')) {
console.warn(`Warning: ${file} contains console.log statements`);
}
// Check that the file isn't empty
if (content.length === 0) {
console.error(`Error: ${file} is empty`);
hasErrors = true;
} else {
console.log(`OK: ${file} passed checks`);
}
});
// Exit with error code if any checks failed
if (hasErrors) {
process.exit(1);
} else {
console.log('All files passed lint checks!');
process.exit(0);
}
What does this script do?
This is a lightweight lint script that checks index.js and test.js for basic issues.
- It checks that each file exists and isn't empty.
- It warns if any file contains console.log statements.
- process.exit(1) exits with an error code if any check fails, which causes the workflow step to fail.
- Save lint-check.js.
- Switch to your terminal and run the lint check by running this command:
npm run lint
You should see OK: index.js passed checks, OK: test.js passed checks, and All files passed lint checks!.
Lint check failing?
- If you see Missing file: index.js or Missing file: test.js, make sure both files are saved in the same folder as lint-check.js.
- If you see Missing script: "lint", check that your package.json has a lint entry in the scripts section.
- Help me debug my lint check script.
Why a custom script instead of ESLint?
ESLint requires configuration files and extra dependencies that would distract from the real goal: learning YAML workflow syntax. This lightweight script gives your workflows something real to run without any setup overhead.
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved before moving on.
ⓧ I'd like to double check the full code
Here are all four files. Compare them with what you have in VS Code.
function greet(name) {
if (!name) {
throw new Error('Name is required');
}
return `Hello, ${name}! Welcome to GitHub Actions.`;
}
function add(a, b) {
return a + b;
}
module.exports = { greet, add };
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { greet, add } = require('./index');
describe('greet', () => {
it('should return a greeting with the name', () => {
const result = greet('Learner');
assert.strictEqual(result, 'Hello, Learner! Welcome to GitHub Actions.');
});
it('should throw an error if no name is provided', () => {
assert.throws(() => greet(), { message: 'Name is required' });
});
});
describe('add', () => {
it('should add two numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
it('should handle negative numbers', () => {
assert.strictEqual(add(-1, 1), 0);
});
});
const fs = require('fs');
const path = require('path');
const filesToCheck = ['index.js', 'test.js'];
let hasErrors = false;
filesToCheck.forEach((file) => {
const filePath = path.join(__dirname, file);
if (!fs.existsSync(filePath)) {
console.error(`Missing file: ${file}`);
hasErrors = true;
return;
}
const content = fs.readFileSync(filePath, 'utf8');
if (content.includes('console.log')) {
console.warn(`Warning: ${file} contains console.log statements`);
}
if (content.length === 0) {
console.error(`Error: ${file} is empty`);
hasErrors = true;
} else {
console.log(`OK: ${file} passed checks`);
}
});
if (hasErrors) {
process.exit(1);
} else {
console.log('All files passed lint checks!');
process.exit(0);
}
{
"name": "actions-pipeline",
"version": "1.0.0",
"description": "A project for learning GitHub Actions workflow YAML syntax",
"main": "index.js",
"scripts": {
"test": "node --test test.js",
"lint": "node lint-check.js"
}
}
Create the workflows directory and push to GitHub
GitHub Actions looks for workflow files in a specific directory: .github/workflows/. You'll create this directory now so it's ready for the next steps.
- In your terminal, create the workflows directory by running this command:
mkdir -p .github/workflows
What does this command do?
mkdir -p creates a directory and any parent directories that don't exist yet. This creates both the .github folder and the workflows folder inside it in one command.
- Confirm the directory structure by running:
ls -la .github/workflows
You should see an empty directory listing. The folder exists and is ready for your workflow files.
Now push everything to GitHub so your remote repository matches your local project.
- Stage all your files, commit, and push by running these commands:
git add .
git commit -m "Initial project setup with source code, tests, and lint script"
git push origin main
What do these commands do?
- git add . stages all new and changed files for the commit.
- git commit -m creates a commit with your message.
- git push origin main uploads your commit to the main branch on GitHub.
Push failed or authentication error?
- If you see an authentication error, GitHub may need you to use a personal access token instead of your password. Go to GitHub Settings > Developer settings > Personal access tokens to create one.
- If the branch name doesn't match, try git branch -M main to rename your default branch to main before pushing.
- Help me troubleshoot my git push.
- Open your browser and go to your repository page on GitHub.
- Refresh the page.
You should see all your files listed: index.js, test.js, lint-check.js, package.json, and the .github/workflows directory.
Your project is set up and your code is live on GitHub. Next up, you'll write your first multi-job workflow that lints and tests this code automatically.
Write Your First Multi-Job Workflow
Your repository is set up with a Node.js project, test suite, and lint checker. Now it's time to put GitHub Actions to work.
In this step, you'll write your first workflow YAML file from scratch. This workflow runs two jobs in sequence: a lint check followed by your test suite. You'll learn how triggers, job dependencies, and environment variables work along the way.
In this step, get ready to:
- Create a workflow file with a trigger and environment configuration.
- Write a lint job and a dependent test job.
- Push to GitHub and verify both jobs run sequentially.
Create the workflow file and define the trigger
Every GitHub Actions workflow lives in a YAML file inside the .github/workflows/ directory. At the top of each file, you define three things: a name, a trigger that decides when the workflow runs, and optionally, environment variables available to every job.
- In VS Code, expand the .github folder in the Explorer sidebar to reveal the workflows folder.
- Right-click the workflows folder and click New File.
- Name the file lint-and-test.yml and press Enter.
- Add the workflow header by pasting this code into the empty file:
name: Lint and Test
on:
push:
branches:
- main
env:
NODE_ENV: test
What does this code do?
- name: Lint and Test gives your workflow a human-readable name that appears in the GitHub Actions tab.
- on: push: branches: - main is the trigger. This workflow only runs when you push commits to the main branch. Pushes to other branches are ignored.
- env: NODE_ENV: test sets a workflow-level environment variable. Every job in this file can read NODE_ENV without defining it themselves.
Why 3 separate workflow files?
You could put everything in one big YAML file, but writing 3 separate files means you practice the full workflow structure (name, on, jobs, steps) three times with increasing complexity.
Each file is self-contained and teaches a distinct set of keywords. By the end, the structure will feel automatic.
You won't see a change yet. The next chunk adds the actual jobs that this workflow runs.
Write the lint and test jobs
A workflow file on its own doesn't do anything. The real work happens inside jobs. Each job runs on its own virtual machine (called a runner) and contains a series of steps.
- In lint-and-test.yml, add the lint job below the env block by pasting this code:
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Run lint check
run: npm run lint
What does this code do?
- jobs: opens the jobs section. Everything indented below it is a job.
- runs-on: ubuntu-latest tells GitHub to run this job on a hosted Ubuntu virtual machine. You don't need to set up your own server.
- actions/checkout@v6 is a pre-built action that clones your repository onto the runner. Without it, the runner has no access to your code.
- actions/setup-node@v6 installs Node.js version 20 on the runner so your scripts can execute.
- run: npm run lint executes the lint script from your package.json. This runs the lint-check.js file you created in Step 1.
You won't see a change yet. The next chunk adds a second job that depends on this one.
The lint job runs on its own, but you want tests to only run after linting passes. The needs keyword creates this dependency.
- Add the test job directly below the lint job. Make sure test: is indented at the same level as lint: (two spaces under jobs:):
test:
name: Run Tests
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Run tests
run: npm test
env:
CI: true
What does this code do?
- needs: lint is the key line. It tells GitHub Actions: do not start the test job until the lint job finishes successfully. If lint fails, test is skipped entirely.
- env: CI: true sets an environment variable at the step level. Unlike the workflow-level env you defined earlier, this one only applies to the Run tests step.
- run: npm test executes your test.js file using the Node.js built-in test runner.
- Save lint-and-test.yml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
YAML looking messy?
YAML is strict about indentation. Every level uses exactly 2 spaces (not tabs). If your file looks off, check that lint: and test: are both indented 2 spaces under jobs:.
Also confirm that steps: is indented 4 spaces from the left, and each - name: entry is indented 6 spaces. Use the double-check tab below to compare your full file.
Help me fix the YAML indentation in my lint-and-test.yml file.
Double check your full file matches before moving on.
✔️ Awesome, I've got everything!
Make sure you've saved lint-and-test.yml before continuing.
ⓧ I'd like to double check the full code
name: Lint and Test
on:
push:
branches:
- main
env:
NODE_ENV: test
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Run lint check
run: npm run lint
test:
name: Run Tests
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Run tests
run: npm test
env:
CI: true
Push your workflow and verify the pipeline
Your workflow file is ready locally. GitHub Actions only picks up workflows once they exist on the remote repository. Time to push your changes and watch the pipeline run.
- Open your terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Make sure you are inside the actions-pipeline folder. If not, navigate to it by running:
cd ~/Desktop/actions-pipeline
- Stage, commit, and push your workflow file by running these commands:
git add .
git commit -m "Add lint-and-test workflow"
git push
Push rejected or authentication error?
If you see a permission or authentication error, make sure you are signed into GitHub in your terminal. You may need to set up a personal access token or SSH key if you haven't already.
Help me fix a git push authentication error for my GitHub repository.
The moment you push, GitHub detects the new workflow file and starts running it. Time to go watch it in action.
- Open your browser and go to your actions-pipeline repository on GitHub.
- Click the Actions tab at the top of the repository page.
- You should see a workflow run labeled Add lint-and-test workflow (matching your commit message). Click on it to open the run details.
Inside the run, you'll see a job graph showing two boxes: Lint Check and Run Tests. An arrow connects them, showing that Run Tests waited for Lint Check to finish before starting.
- Click on the Lint Check job to expand its logs. You should see All files passed lint checks! in the Run lint check step.
- Go back to the workflow run summary and click the Run Tests job. You should see all 4 test cases passing in the Run tests step.
Both jobs should show a green checkmark. The needs keyword is what turned two independent jobs into a sequential pipeline.
What is a runner?
A runner is the virtual machine that executes your job. When you set runs-on: ubuntu-latest, GitHub spins up a fresh Ubuntu machine with 4 CPUs and 16 GB of RAM just for that job.
Each job gets its own runner. That's why both the lint and test jobs need their own actions/checkout and actions/setup-node steps. They don't share files or state.
Workflow run not appearing?
Make sure your push actually went through by checking the commit history on GitHub. If the commit is there but the workflow didn't trigger, confirm that your lint-and-test.yml file is inside .github/workflows/ (not just .github/).
Also check for YAML syntax errors. Click the Actions tab. If you see a yellow warning icon, click it to read the error details.
Help me figure out why my GitHub Actions workflow didn't trigger.
You've got a working multi-job pipeline with job dependencies. Next up, you'll write a matrix workflow that tests your code across multiple Node.js versions and operating systems at the same time.
Write a Matrix Build Workflow
Your lint-and-test.yml workflow is live on GitHub Actions with job dependencies working exactly as designed. Now it's time to go wider.
Real projects need to work across multiple Node.js versions and operating systems. In this step, you'll write a matrix build workflow that tests every combination automatically.
In this step, get ready to:
- Create matrix-test.yml with multiple trigger events.
- Configure a matrix strategy across Node.js versions and operating systems.
- Push and verify 6 parallel jobs in the Actions tab.
Create the workflow file and define triggers
Your first workflow only triggered on push. This workflow introduces a second trigger event: pull_request. Having both means your tests run when code lands on main and when someone opens a pull request targeting main.
- Create a new file at .github/workflows/matrix-test.yml inside your actions-pipeline folder.
- Add the workflow name and trigger configuration by pasting this code:
name: Matrix Test
on:
push:
branches:
- main
pull_request:
branches:
- main
What does this code do?
- name: Matrix Test gives this workflow a readable label in the Actions tab, separate from your first workflow.
- The on block defines two trigger events. push fires when commits land on main. pull_request fires when a PR targets main.
- Both triggers use branches filters so only activity involving main kicks off this workflow.
Why add a pull_request trigger?
In real projects, you want tests to run before code gets merged, not just after. The pull_request trigger catches breaking changes while the PR is still open, so reviewers can see test results before approving.
- Save matrix-test.yml.
You won't see a change yet. The next section adds the job that makes this workflow do something.
Write the test job with matrix strategy
A matrix strategy lets you define dimensions (like Node.js versions and operating systems) and GitHub Actions automatically creates a job for every combination. Instead of writing 6 separate jobs by hand, you define 2 lists and let the matrix do the multiplication.
- In matrix-test.yml, add the following jobs block below the on section:
jobs:
test:
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install --ignore-scripts
- name: Run tests
run: npm test
What does this code do?
- strategy.matrix defines two dimensions: node-version with values [18, 20, 22] and os with values [ubuntu-latest, windows-latest]. GitHub Actions creates one job for every combination, giving you 3 x 2 = 6 jobs.
- runs-on: ${{ matrix.os }} dynamically sets the runner for each job. Some jobs run on Ubuntu, others on Windows.
- fail-fast: false means all 6 jobs run to completion even if one fails. The default (true) would cancel remaining jobs after the first failure.
- name: Node ${{ matrix.node-version }} on ${{ matrix.os }} gives each job a readable label like "Node 20 on ubuntu-latest" in the Actions tab.
- npm install --ignore-scripts installs dependencies without running postinstall scripts. This avoids compatibility issues on Windows runners.
- Save matrix-test.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete matrix-test.yml file. Make sure yours matches exactly.
name: Matrix Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install --ignore-scripts
- name: Run tests
run: npm test
Seeing a YAML syntax error?
- YAML is strict about indentation. Every level must be indented by exactly 2 spaces. Tabs will break the file.
- Make sure jobs: is at the root level (no indentation) and test: is indented 2 spaces under it.
- Check that the ${{ }} expressions are wrapped in quotes if they appear as standalone values. In this file, they're used inline with other text so quotes aren't needed.
Help me fix a YAML indentation error in my matrix-test.yml file.
Push and verify the matrix build
Your matrix workflow is ready locally. Pushing it to main triggers both the new matrix workflow and your existing lint-and-test workflow.
- Push your new workflow file to GitHub by running these commands:
git add .github/workflows/matrix-test.yml
git commit -m "Add matrix test workflow"
git push
- Open your repository on GitHub.
- Click the Actions tab.
- Click on the Matrix Test workflow run (the most recent one).
You should see 6 jobs listed, one for each combination of Node.js version and operating system. The job names look like "Node 18 on ubuntu-latest", "Node 20 on windows-latest", and so on.
What should I see?
All 6 jobs should run in parallel (or close to it) since none of them depend on each other. Compare this to your lint-and-test.yml workflow where the test job had to wait for lint to finish.
Each job shows a green checkmark when it passes. Wait for all 6 to complete before moving on.
- Verify that all 6 jobs show green checkmarks.
- Click on any individual job (like Node 22 on windows-latest) to see its step-by-step log output.
Don't see 6 jobs?
- Make sure you pushed to the main branch. The workflow only triggers on pushes to main.
- Check that the matrix block has both node-version and os keys. If one is missing, GitHub Actions only generates jobs for the remaining dimension.
- If you see the workflow but it failed immediately, click on the failed run to read the error message. It's usually a YAML indentation issue.
My matrix workflow isn't running all 6 jobs on GitHub Actions.
Two workflows down, one to go. Your next workflow introduces manual triggers, custom inputs, conditionals, and secrets. You'll control exactly when and how a deployment runs.
Write a Manual Deploy Workflow
Your matrix workflow is now testing across 6 environments in parallel. That covers automated triggers nicely.
Not every workflow should run automatically. Deployments are a perfect example. You want to choose when to deploy, which environment to target, and whether to do a dry run first. In this step, you'll write a workflow that only runs when you manually trigger it from the GitHub UI.
In this step, get ready to:
- Create a workflow_dispatch trigger with custom inputs for environment selection and dry-run mode.
- Write a deploy job with conditional steps that respond to your inputs.
- Trigger the workflow manually and verify the conditional logic.
Create the deploy workflow file with manual trigger inputs
Your first two workflows triggered automatically on push events. This workflow introduces workflow_dispatch, a trigger that lets you start the workflow manually from the GitHub Actions tab.
You can also define custom inputs that the workflow prompts you to fill in before each run.
- Create a new file called deploy.yml inside your .github/workflows/ folder.
- Add the workflow name, run-name, and trigger configuration by pasting this code:
name: Deploy
run-name: Deploy to ${{ inputs.environment }} by @${{ github.actor }}
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
dry_run:
description: 'Perform a dry run (no actual deploy)'
required: true
type: boolean
default: true
What does this code do?
- name: Deploy sets the workflow name that appears in the Actions tab.
- run-name customizes the label for each individual run. It uses expressions to include the selected environment and the person who triggered it.
- workflow_dispatch makes this a manually triggered workflow. Instead of running on every push, it waits for you to click "Run workflow" in the GitHub UI.
- inputs defines two fields that appear when you trigger the workflow. environment is a choice type with two options. dry_run is a boolean that defaults to true.
Why use workflow_dispatch instead of push?
Deployments should be deliberate. Automatically deploying on every push can send broken code to production before anyone reviews it.
workflow_dispatch gives you a manual gate where you choose the environment and confirm before anything runs.
- Save deploy.yml.
You won't see a change in the Actions tab yet. The workflow needs a jobs section before GitHub considers it valid.
Write the deploy job with conditional steps
Before writing the job, you need to create a repository secret in GitHub. The deploy workflow references a secret called DEPLOY_TOKEN, so it needs to exist before the workflow runs.
- Open your actions-pipeline repository on GitHub.
- Click Settings in the top menu bar.
- In the left sidebar, click Secrets and variables, then click Actions.
- Click New repository secret.
- Enter DEPLOY_TOKEN in the Name field.
- Enter my-deploy-token-123 in the Secret field.
- Click Add secret.
What is a repository secret?
A repository secret is an encrypted value that your workflows can access at runtime. GitHub never exposes the actual value in logs or to forks.
This is how you safely pass credentials like API keys or deploy tokens into your workflow without hardcoding them. The placeholder value here is fine because you're just learning the pattern.
Now back in your editor, you'll add the deploy job to deploy.yml.
- In deploy.yml, add the following code below the dry_run input block:
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Print deploy configuration
run: |
echo "Environment: ${{ inputs.environment }}"
echo "Dry run: ${{ inputs.dry_run }}"
echo "Triggered by: ${{ github.actor }}"
echo "Commit SHA: ${{ github.sha }}"
What does this code do?
- jobs.deploy defines a single job. The job name uses ${{ inputs.environment }} so each run displays the target environment.
- The first step checks out your code using actions/checkout@v6.
- The "Print deploy configuration" step echoes four values. The inputs context accesses your custom inputs. The github context provides built-in metadata like who triggered the run and the commit SHA.
- Still in deploy.yml, add the conditional deployment step below the "Print deploy configuration" step:
- name: Run deployment
if: ${{ inputs.dry_run == false }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "Deploying to ${{ inputs.environment }}..."
echo "Using deploy token: [REDACTED]"
echo "Deployment to ${{ inputs.environment }} complete!"
What does this code do?
- if: ${{ inputs.dry_run == false }} is a conditional. This step only runs when dry-run mode is turned off. When dry_run is true, GitHub skips this step entirely.
- env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} maps your repository secret into an environment variable for this step. The secret value never appears in logs.
- The inputs context preserves the boolean type from dry_run, so comparing it to false works directly. The older github.event.inputs context converts everything to strings, which is why inputs is the recommended approach.
- Save deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved deploy.yml before moving on.
ⓧ I'd like to double check the full code
name: Deploy
run-name: Deploy to ${{ inputs.environment }} by @${{ github.actor }}
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
dry_run:
description: 'Perform a dry run (no actual deploy)'
required: true
type: boolean
default: true
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Print deploy configuration
run: |
echo "Environment: ${{ inputs.environment }}"
echo "Dry run: ${{ inputs.dry_run }}"
echo "Triggered by: ${{ github.actor }}"
echo "Commit SHA: ${{ github.sha }}"
- name: Run deployment
if: ${{ inputs.dry_run == false }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "Deploying to ${{ inputs.environment }}..."
echo "Using deploy token: [REDACTED]"
echo "Deployment to ${{ inputs.environment }} complete!"
Seeing a YAML syntax error?
- YAML is strict about indentation. Every level must use exactly 2 spaces, not tabs. Check that jobs aligns with on and name at the top level.
- Make sure the if line is indented to the same level as name, env, and run inside the step.
- Help me fix a YAML syntax error in my deploy.yml workflow file.
Push and trigger the workflow manually
The workflow_dispatch trigger only responds to manual triggers when the workflow file exists on the default branch. You need to push deploy.yml to main first.
- Push your changes to GitHub by running these commands:
git add .
git commit -m "Add deploy workflow"
git push
- Open your actions-pipeline repository on GitHub.
- Click the Actions tab.
You should see Deploy listed in the left sidebar alongside your other two workflows.
Don't see the Deploy workflow?
- Make sure you pushed to the main branch. workflow_dispatch only works when the file is on the default branch.
- Check that deploy.yml is inside the .github/workflows/ folder, not somewhere else in the repo.
- My Deploy workflow isn't showing up in the GitHub Actions tab after pushing.
Time to trigger your first dry run.
- Click Deploy in the left sidebar.
- Click the Run workflow dropdown button on the right side of the page.
- Leave Target environment set to staging.
- Leave Perform a dry run checked (set to true).
- Click the green Run workflow button.
Wait for the run to complete. This should take about 10-20 seconds.
- Click into the completed run, then click the Deploy to staging job to see the step details.
You should see a checkmark next to "Print deploy configuration" and a skip icon next to "Run deployment". The conditional blocked the deploy step because dry_run was true.
Now trigger the workflow again, this time with dry-run mode off.
- Go back to the Deploy workflow page.
- Click Run workflow again.
- Set Target environment to production.
- Uncheck Perform a dry run (set it to false).
- Click the green Run workflow button.
This time, both steps run. The "Run deployment" step executes because dry_run is false.
- Click into the completed run and verify that both "Print deploy configuration" and "Run deployment" show checkmarks.
What about the run-name?
Look at the run titles on the Deploy workflow page. Instead of a generic commit message, each run displays something like "Deploy to staging by @yourusername". The run-name expression you wrote earlier makes each run instantly identifiable.
Both steps ran even with dry_run on?
- Make sure your if condition says inputs.dry_run == false (not == true). The step should run only when dry_run is false.
- Check that the if line is inside the "Run deployment" step block, not at the job level.
- My deploy workflow if conditional isn't working correctly. Both steps run regardless of the dry_run input.
Secret mission
Add a Scheduled Trigger and Concurrency Controls
Your workflows run on push events and manual triggers. Real-world pipelines also need scheduled runs, concurrency controls, and least-privilege permissions. Can you add these production-grade features to your existing workflows?
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses GitHub-hosted runners on a public repository, so there are no ongoing costs.
Resources you used:
- GitHub repository with 3 workflow files (lint-and-test.yml, matrix-test.yml, deploy.yml).
- DEPLOY_TOKEN repository secret.
- Local project folder (actions-pipeline) on your Desktop.
- Node.js project files (index.js, test.js, lint-check.js, package.json).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your workflows or want to use this repo as a reference.
- Your GitHub repository stays live with all 3 workflows.
- Workflows will continue to trigger on pushes to main.
- Your local project folder stays on your Desktop for easy access.
Pause - I'll come back to this later
Disable your workflows so they stop running on every push, but keep all your files and repo intact for later.
- Go to your repository on GitHub and click the Actions tab.
- Select each workflow from the left sidebar.
- Click the ... menu (top right of the workflow page) and select Disable workflow.
- Repeat for all 3 workflows (Lint and Test, Matrix Test, Deploy).
- You can re-enable any workflow later from the same menu.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the GitHub repository by going to Settings at the bottom of your repo page.
- Scroll down to the Danger Zone section and click Delete this repository.
- Follow the confirmation prompts to complete the deletion.
- Delete the local actions-pipeline folder from your Desktop.
Mission accomplished
That's a Wrap!
Nice work! You just wrote 3 GitHub Actions workflow files from scratch. You now have hands-on fluency with the YAML syntax that powers CI/CD pipelines on GitHub.
You've learned how to:
- Build a multi-job pipeline where a lint job must pass before test runs, using job dependencies, branch filters, and environment variables.
- Create a matrix build that tests across 3 Node.js versions and 2 operating systems in parallel, producing 6 jobs from a single configuration.
- Write a manual deploy workflow with custom inputs, conditionals, and secrets that you trigger on demand from the Actions tab.
- Secret Mission: Added scheduled triggers with cron syntax, concurrency controls, and permissions to harden your pipelines.
Ready to quiz yourself?