Build a CI/CD Pipeline with Azure DevOps
Build a multi-stage CI/CD pipeline that auto-builds and tests a Node.js app.
Introduction
30 Second Summary
Every time a developer pushes code, someone has to check it compiles, passes tests, and is ready to ship. In professional teams, that entire process runs automatically through a system called a CI/CD pipeline.
In this project, you will build your own automated pipeline using Azure DevOps that builds, tests, and packages a Node.js application every time you push code to GitHub.
What You'll Build
A multi-stage Azure DevOps YAML pipeline with a self-hosted build agent that automatically runs build and test stages on every code push.
By the end of this project, you'll have:
- A GitHub repository containing a Node.js application with automated tests.
- A self-hosted build agent running on your Windows machine, registered with Azure DevOps.
- A multi-stage YAML pipeline that automatically builds, tests, and publishes artifacts on every push.
- Secret Mission: Add a deployment stage with manual approval gates that simulates a real production release.
Are there any prerequisites?
You should have basic familiarity with Git (commit, push, pull). No prior CI/CD experience is required.
Build the Sample Node.js Application
Your development environment is ready to go. Now it's time to build the application that your CI/CD pipeline will automatically build and test.
Every pipeline needs something to run against. You'll create a simple Node.js web server with a couple of functions and matching tests. The app itself is deliberately minimal so you can focus on the automation, not the application logic.
In this step, get ready to:
- Initialize a Node.js project and create application files.
- Install a test framework and configure build scripts.
- Run tests locally and push your code to GitHub.
Initialize the project and create application files
Your pipeline needs source code to build. You'll clone your empty GitHub repo, initialize a Node.js project inside it, and create the application files your pipeline will work with.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your empty GitHub repository and move into it by running these commands:
git clone [[YOUR_GITHUB_URL="https://github.com/your-username/nodejs-cicd-app.git"]]
cd nodejs-cicd-app
- Initialize a Node.js project by running:
npm init -y
What does this command do?
The -y flag tells npm to accept all defaults and skip the interactive questionnaire. It creates a package.json file that tracks your project's metadata, scripts, and dependencies.
- Confirm the file was created by running:
ls
You should see package.json in the output.
Now you'll create the application code. This is a simple HTTP server with two helper functions that the pipeline's test stage will verify.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the nodejs-cicd-app folder on your Desktop, then click Open.
- In the Explorer sidebar, click the new file icon (a page with a plus sign) next to your project name.
- Name the file index.js and press Enter.
- Paste the following code into index.js:
const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;
// Returns a greeting message, personalized if a name is provided
function getGreeting(name) {
if (!name) {
return 'Hello, World!';
}
return `Hello, ${name}!`;
}
// Returns the application health status and version
function getStatus() {
return { status: 'healthy', version: '1.0.0' };
}
// Create an HTTP server that responds to requests
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(getStatus()));
} else {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(getGreeting());
}
});
// Only start the server when this file is run directly
if (require.main === module) {
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
}
// Export functions so tests can import them
module.exports = { getGreeting, getStatus };
What does this code do?
This file defines a minimal HTTP server with two testable functions:
- `getGreeting(name)` returns a greeting string. If no name is passed, it defaults to "Hello, World!".
- `getStatus()` returns a JSON object with the app's health and version info.
- The server listens on port 3000 and routes /health requests to getStatus() and everything else to getGreeting().
- The module.exports line lets your test file import these functions directly without starting the server.
- Save index.js with Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;
function getGreeting(name) {
if (!name) {
return 'Hello, World!';
}
return `Hello, ${name}!`;
}
function getStatus() {
return { status: 'healthy', version: '1.0.0' };
}
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(getStatus()));
} else {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(getGreeting());
}
});
if (require.main === module) {
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
}
module.exports = { getGreeting, getStatus };
Now you'll create the test file. Tests are what your pipeline will run automatically to verify the code works after every push.
- In the Explorer sidebar, click the new file icon again.
- Name the file index.test.js and press Enter.
- Paste the following code into index.test.js:
// Import the functions we want to test from index.js
const { getGreeting, getStatus } = require('./index');
// Test suite for the getGreeting function
describe('getGreeting', () => {
test('returns default greeting when no name is provided', () => {
expect(getGreeting()).toBe('Hello, World!');
});
test('returns personalized greeting when name is provided', () => {
expect(getGreeting('Azure')).toBe('Hello, Azure!');
});
});
// Test suite for the getStatus function
describe('getStatus', () => {
test('returns healthy status', () => {
const status = getStatus();
expect(status.status).toBe('healthy');
expect(status.version).toBe('1.0.0');
});
});
What does this code do?
This test file imports getGreeting and getStatus from index.js and runs three checks:
- Calling getGreeting() with no argument returns Hello, World!.
- Calling getGreeting('Azure') returns Hello, Azure!.
- Calling getStatus() returns an object with status: 'healthy' and version: '1.0.0'.
These are the exact tests your pipeline will run in its Test stage later.
- Save index.test.js with Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const { getGreeting, getStatus } = require('./index');
describe('getGreeting', () => {
test('returns default greeting when no name is provided', () => {
expect(getGreeting()).toBe('Hello, World!');
});
test('returns personalized greeting when name is provided', () => {
expect(getGreeting('Azure')).toBe('Hello, Azure!');
});
});
describe('getStatus', () => {
test('returns healthy status', () => {
const status = getStatus();
expect(status.status).toBe('healthy');
expect(status.version).toBe('1.0.0');
});
});
Don't see both files in the Explorer?
Make sure you created the files at the root of the nodejs-cicd-app folder, not inside a subfolder. Both index.js and index.test.js should sit right next to package.json.
Help me check my file structure is correct.
Install Jest and update package scripts
Your tests use Jest, a popular JavaScript testing framework. You need to install it as a development dependency and configure your package.json scripts so the pipeline knows how to run tests and builds.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Install Jest by running:
npm install --save-dev jest
What does --save-dev mean?
The --save-dev flag adds Jest to devDependencies in package.json. Dev dependencies are tools needed during development and CI builds, but not in production.
- You should see output confirming packages were added. A node_modules folder and package-lock.json file now appear in your Explorer sidebar.
Now you need to update package.json with the correct scripts. The pipeline will use these scripts to know how to build and test your app.
- Click on package.json in the Explorer sidebar to open it.
- Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and replace it with the following:
{
"name": "nodejs-cicd-app",
"version": "1.0.0",
"description": "A simple Node.js app for learning CI/CD with Azure DevOps",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "jest",
"build": "echo Build complete"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^29.7.0"
}
}
What are these scripts for?
- `"start": "node index.js"` runs the server locally.
- `"test": "jest"` tells npm test to run Jest, which finds and executes your index.test.js file.
- `"build": "echo Build complete"` is a placeholder build step. Your pipeline will call npm run build during its Build stage.
- Save package.json.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before continuing.
ⓧ I'd like to double check the full code
{
"name": "nodejs-cicd-app",
"version": "1.0.0",
"description": "A simple Node.js app for learning CI/CD with Azure DevOps",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "jest",
"build": "echo Build complete"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^29.7.0"
}
}
Seeing a JSON syntax error?
JSON is strict about commas and brackets. Make sure there's no trailing comma after the last item in any list, and that all curly braces are properly closed. Compare your file against the double-check tab above.
Help me fix a JSON error in my package.json.
Run tests and push to GitHub
Before your pipeline can automate testing, you should confirm the tests pass locally. Then you'll push everything to GitHub so the pipeline has code to work with.
- Run your tests locally by entering this in your terminal:
npm test
What should I see?
You should see Jest output showing 3 passing tests with green checkmarks. It will say something like "3 passed" and "Tests: 3 passed, 3 total".
Tests failing?
Make sure your index.js includes the module.exports = { getGreeting, getStatus } line at the bottom. Without it, the test file can't import the functions.
Also check that your package.json has "test": "jest" in the scripts section, not the default value that was there before.
Help me debug my failing Jest tests.
All three tests pass. Now push your code to the GitHub repository so your pipeline can access it.
- Commit and push all your project files by running these commands:
git add .
git commit -m "Initial commit: Node.js app with tests"
git push origin main
What do these commands do?
- `git add .` stages all files in the project folder for commit.
- `git commit -m` saves a snapshot of your staged files with a descriptive message.
- `git push origin main` uploads your commit to the main branch on GitHub.
- Verify the push worked by opening your nodejs-cicd-app repository on GitHub in your browser.
- You should see index.js, index.test.js, and package.json listed in the repository.
Push rejected or authentication error?
GitHub may prompt you to authenticate. If you see a browser window asking you to sign in, follow the prompts to authorize Git access.
If the push is rejected because the branch doesn't exist, try running git push -u origin main instead. This creates the remote branch and sets up tracking.
Help me push my code to GitHub.
Your application is built, tested, and living on GitHub. Next up, you'll set up a self-hosted build agent on your machine so Azure DevOps has something to run your pipeline on.
Set Up a Self-Hosted Build Agent
Your Node.js app is on GitHub, ready for automation. Now you need something to actually run your pipeline.
In Azure DevOps, that something is called a build agent. A build agent is a machine that listens for pipeline jobs and executes them on your behalf.
You could use a Microsoft-hosted agent, but new organizations must request free parallelism and wait days for approval. Instead, you will turn your own Windows machine into a self-hosted agent that works immediately with unlimited minutes.
In this step, get ready to:
- Download the Azure DevOps agent software from your organization settings.
- Configure the agent with your organization URL and a Personal Access Token.
- Start the agent and verify it appears online in Azure DevOps.
Why a self-hosted agent instead of Microsoft-hosted?
New Azure DevOps organizations no longer receive free Microsoft-hosted parallelism automatically. You must submit a request form and wait 4-5 business days for approval.
A self-hosted agent runs on your own machine and gives you 1 free parallel job with unlimited minutes per month. It works immediately with no waiting period, and it teaches the pattern many enterprises use in production.
Download the agent from Azure DevOps
Azure DevOps provides pre-built agent software you download as a ZIP file. You will grab it from the Default agent pool in your organization settings.
- In your browser, return to your Azure DevOps organization at https://dev.azure.com/{your-org}.
- Click the gear icon in the bottom-left corner to open Organization Settings.
- In the left sidebar under Pipelines, click Agent pools.
- Select the Default pool from the list.
- Click the Agents tab.
- Click New agent.
- On the Get the agent dialog, select Windows.
- Click the Download button to download the agent ZIP file.
What is the Default pool?
The Default pool is where you register self-hosted agents that you set up. When your pipeline specifies pool: Default in its YAML, Azure DevOps routes the job to an available agent in this pool.
- Create a folder at C:\agents and extract the contents of the downloaded ZIP into it.
Why C:\agents?
The agent path must contain no spaces to avoid permission and script issues. C:\agents is the recommended path from Microsoft's documentation.
Don't see the Default pool?
Make sure you navigated to Organization Settings (the gear icon at the bottom-left), not Project Settings. The Default pool is created automatically at the organization level.
Help me find the Default agent pool in my Azure DevOps organization settings.
Configure the agent with your organization URL and PAT
The agent needs to authenticate with your Azure DevOps organization. You will create a Personal Access Token (PAT) for this, then run the interactive configuration script.
First, create a PAT that the agent will use to register itself:
- In Azure DevOps, click your profile icon in the top-right corner.
- Select Personal access tokens from the dropdown menu.
- Click + New Token.
- Enter a name like AgentSetup in the Name field.
- Set the expiration to 30 days (the default is fine for this project).
- Click Show all scopes at the bottom of the dialog to expand the full list.
- Find Agent Pools and check the box for Read & manage. Make sure all other boxes are cleared.
- Click Create.
- Copy the token immediately and save it somewhere safe. It will not be shown again.
Copy your token now
The PAT is only displayed once at creation time. If you close the dialog without copying it, you will need to create a new one. Paste it into a temporary text file or notes app until you finish the next step.
Now configure the agent using an elevated PowerShell window:
- Press the Windows key to open your search bar.
- Type PowerShell.
- Right-click Windows PowerShell and select Run as administrator.
- Navigate to the agent folder by running this command:
cd C:\agents
- Start the configuration wizard by running:
.\config.cmd
The configuration script asks a series of questions. Answer them as follows:
- Enter server URL: type your organization URL in the format https://dev.azure.com/{your-org} and press Enter.
- Enter authentication type: press Enter to accept the default (PAT).
- Enter personal access token: paste the PAT you copied earlier and press Enter.
- Enter agent pool: press Enter to accept the default (Default).
- Enter agent name: press Enter to accept the default (your computer name).
- Enter work folder: press Enter to accept the default (_work).
- Enter run agent as service: type N and press Enter (you will run it interactively for this project).
What do these prompts do?
The server URL tells the agent which Azure DevOps organization to connect to. The PAT proves you have permission to register agents in that organization.
The agent pool determines which pool this agent joins. The PAT is only used during registration. After the agent is configured, it communicates with Azure DevOps using a different mechanism.
You should see a message saying the agent was added successfully.
Seeing a connection or authentication error?
Double-check that your server URL matches the format https://dev.azure.com/{your-org} exactly, with no trailing slash or extra path segments.
Make sure the PAT has the Agent Pools (read, manage) scope selected. If you accidentally cleared it, create a new PAT with the correct scope.
Help me troubleshoot this authentication error with my Azure DevOps agent.
Start the agent and verify it is online
With configuration complete, you can start the agent so it begins listening for pipeline jobs.
- In the same elevated PowerShell window, start the agent by running:
.\run.cmd
You should see output indicating the agent is listening for jobs. Keep this PowerShell window open.
- Switch back to your browser.
- Return to Organization Settings (the gear icon in the bottom-left).
- Click Agent pools in the left sidebar, then select the Default pool.
- Click the Agents tab.
- Verify your agent appears in the list with a green Online status.
What does Online mean?
An Online status means the agent is connected to Azure DevOps and actively listening for pipeline jobs. When you trigger a pipeline targeting the Default pool, this agent will pick up the job and execute it on your machine.
Agent shows as Offline?
Make sure the PowerShell window running .\run.cmd is still open. If you closed it, the agent stops listening. Re-run .\run.cmd from C:\agents in an elevated PowerShell window.
Also try refreshing the Agents page in your browser. It may take a few seconds for the status to update.
Help me get my self-hosted agent online in Azure DevOps.
Your self-hosted agent is now connected and ready to execute pipelines. Next up, you will write a YAML pipeline that tells this agent exactly what to build and test.
Create Your First YAML Pipeline
Your self-hosted agent is online and waiting for work. Now it needs a set of instructions to follow.
In Azure DevOps, those instructions live in a YAML pipeline file. This file defines exactly what happens when code changes land in your repository. In this step, you'll write a multi-stage pipeline that builds your app, then runs your tests.
In this step, get ready to:
- Create a new pipeline in Azure DevOps connected to your GitHub repository.
- Define a Build stage that installs dependencies, builds your app, and publishes an artifact.
- Define a Test stage that runs your unit tests after the build succeeds.
Create a new pipeline in Azure DevOps
Azure DevOps provides a guided wizard that connects your GitHub repository to a new pipeline. The wizard generates a starter YAML file that you'll replace with your own multi-stage configuration.
- In your Azure DevOps project (MyFirstPipeline), click Pipelines in the left sidebar.
- Click New pipeline.
- Select GitHub as your code source.
- Azure DevOps may ask you to authorize GitHub access. Follow the prompts to sign in and grant permission.
- Select your nodejs-cicd-app repository from the list.
- On the Configure step, select Starter pipeline.
You should now see a YAML editor with some default content. You'll replace all of it in the next substep.
What is a YAML pipeline?
A YAML pipeline is a text file (called azure-pipelines.yml) that lives in your repository alongside your code. It defines the automation steps Azure DevOps runs every time code changes.
Because it's a file in your repo, your pipeline configuration is version-controlled just like your application code. You can review changes, roll back mistakes, and track the history of your automation.
Define the Build stage
A pipeline is organized into stages, and each stage contains jobs with steps. Your Build stage will install dependencies, run the build script, and package everything into a build artifact that later stages can use.
- Select all the starter YAML content in the editor and delete it.
- Paste the following YAML into the editor:
trigger:
- main
pool: Default
stages:
- stage: Build
displayName: 'Build Stage'
jobs:
- job: BuildJob
displayName: 'Build Application'
steps:
- script: npm ci
displayName: 'Install dependencies'
- script: npm run build
displayName: 'Build application'
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(Build.SourcesDirectory)'
artifactName: 'app'
displayName: 'Publish build artifact'
What does this code do?
- trigger: - main tells Azure DevOps to run this pipeline whenever code is pushed to the main branch.
- pool: Default routes the pipeline to your self-hosted agent in the Default pool.
- npm ci installs your project's dependencies from the lockfile. This is faster and more reliable than npm install in CI environments.
- npm run build runs the build script defined in your package.json.
- PublishBuildArtifacts@1 packages your source directory into an artifact named app. This makes the build output available to later stages or for download.
You won't save yet. The next chunk adds the Test stage below the Build stage.
Define the Test stage and run the pipeline
The Test stage runs after the Build stage completes successfully. If the build fails, the tests never run. This ensures you only test code that actually compiles.
- Place your cursor at the end of the Build stage (after the last line you just pasted).
- Add the following YAML below the Build stage, keeping the same indentation level as the first - stage: entry:
- stage: Test
displayName: 'Test Stage'
dependsOn: Build
jobs:
- job: TestJob
displayName: 'Run Tests'
steps:
- script: npm ci
displayName: 'Install dependencies'
- script: npm test
displayName: 'Run unit tests'
What does this code do?
- dependsOn: Build creates a dependency between stages. The Test stage waits for the Build stage to finish before it starts.
- npm ci is called again because each stage runs in a fresh workspace. Dependencies from the Build stage are not carried over.
- npm test runs Jest to execute the tests you wrote in index.test.js.
Why does each stage reinstall dependencies?
Each stage starts with a clean workspace. This is a deliberate design choice that prevents hidden dependencies between stages. If Stage B only works because Stage A left files behind, your pipeline is fragile. Reinstalling keeps each stage self-contained and reproducible.
✔️ Awesome, I've got everything!
Great. Double check that the indentation looks correct in the editor before saving.
ⓧ I'd like to double check the full code
Here is the complete azure-pipelines.yml file. Make sure the content in your editor matches this exactly:
trigger:
- main
pool: Default
stages:
- stage: Build
displayName: 'Build Stage'
jobs:
- job: BuildJob
displayName: 'Build Application'
steps:
- script: npm ci
displayName: 'Install dependencies'
- script: npm run build
displayName: 'Build application'
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(Build.SourcesDirectory)'
artifactName: 'app'
displayName: 'Publish build artifact'
- stage: Test
displayName: 'Test Stage'
dependsOn: Build
jobs:
- job: TestJob
displayName: 'Run Tests'
steps:
- script: npm ci
displayName: 'Install dependencies'
- script: npm test
displayName: 'Run unit tests'
- Click Save and run in the top right corner of the editor.
- In the commit dialog that appears, leave the default commit message and branch (main) as-is.
- Click Save and run again to confirm.
Azure DevOps commits the azure-pipelines.yml file to your GitHub repository and immediately starts the pipeline run. You'll see the pipeline stages appear as boxes on screen.
- Watch the Build stage turn green (passed), then the Test stage starts.
- Wait for both stages to show green checkmarks. This confirms your pipeline ran successfully on your self-hosted agent.
- Click on the pipeline run summary. Under Related, you should see 1 published artifact. Click it to see your app artifact listed.
Pipeline stuck or failing?
- Check that your agent is still running. Switch to the PowerShell window where you started .\run.cmd. If it's closed, navigate to C:\agents and run .\run.cmd again.
- If you see a YAML indentation error, go back to the pipeline editor and compare your file against the full code in the double-check tab above. YAML is whitespace-sensitive.
- If the Test stage fails, click into the failed job to read the log output. A common cause is a missing package.json or index.test.js in your repository.
Help me debug my Azure DevOps pipeline run
Your pipeline builds, tests, and packages your app automatically. Next up, you'll add a CI trigger so this happens every time you push code to GitHub.
Add a CI Trigger and Verify Automation
Your pipeline ran successfully in the previous step, but you had to click Run manually. The whole point of Continuous Integration is that the pipeline fires on its own every time you push code.
In this step, you'll confirm that the CI trigger is already active, push a new test case to prove it works, and then review the automated pipeline results.
In this step, get ready to:
- Verify the CI trigger targets the main branch.
- Push a new test case to GitHub and watch the pipeline trigger automatically.
- Review the build logs, test results, and download the published artifact.
Verify the CI trigger in your pipeline YAML
A CI trigger tells Azure Pipelines to start a new run every time a matching branch receives a push. Your azure-pipelines.yml already includes this from Step 4.
- In your code editor, open azure-pipelines.yml and confirm the first few lines look like this:
trigger:
- main
What does this trigger do?
The trigger section lists which branches activate the pipeline. With - main specified, any push to the main branch starts a new pipeline run automatically.
Without this section, you would need to click Run pipeline in the Azure DevOps UI every single time. The trigger removes that manual step entirely.
Add a new test case and push to GitHub
To see the CI trigger in action, you need to push a change. You will add a new test case that verifies the getGreeting function handles an empty string correctly.
- In your code editor, open index.test.js and find the describe('getGreeting') block.
- Add this new test inside the describe('getGreeting') block, below the existing two tests:
test('returns default greeting when empty string is provided', () => {
expect(getGreeting('')).toBe('Hello, World!');
});
What does this test do?
The getGreeting function checks if (!name) before returning the default greeting. An empty string is falsy in JavaScript, so passing '' should return 'Hello, World!' just like calling it with no argument.
This test covers an edge case that the existing tests miss. It also gives you a meaningful code change to trigger the pipeline.
- Save index.test.js.
- Confirm the new test passes locally by running this command in your terminal:
npm test
You should see all 4 tests passing, including your new one.
Tests failing?
Make sure the new test is inside the describe('getGreeting', () => { ... }) block, not outside it. Check that you have a closing }); for the test and that the describe block still has its own closing bracket.
Help me debug my failing tests.
- Commit and push your change to GitHub by running these commands:
git add .
git commit -m "Add edge case test for empty string greeting"
git push origin main
Why does this push matter?
The moment git push completes, GitHub notifies Azure DevOps about the new commit on main. Azure DevOps matches this against your pipeline's trigger section and starts a new run. No button click required.
- Switch to the Azure DevOps browser tab.
- Click Pipelines in the left sidebar.
- You should see a new pipeline run appear within a few seconds, triggered automatically by your push.
Don't see a new run?
Make sure your self-hosted agent is still running. Switch to the PowerShell window where .\run.cmd is active and confirm it shows "Listening for Jobs." If the agent stopped, restart it.
Also confirm your push went to the main branch. Run git branch in your terminal to check which branch you are on.
Help me troubleshoot my CI trigger.
Review the pipeline run results
Your pipeline should complete both the Build and Test stages within a minute or two. Now you will inspect the results to confirm everything passed.
- Click the running (or completed) pipeline run to open its details.
- Click on the Build Stage box in the pipeline visualization.
- Click BuildJob to expand its step logs.
- Click each step (Install dependencies, Build application, Publish build artifact) and review the console output.
What should I see in the build logs?
The Install dependencies step shows npm ci fetching packages. The Build application step outputs "Build complete" from the echo script. The Publish build artifact step confirms the artifact was uploaded to Azure DevOps.
- Click the back arrow to return to the pipeline run overview.
- Click on the Test Stage box, then click TestJob.
- Click Run unit tests to see the Jest output.
You should see all 4 tests passing, including the new edge case test you just added.
- Click the back arrow to return to the pipeline run overview.
- Click the Summary tab at the top of the run details page.
- In the Related section, find and click the published artifact named app.
- Explore the artifact contents. You can download it by clicking the download icon.
What is this artifact?
The published artifact contains your application source code as packaged by the Build stage. In a real-world pipeline, this artifact would be passed to a deployment stage that ships it to a server.
Downloading it confirms that the pipeline successfully stored a build output that downstream stages (or other pipelines) can consume.
Pipeline run failed?
Click the failed stage to see which step errored. Check the console output for the failing step. Common causes include a syntax error in your test file or the agent losing its connection.
If the Test stage failed, compare your index.test.js against the code below to check for typos.
Help me read my pipeline error logs.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.test.js and that all 4 tests passed both locally and in the pipeline.
ⓧ I'd like to double check the full code
Here is what your index.test.js should look like after adding the new test:
const { getGreeting, getStatus } = require('./index');
describe('getGreeting', () => {
test('returns default greeting when no name is provided', () => {
expect(getGreeting()).toBe('Hello, World!');
});
test('returns personalized greeting when name is provided', () => {
expect(getGreeting('Azure')).toBe('Hello, Azure!');
});
test('returns default greeting when empty string is provided', () => {
expect(getGreeting('')).toBe('Hello, World!');
});
});
describe('getStatus', () => {
test('returns healthy status', () => {
const status = getStatus();
expect(status.status).toBe('healthy');
expect(status.version).toBe('1.0.0');
});
});
Your pipeline now fires on every push to main without any manual intervention. That is the core of Continuous Integration. Next up, you will take this further in the Secret Mission by adding a deployment stage with approval gates.
Secret mission
Add a Deployment Stage with Approvals
Your pipeline builds and tests automatically, but what about deploying? In this secret mission, you'll add a Deploy stage with a manual approval gate. The pipeline will pause before deploying, waiting for you to review and approve the release. This is how real production deployments work.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on the Azure DevOps free tier with a self-hosted agent, so there are no ongoing cloud costs.
Resources you used:
- Azure DevOps project (MyFirstPipeline) with pipeline, environment, and agent pool.
- Self-hosted agent installed at C:\agents.
- GitHub repository (nodejs-cicd-app).
- Local project files (nodejs-cicd-app folder on your Desktop).
- Deployed application files at C:\deployed-app.
Keep everything running
No action needed. Choose this if you are still actively building on this pipeline or plan to extend it soon.
- Your self-hosted agent remains registered and online in Azure DevOps.
- Your pipeline triggers automatically whenever you push to the main branch.
- Node.js and Git remain installed on your machine for future projects.
Pause - I'll come back to this later
Shut down the running agent process to free up system resources. Your Azure DevOps project and GitHub repository stay intact for when you return.
- In the PowerShell window where run.cmd is running, press Ctrl+C to stop the agent.
- Your agent shows as Offline in Azure DevOps. You can restart it anytime by navigating back to C:\agents and running .\run.cmd again.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps in order.
- Stop the agent by pressing Ctrl+C in the PowerShell window running run.cmd.
- Remove the agent registration from Azure DevOps by running this command in the C:\agents folder:
.\config.cmd remove
When prompted, choose PAT authentication and enter the same Personal Access Token you used during setup.
- Delete the agent folder and deployed app folder by running:
rmdir /s /q C:\agents
rmdir /s /q C:\deployed-app
- Delete your local project folder by running:
rmdir /s /q %USERPROFILE%\Desktop\nodejs-cicd-app
- Delete your Azure DevOps project by signing in to https://dev.azure.com.
- Select Organization settings.
- Select Projects, check MyFirstPipeline, and click Delete.
- Confirm by typing the project name, then click Delete.
- Delete your GitHub repository by navigating to the nodejs-cicd-app repository on GitHub.
- Click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Confirm by typing the repository name, then complete the deletion.
Nice Work!
Nice Work!
You just built a professional CI/CD pipeline from scratch using Azure DevOps. Every time you push code to GitHub, your pipeline automatically builds, tests, and packages your application without any manual intervention.
You've learned how to:
- Set up a self-hosted build agent on your Windows machine and registered it with Azure DevOps.
- Created a multi-stage YAML pipeline with Build and Test stages that runs automatically on every push to main.
- Published build artifacts and verified the full pipeline hierarchy of triggers, stages, jobs, and steps.
- Added a deployment stage with manual approval gates using Azure DevOps Environments for production-safe releases.
Ready to quiz yourself?