Deploy a Serverless Release Gate
Build, test, and deploy a serverless release-readiness API with CI/CD.
Introduction
30 Second Summary
A release can look finished while one failed check sits unnoticed. That gap can turn a routine deployment into an avoidable outage.
In this project, you will build DeployGate, an HTTP API that decides whether a software release is ready to ship. A GitHub Actions pipeline will run automated tests before deploying passing code to Cloudflare Workers.
What You'll Build
You will watch a push to main pass automated tests before becoming a live API that reports whether a release is ready.
By the end of this project, you'll have:
- A health endpoint you can call to see healthy from the local service or its public workers.dev URL.
- A release decision that turns three checks into ready, blocked, or a validation error with an appropriate HTTP status code.
- A tested delivery pipeline that runs four tests before a push to main reaches Cloudflare Workers.
- Secret Mission: Add a post-deployment smoke test that checks /health before the pipeline reports success.
Are there any prerequisites?
You need a Mac running macOS 13.5 or newer. You also need a GitHub account plus an existing code editor.
The guide includes setup for Node.js 24.21.0 LTS, Git, Wrangler 4.143.0, plus a Cloudflare account on Workers Free.
Before We Start
Before the hands-on work begins, take a moment to commit to DeployGate's release contract. The API protects production delivery by blocking any release with missing or failed test, security, or approval evidence.
Set Up the Delivery Toolchain
Your release contract defines which evidence DeployGate must require before production delivery. Local environment drift can still hide failures before those rules reach the pipeline.
This step aligns Node.js with the project runtime. You will confirm Git inside macOS Terminal. You will also pin Wrangler for later deployment to Cloudflare Workers.
In this step, get ready to:
- Confirm that your Mac supports the required command-line tools.
- Prepare your Cloudflare account on Workers Free.
- Create the local DeployGate project with its pinned Wrangler dependency.
Verify local compatibility
Wrangler requires macOS 13.5 or newer. Checking the operating system first prevents compatibility problems from looking like project failures.
- Choose the Apple menu in the upper-left corner of your screen.
- Select About This Mac.
You will see the installed macOS name plus its version number.
✔️ I see the required macOS version
Your Mac supports the pinned Wrangler release. You can continue with the command-line checks.
ⓧ I see an older macOS version
Your current macOS version does not meet Wrangler's minimum requirement. Update macOS before continuing.
- Follow Apple's macOS guidance to identify a compatible update for your Mac.
- Complete the macOS update.
- Return to About This Mac.
- Confirm that the displayed version is 13.5 or newer.
Terminal provides one place to check every local command. Git may already be available through Apple's Command Line Tools.
- Press Cmd+Space to open Spotlight.
You will see the Spotlight search field.
- Type Terminal into the search field.
- Press Return to open Terminal.
You will see a Terminal window with a command prompt.
- Check whether Git is available by running this command:
git --version
What does this command check?
The command asks Git to print its installed version. A version number confirms that Terminal can access Git.
✔️ I see a Git version
Git is ready. Your Mac can record the project's history when you build the delivery pipeline.
ⓧ Git is unavailable
Apple supplies Git through its Command Line Tools. The installation command opens Apple's installer in a separate window.
- Start the Command Line Tools installer by running this command:
xcode-select --install
What does this command do?
The command asks macOS to install Apple's developer command-line tools. That package makes Git available in Terminal.
The installer can sit quietly while macOS adds the tools. That pause is normal.
- Complete the installation in the window that opens.
- Return to Terminal after the installation finishes.
- Retry the Git check by running this command:
git --version
What does the retry prove?
A Git version confirms that Apple's installer added the command successfully.
Git still unavailable?
- Close Terminal after the installer finishes.
- Reopen Terminal through Spotlight.
- Use help with the Git installation if the command remains unavailable.
Node.js runs the local development commands plus the automated tests. The exact v24.21.0 LTS release keeps your Mac aligned with the workflow runtime used later.
- Check Node.js plus its bundled npm command by running:
node -v
npm -v
What do these checks prove?
- The first command prints the active Node.js version.
- The second command confirms that npm is available for installing project dependencies.
✔️ I see the required Node.js version
Node.js is on the required release. An npm version confirms that its package manager is ready too.
ⓧ I see an older Node.js version
The active Node.js release does not match the project pin. The official macOS package installs the required runtime plus npm.
- Visit the official Node.js download page.
- Download node-v24.21.0.pkg for macOS.
- Open the downloaded installer package.
- Complete the installer prompts.
- Return to Terminal after the installation finishes.
- Confirm the updated runtime by running:
node -v
npm -v
What does the second check prove?
Node.js should now print v24.21.0. An npm version confirms that the package manager was installed with it.
Still seeing the older version?
- Close Terminal after the installer finishes.
- Reopen Terminal through Spotlight.
- Use help with the active Node.js version if the older release remains active.
ⓧ The Node.js command is unavailable
Node.js is not installed yet. The official macOS package includes the required runtime plus npm.
- Visit the official Node.js download page.
- Download node-v24.21.0.pkg for macOS.
- Open the downloaded installer package.
- Complete the installer prompts.
- Return to Terminal after the installation finishes.
- Confirm the new runtime by running:
node -v
npm -v
What does the new output prove?
The Node.js output confirms that the runtime is available. The npm output confirms that you can install the project's pinned dependency.
Node.js still unavailable?
- Confirm that the macOS installer completed successfully.
- Restart Terminal before repeating the checks.
- Use help with the Node.js installation path if the command remains unavailable.
Prepare your Cloudflare account
Cloudflare provides the serverless environment that hosts DeployGate later. This step prepares account access only. It creates no Worker.
Account setup can feel like a billing commitment. Workers Free keeps this project's planned usage at $0.
✔️ I already have a Cloudflare account
- Visit the official Cloudflare dashboard.
- Choose your existing sign-in option.
- Complete the sign-in process.
You will see the dashboard for your Cloudflare account. Keep the account on Workers Free.
ⓧ I need to create a Cloudflare account
- Visit the official Cloudflare sign-up page.
- Enter your email address in the Email field.
- Enter a strong password in the Password field.
- Select Create Account.
- Verify your email address through Cloudflare's message.
- Return to the Cloudflare dashboard.
- Select Workers Free if Cloudflare asks you to choose a Workers plan.
You will see your Cloudflare account in the dashboard. No Worker has been created.
Cannot reach the dashboard?
- Check your inbox for Cloudflare's verification message.
- Check your spam folder if the message is missing.
- Use help with Cloudflare account verification if the dashboard remains inaccessible.
Your dashboard access confirms that the cloud account is ready. The actual Worker deployment happens later.
Build the local project foundation
DeployGate needs a predictable directory structure before you add its API. The configuration files also pin the commands used for development plus testing plus deployment plus cleanup.
- Press Cmd+Space to open Spotlight.
You will see the Spotlight search field.
- Type the name of your existing code editor into Spotlight.
- Press Return to open the editor.
Your code editor is now ready to open a local folder.
- Use your editor's folder-opening control to display the Desktop.
- Use the folder picker's new-folder control to create deploygate-api on the Desktop.
You will see the new deploygate-api folder in the picker.
- Select the deploygate-api folder.
- Confirm the folder choice in your editor.
Your editor will show the empty deploygate-api folder in its file sidebar.
- Create src inside deploygate-api with the sidebar's new-folder control.
- Create test inside deploygate-api with the same control.
You will see src plus test in the project sidebar.
- Create .github inside deploygate-api with the sidebar's new-folder control.
- Create workflows inside .github with the same control.
Your project structure now contains src plus test plus .github/workflows.
Cannot see the hidden folder?
- Confirm that .github begins with a period.
- Expand .github in your editor's file sidebar.
- Use help with the hidden project folder if it remains hidden.
The package.json file defines the project's npm scripts. It also pins the exact Wrangler dependency used by each installation.
- Create package.json inside deploygate-api with your editor's new-file control.
- Paste this configuration into package.json:
{
"name": "deploygate-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"test": "node --test",
"login": "wrangler login",
"deploy": "wrangler deploy",
"cleanup": "wrangler delete"
},
"devDependencies": {
"wrangler": "4.143.0"
}
}
What does this configuration define?
- The package metadata identifies the project as deploygate-api.
- The five scripts provide consistent entry points for each project task.
- The development dependency pins Wrangler to 4.143.0.
- Save package.json.
- Confirm that package.json appears in the deploygate-api file sidebar.
Seeing a JSON warning?
- Check that every property name uses double quotes.
- Confirm that the final dependency entry has no trailing comma.
- Use help with the package configuration if the warning remains.
npm reads package.json to install Wrangler locally. It records the resolved dependency tree in package-lock.json.
- Switch back to Terminal from earlier.
- Move into the Desktop's deploygate-api folder by running this command:
cd ~/Desktop/deploygate-api
What does this command do?
The command changes Terminal's current folder. The path points directly to the project on your Desktop.
The installation may take a moment while npm downloads the dependency. Terminal continues printing progress during the process.
- Install the pinned project dependency by running:
npm install
What does this command create?
npm installs Wrangler inside node_modules. It also creates package-lock.json for reproducible dependency installation.
- Switch back to your code editor after the installation completes.
You will see node_modules plus package-lock.json in the file sidebar. That visible change confirms that npm installed the local dependency.
Did the dependency installation fail?
- Confirm that Terminal is inside ~/Desktop/deploygate-api.
- Check that package.json is saved.
- Use help with the npm installation if the dependency still does not install.
The .gitignore file keeps downloaded dependencies out of version control. The lockfile remains available for Git because it records the reproducible installation.
- Create .gitignore inside deploygate-api with your editor's new-file control.
- Paste this ignore rule into .gitignore:
node_modules/
What does this rule protect?
Git will ignore the downloaded node_modules directory. The project can track its dependency definition plus lockfile without storing every installed package.
- Save .gitignore.
- Confirm that the file contains only node_modules/.
Cannot see the ignore file?
- Check that the filename starts with a period.
- Confirm that your editor did not append another extension.
- Use help with the ignore file if the file remains missing.
The wrangler.jsonc file tells Wrangler which Worker to run later. It names the future entry file without creating a cloud resource.
- Create wrangler.jsonc inside deploygate-api with your editor's new-file control.
- Paste this configuration into wrangler.jsonc:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "deploygate-api",
"main": "src/index.js",
"compatibility_date": "2026-09-29",
"workers_dev": true
}
What does this Worker configuration define?
- The schema path lets your editor validate supported Wrangler settings.
- The Worker uses the name deploygate-api.
- The future entry point is src/index.js.
- The compatibility date fixes runtime behavior to 2026-09-29.
- The workers_dev setting enables a Workers development URL after deployment.
- Save wrangler.jsonc.
- Confirm that your editor shows no JSON syntax warning.
Seeing a configuration warning?
- Confirm that the schema path starts with ./node_modules.
- Check that the compatibility date contains two hyphens.
- Use help with the Wrangler configuration if the warning remains.
✔️ Awesome, I've got everything!
Your three configuration files are ready. Save every open file before the final toolchain check.
ⓧ I'd like to double check the full code
Compare each file with the complete versions below. Every filename plus value should match exactly.
node_modules/
{
"name": "deploygate-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"test": "node --test",
"login": "wrangler login",
"deploy": "wrangler deploy",
"cleanup": "wrangler delete"
},
"devDependencies": {
"wrangler": "4.143.0"
}
}
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "deploygate-api",
"main": "src/index.js",
"compatibility_date": "2026-09-29",
"workers_dev": true
}
Before you run the final check, which two commands do you expect to print the project's exact pinned versions?
- Switch back to Terminal from earlier.
- Verify the complete local toolchain by running:
node -v
npm -v
git --version
npx wrangler --version
What does the final check prove?
- The Node.js check confirms the pinned runtime.
- The npm check confirms that the package manager is available.
- The Git check confirms that version control is available.
- The Wrangler check runs the dependency installed inside deploygate-api.
You will see Node.js v24.21.0 plus an npm version. You will also see a Git version plus Wrangler 4.143.0.
Your local delivery foundation is ready. Next up, you will build the first Worker request path and watch an unsafe release decision expose the gap in its rules.
Build the First Release Gate
Your local toolchain is ready. DeployGate still cannot answer a release request.
In this step, you will create a local HTTP API in a Cloudflare Worker. You will confirm its health route before probing how it handles an empty release request.
In this step, get ready to:
- Create the first Worker handler with two request routes.
- Run the Worker through the local development runtime.
- Probe the release route with an empty request body.
Create the first Worker handler
A Worker fetch handler is the entry point for every request. It uses the request method plus URL path to choose a response.
- Select src in your editor's file sidebar.
- Create index.js inside src with your editor's new-file control.
- Build the two routes by pasting this code into src/index.js:
export default {
async fetch(request) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return Response.json({
service: "deploygate-api",
status: "healthy",
});
}
if (request.method === "POST" && url.pathname === "/evaluate") {
return Response.json({
status: "ready",
failedChecks: [],
});
}
return Response.json({ error: "Route not found" }, { status: 404 });
},
};
What does this handler do?
- The fetch(request) handler receives every incoming request.
- The url identifier holds the parsed request URL.
- The /health branch reports the service name plus its health status.
- The /evaluate branch returns the first release-decision shape.
- The final response gives unknown routes an HTTP 404 status.
- Save src/index.js.
- Confirm index.js appears under src in your editor's file sidebar.
Seeing a syntax warning?
- Check that every opening brace has a matching closing brace.
- Compare the commas after each response property with the code above.
- Help me check the syntax in my Worker handler.
Prove the health route locally
Wrangler reads wrangler.jsonc to launch your Worker on your Mac. The development runtime keeps listening while you send requests from another terminal tab.
- Switch back to the macOS Terminal tab from earlier.
- Launch the Worker through the configured development script by running:
npm run dev
What does this command do?
The dev script launches the local Wrangler development server. Wrangler loads the entry point from wrangler.jsonc.
- Keep this Terminal tab running.
- Confirm the terminal reports a local service at http://localhost:8787.
Your Worker is live on your Mac. Every request to that local address now reaches src/index.js.
- Press Cmd+T in Terminal to create a second tab.
Before you send the first request, what should a healthy service report about itself?
- Check the health route by running:
curl http://localhost:8787/health
What does this request check?
The curl command sends a GET request to the local health route. A response proves that the runtime reached your handler.
You will see {"service":"deploygate-api","status":"healthy"}. Your first API route now responds through the local Worker runtime.
Health request not responding?
- Confirm the first Terminal tab still shows the development server running.
- Check that the request uses port 8787 plus the exact /health path.
- Help me troubleshoot my local Worker health request.
Probe an empty release request
The health response proves that requests reach your Worker. The release route now needs a body with no release checks as its first test.
Before you send it, do you think the current handler can distinguish an empty release from a checked release?
- Send an empty JSON object to the release route by running:
curl --json '{}' http://localhost:8787/evaluate
Why is this response unsafe?
The --json option sends {} as a JSON request body. The current handler returns {"status":"ready","failedChecks":[]} without inspecting that body.
DeployGate has approved a release with no test result. It also has no security-scan result or approval evidence.
You have exposed the gate's first dangerous assumption. This result proves the route works while revealing the control gap that must be closed.
Not seeing the naive ready response?
- Confirm the command includes the exact --json option plus the {} body.
- Check that src/index.js contains the /evaluate route from the code above.
- Help me troubleshoot my empty release evaluation request.
✔️ Awesome, I've got everything!
Your src/index.js file is saved. Keep the local Wrangler runtime running for the next step.
ⓧ I'd like to double check the full code
Compare your complete src/index.js file with this version.
export default {
async fetch(request) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return Response.json({
service: "deploygate-api",
status: "healthy",
});
}
if (request.method === "POST" && url.pathname === "/evaluate") {
return Response.json({
status: "ready",
failedChecks: [],
});
}
return Response.json({ error: "Route not found" }, { status: 404 });
},
};
What should match?
Your exported handler should contain the health route plus the naive evaluation route. The final response should return HTTP 404 for every unknown route.
Your API now answers local requests. Next, you will replace its unsafe assumption with explicit release validation.
Make Unsafe Releases Fail
Your local API now runs through Cloudflare Workers. However, an empty JSON object still receives a ready decision.
This step adds input validation before any release can be approved. Each outcome also receives an explicit HTTP status code that callers can act on.
In this step, get ready to:
- Create a reusable evaluator for the three required release checks.
- Connect the evaluator to the Worker request handler.
- Verify the validation error, blocked release, and ready release paths.
Create the release evaluator
A release decision needs a clear contract. DeployGate requires boolean results for tests, the security scan, and approval before it calculates readiness.
- Switch back to the code editor from the previous step.
- Select the src folder in the editor's file sidebar.
- Click the New File control in the file sidebar.
- Type evaluate.js as the file name.
- Press Enter to create the file.
- Define the required release checks by adding this code to src/evaluate.js:
const REQUIRED_CHECKS = [
"testsPassed",
"securityScanPassed",
"approvalReceived",
];
What Do These Checks Represent?
- The REQUIRED_CHECKS array gives the evaluator one source of truth for required evidence.
- The testsPassed value represents the automated test result.
- The securityScanPassed value represents the security scan result.
- The approvalReceived value represents the release approval decision.
- Add the evaluator below REQUIRED_CHECKS by pasting this code:
export function evaluateRelease(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
return {
valid: false,
errors: ["Body must be a JSON object"],
};
}
const errors = REQUIRED_CHECKS.filter(
(check) => typeof input[check] !== "boolean",
).map((check) => `${check} must be a boolean`);
if (errors.length > 0) {
return {
valid: false,
errors,
};
}
const failedChecks = REQUIRED_CHECKS.filter((check) => !input[check]);
return {
valid: true,
ready: failedChecks.length === 0,
failedChecks,
};
}
How Does the Evaluator Decide?
- The first condition rejects values that are not JSON objects.
- The errors array identifies missing checks or values that are not boolean.
- The failedChecks array records every valid check set to false.
- The ready value becomes true only when failedChecks is empty.
- Save src/evaluate.js.
- Confirm that evaluate.js now appears beneath the src folder in the editor's file sidebar.
Does the Evaluator Look Incomplete?
- Check that evaluateRelease appears after the closing bracket of REQUIRED_CHECKS.
- Check that the final closing brace belongs to the evaluateRelease function.
- Help me compare my evaluator with the required structure.
✔️ Awesome, I've got everything!
Your evaluator now has one list of required checks and one function that decides whether the input is valid.
ⓧ I'd like to double check the full code
Compare your saved src/evaluate.js file with this complete version.
const REQUIRED_CHECKS = [
"testsPassed",
"securityScanPassed",
"approvalReceived",
];
export function evaluateRelease(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
return {
valid: false,
errors: ["Body must be a JSON object"],
};
}
const errors = REQUIRED_CHECKS.filter(
(check) => typeof input[check] !== "boolean",
).map((check) => `${check} must be a boolean`);
if (errors.length > 0) {
return {
valid: false,
errors,
};
}
const failedChecks = REQUIRED_CHECKS.filter((check) => !input[check]);
return {
valid: true,
ready: failedChecks.length === 0,
failedChecks,
};
}
Connect validation to the request handler
The evaluator now understands release evidence. The Worker handler needs to translate each evaluator result into a JSON response with the correct status.
- Select src/index.js in the editor's file sidebar.
- Place your cursor before the existing export default line.
- Add the evaluator import and shared response helper by pasting this code:
import { evaluateRelease } from "./evaluate.js";
const JSON_HEADERS = {
"content-type": "application/json; charset=UTF-8",
};
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: JSON_HEADERS,
});
}
What Does the Response Helper Do?
- The import makes evaluateRelease available to the request handler.
- The JSON_HEADERS object gives every response the same JSON content type.
- The jsonResponse function converts a JavaScript value into formatted JSON.
- The status parameter defaults successful responses to 200.
- Find the existing POST /evaluate condition in src/index.js.
- Replace that entire condition with this validated route:
if (request.method === "POST" && url.pathname === "/evaluate") {
let payload;
try {
payload = await request.json();
} catch {
return jsonResponse({ error: "Body must contain valid JSON" }, 400);
}
const result = evaluateRelease(payload);
if (!result.valid) {
return jsonResponse(
{
error: "Validation failed",
details: result.errors,
},
400,
);
}
return jsonResponse(
{
status: result.ready ? "ready" : "blocked",
failedChecks: result.failedChecks,
},
result.ready ? 200 : 422,
);
}
How Does the Route Use the Result?
- The try block reads the request body into payload.
- Invalid JSON receives a 400 response before evaluation.
- Missing or incorrectly typed checks also receive a 400 response.
- A valid failed check receives 422. A valid passing release receives 200.
- Save src/index.js.
Before you send the empty object, which response should the new evaluator choose?
- Send an empty object to the local /evaluate route by running this command in the terminal from the previous step:
curl -i --json '{}' http://localhost:8787/evaluate
What Changed?
You should see a 400 status with Validation failed in the response body. The details identify all three missing boolean checks.
Still Seeing a Ready Decision?
- Confirm that the naive POST /evaluate condition was completely replaced.
- Confirm that src/index.js imports evaluateRelease from ./evaluate.js.
- Help me find why my empty payload still returns ready.
- Find async fetch(request) { in src/index.js.
- Replace it with this handler signature:
async fetch(request, env, ctx) {
Why Keep the Full Handler Signature?
The handler retains the request plus the Worker environment and execution context parameters. This matches the final module handler used throughout the project.
- Find the existing response inside the GET /health condition.
- Replace that response with this shared helper call:
return jsonResponse({
service: "deploygate-api",
status: "healthy",
});
Why Use One Response Helper?
The health route now uses the same JSON formatting and headers as every release decision. Consistent responses make the API easier to inspect from any client.
- Save src/index.js.
- Check the updated health response by running this command:
curl http://localhost:8787/health
What Should the Health Route Show?
You should still see deploygate-api with a healthy status. This proves the response refactor preserved the existing health route.
- Find the final unmatched-route response in src/index.js.
- Replace it with this shared helper call:
return jsonResponse({ error: "Route not found" }, 404);
How Does the Fallback Protect the API?
Any request that misses the two supported routes reaches this fallback. The 404 status tells the caller that the requested route does not exist.
- Save src/index.js.
- Check the unmatched-route response by running this command:
curl -i http://localhost:8787/missing
What Should the Fallback Return?
You should see a 404 status with Route not found in the JSON body. DeployGate now gives unknown paths an explicit outcome.
Is the Local Runtime Reporting a Problem?
- Check that every jsonResponse call uses the same capitalization as the function declaration.
- Check that the import stays at the first line of src/index.js.
- Help me troubleshoot my updated Worker handler.
✔️ Awesome, I've got everything!
The Worker now validates release evidence and returns a deliberate status for every supported outcome.
ⓧ I'd like to double check the full code
Compare your saved src/index.js file with this complete version.
import { evaluateRelease } from "./evaluate.js";
const JSON_HEADERS = {
"content-type": "application/json; charset=UTF-8",
};
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: JSON_HEADERS,
});
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return jsonResponse({
service: "deploygate-api",
status: "healthy",
});
}
if (request.method === "POST" && url.pathname === "/evaluate") {
let payload;
try {
payload = await request.json();
} catch {
return jsonResponse({ error: "Body must contain valid JSON" }, 400);
}
const result = evaluateRelease(payload);
if (!result.valid) {
return jsonResponse(
{
error: "Validation failed",
details: result.errors,
},
400,
);
}
return jsonResponse(
{
status: result.ready ? "ready" : "blocked",
failedChecks: result.failedChecks,
},
result.ready ? 200 : 422,
);
}
return jsonResponse({ error: "Route not found" }, 404);
},
};
Verify every release outcome
The final check compares three payloads against the same endpoint. Their status codes show whether DeployGate distinguishes missing evidence, failed evidence, and complete passing evidence.
Before you run the requests, which payload should receive the only successful release decision?
- Send all three release scenarios to the local API by running these commands:
curl -i --json '{}' http://localhost:8787/evaluate
curl -i --json '{"testsPassed":true,"securityScanPassed":false,"approvalReceived":true}' http://localhost:8787/evaluate
curl -i --json '{"testsPassed":true,"securityScanPassed":true,"approvalReceived":true}' http://localhost:8787/evaluate
What Do the Three Responses Prove?
- The empty object receives 400 because required evidence is missing.
- The failed security scan receives 422 with a blocked decision.
- The three passing checks receive 200 with a ready decision.
Do the Statuses Look Wrong?
- Confirm that each payload uses the exact field names from REQUIRED_CHECKS.
- Confirm that each field value is the boolean true or false.
- Help me diagnose an unexpected DeployGate status code.
That closes the dangerous gap. DeployGate now refuses to approve a release until every required check is present and passing.
Your release rules now work through visible API responses. Next, you'll turn those rules into automated tests that catch regressions before delivery.
Turn Release Rules into Automated Tests
Your release gate now rejects missing evidence. It also blocks releases with a failed check.
Manual requests proved those rules once. Built-in Node.js unit tests make the release logic repeatable. Handler tests protect the API's HTTP behavior before delivery begins.
In This Step, Get Ready To:
- Test the evaluator's release decisions.
- Test the Worker's HTTP responses.
- Introduce a controlled regression to prove the suite catches it.
Test the Release Evaluator
The evaluator contains the decision rules that protect a release. Testing it directly isolates those rules from request routing.
- Switch back to the deploygate-api project in your editor.
- Select the test directory in the editor's file sidebar.
- Click your editor's New File control.
- Enter deploygate.test.js as the file name.
- Add the test harness with the ready-release case by pasting this code into test/deploygate.test.js:
import test from "node:test";
import assert from "node:assert/strict";
import worker from "../src/index.js";
import { evaluateRelease } from "../src/evaluate.js";
test("marks a release ready when every check passes", () => {
const result = evaluateRelease({
testsPassed: true,
securityScanPassed: true,
approvalReceived: true,
});
assert.deepEqual(result, {
valid: true,
ready: true,
failedChecks: [],
});
});
What Does This Test Prove?
- The imports connect the test file to Node.js test tooling. They also load the Worker plus evaluateRelease from your application.
- The test passes three successful checks into evaluateRelease.
- The assert.deepEqual() assertion verifies the complete decision object. A ready release must have no failed checks.
- Save test/deploygate.test.js.
- Switch to a terminal in deploygate-api that is separate from the running Wrangler process.
- Run the first automated check with this command:
npm test
What Does This Command Do?
The test script runs Node.js with its built-in test runner. The runner discovers test/deploygate.test.js automatically.
You should see one passing test with no failures. That first green result proves a fully checked release receives the ready decision.
Is the First Test Failing?
- Confirm that test/deploygate.test.js sits inside the existing test directory.
- Check each import path against the code above.
- Save the file before running the suite again.
- Help me troubleshoot the first DeployGate test.
A ready-path test protects successful releases. The evaluator also needs a test that protects the boundary around incomplete evidence.
- Add the missing-check test below the first test in test/deploygate.test.js by pasting this code:
test("rejects a payload with missing checks", () => {
const result = evaluateRelease({ testsPassed: true });
assert.equal(result.valid, false);
assert.deepEqual(result.errors, [
"securityScanPassed must be a boolean",
"approvalReceived must be a boolean",
]);
});
How Does This Protect Validation?
- The input supplies only testsPassed. The evaluator must reject the missing security scan plus approval checks.
- The first assertion confirms the result is invalid.
- The second assertion confirms both missing fields produce specific validation details.
- Save test/deploygate.test.js.
- Run the expanded evaluator suite with this command:
npm test
What Does This Run Confirm?
The test runner executes both evaluator cases. Each case checks a different release decision boundary.
You should see two passing tests with no failures. The evaluator now has automated protection for complete evidence plus missing evidence.
Is the Missing-Check Test Failing?
- Confirm that the input contains only testsPassed: true.
- Check the spelling of securityScanPassed in the expected errors.
- Check the spelling of approvalReceived in the expected errors.
- Help me troubleshoot the missing-check test.
Test the Worker Handler
Evaluator tests protect the decision rules. Handler tests send a Request through the Worker's fetch handler to verify the status plus response body.
- Add the blocked-release handler test below the evaluator tests by pasting this code:
test("returns 422 and failed checks for a blocked release", async () => {
const request = new Request("https://deploygate.example/evaluate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
testsPassed: true,
securityScanPassed: false,
approvalReceived: true,
}),
});
const response = await worker.fetch(request, {}, {});
assert.equal(response.status, 422);
assert.deepEqual(await response.json(), {
status: "blocked",
failedChecks: ["securityScanPassed"],
});
});
How Does the Handler Test Work?
- The Request targets the /evaluate route with one failed security check.
- The test passes that request into worker.fetch.
- The status assertion protects the blocked release response.
- The body assertion confirms that securityScanPassed identifies the failed gate.
- Save test/deploygate.test.js.
- Run the suite with the new handler test by using this command:
npm test
What Does the Third Test Add?
The suite now covers the path from an HTTP request to a blocked JSON response. This protects the behavior an API caller actually observes.
You should see three passing tests with no failures. Your blocked response now has both logic coverage plus handler coverage.
Is the Blocked Handler Test Failing?
- Confirm that the request method is POST.
- Confirm that securityScanPassed is set to false.
- Check that the request body uses JSON.stringify.
- Help me troubleshoot the blocked handler test.
The release route now has handler coverage. A health test adds a fast check for the service response used by operators plus deployment verification.
- Add the health handler test at the bottom of test/deploygate.test.js by pasting this code:
test("returns a healthy service response", async () => {
const request = new Request("https://deploygate.example/health");
const response = await worker.fetch(request, {}, {});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
service: "deploygate-api",
status: "healthy",
});
});
What Does the Health Test Protect?
- The request targets /health with the default GET method.
- The status assertion confirms that the route succeeds.
- The body assertion protects the service name plus health value.
- Save test/deploygate.test.js.
- Run all four tests with this command:
npm test
What Does the Full Suite Cover?
The suite checks two release-logic paths. It also checks two observable handler responses.
You should see four passing tests with no failures. The API's core release contract is now repeatable from one command.
Is the Health Test Failing?
- Confirm that the request URL ends with /health.
- Check that the expected service value is deploygate-api.
- Check that the expected health value is healthy.
- Help me troubleshoot the health handler test.
Prove the Suite Catches a Regression
A test suite earns trust after you see it catch a regression. You will give the blocked-response test a knowingly wrong status expectation.
- Find the blocked-release test in test/deploygate.test.js.
- Locate this status assertion:
assert.equal(response.status, 422);
Why Does This Assertion Matter?
This assertion encodes the API contract for a valid release with a failed check. The handler must report the release as blocked.
- Replace 422 with 200 so the assertion looks like this:
assert.equal(response.status, 200);
Why Is This Change Expected to Fail?
The application still returns the blocked status. The test now expects the ready status for the same response.
- Save test/deploygate.test.js.
Before you run the suite, predict how many tests remain green.
- Run the intentionally broken suite with this command:
npm test
What Did the Regression Reveal?
The runner executes the same four cases. The blocked-release test fails because the actual status differs from the changed expectation.
You should see one failed test. The other three tests still pass. This failure is intentional.
Did Every Test Still Pass?
- Confirm that you changed the assertion inside the blocked-release test.
- Confirm that the temporary expected status is 200.
- Save the test file before running the suite again.
- Help me make the controlled regression fail correctly.
The red result proves the suite detects a broken release contract. Restore the correct expectation to return the project to its tested state.
- Find the temporary assertion in the blocked-release test:
assert.equal(response.status, 200);
What Needs Restoring?
The temporary assertion expects a successful response from a blocked release. Restoring the blocked status makes the test match the API contract again.
- Replace 200 with 422 so the assertion looks like this:
assert.equal(response.status, 422);
What Does the Restored Line Check?
The assertion once again requires the blocked status from the failed security check. The test now agrees with the intended release behavior.
- Save test/deploygate.test.js.
Before the final run, predict whether every test should return to green.
- Verify the restored suite with this command:
npm test
What Does the Final Run Prove?
Node.js runs the complete test file after the correct status expectation is restored. The final result verifies both evaluator rules plus both handler paths.
You should see four passing tests with no failures. That is the test gate your delivery pipeline can trust.
Is One Test Still Failing?
- Confirm that the blocked-release assertion expects 422.
- Confirm that the health assertion still expects 200.
- Save test/deploygate.test.js before rerunning the suite.
- Help me restore all four DeployGate tests.
✔️ Awesome, I've got everything!
Your restored suite passes all four tests. Make sure test/deploygate.test.js is saved before you continue.
ⓧ I'd like to double check the full code
Compare your complete test/deploygate.test.js file with this reference.
import test from "node:test";
import assert from "node:assert/strict";
import worker from "../src/index.js";
import { evaluateRelease } from "../src/evaluate.js";
test("marks a release ready when every check passes", () => {
const result = evaluateRelease({
testsPassed: true,
securityScanPassed: true,
approvalReceived: true,
});
assert.deepEqual(result, {
valid: true,
ready: true,
failedChecks: [],
});
});
test("rejects a payload with missing checks", () => {
const result = evaluateRelease({ testsPassed: true });
assert.equal(result.valid, false);
assert.deepEqual(result.errors, [
"securityScanPassed must be a boolean",
"approvalReceived must be a boolean",
]);
});
test("returns 422 and failed checks for a blocked release", async () => {
const request = new Request("https://deploygate.example/evaluate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
testsPassed: true,
securityScanPassed: false,
approvalReceived: true,
}),
});
const response = await worker.fetch(request, {}, {});
assert.equal(response.status, 422);
assert.deepEqual(await response.json(), {
status: "blocked",
failedChecks: ["securityScanPassed"],
});
});
test("returns a healthy service response", async () => {
const request = new Request("https://deploygate.example/health");
const response = await worker.fetch(request, {}, {});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
service: "deploygate-api",
status: "healthy",
});
});
What Should Match?
The file contains four imports plus four tests. The blocked-response assertion expects 422. The health assertion expects 200.
Your release rules now guard themselves locally. Next, you will connect this green test gate to GitHub Actions for automated delivery.
Deploy Through a GitHub Actions Pipeline
Your release rules now have four automated tests. The next goal is to move that tested API from your Mac to a public serverless endpoint.
A manual deployment proves the service can run on Cloudflare Workers. Repeating that deployment by hand is unreliable. In this step, GitHub Actions becomes the tested delivery path for every push to main.
In this step, get ready to:
- Authenticate Wrangler and deploy the Worker to a public URL.
- Create a public GitHub repository with scoped Cloudflare credentials.
- Push a workflow that tests the API before deploying it.
Deploy the Worker once
The first deployment creates the Worker under your Cloudflare account. Wrangler uses browser authentication for this local deployment.
- Stop the local runtime in the terminal from earlier by pressing Ctrl+C.
- Start Wrangler's browser authentication by running this command:
npm run login
What does this command do?
The login script runs Wrangler's authentication flow. Wrangler opens a browser so your Cloudflare account can authorize local deployments.
- Approve Wrangler's requested access in the browser.
- Return to the terminal after the authentication completes.
Browser authentication not completing?
- Confirm that the browser is using the signed-in Cloudflare account from earlier.
- Retry the login command if the browser window was closed before authorization finished.
- Ask for help with the authentication flow here.
The authenticated session can now create deploygate-api in your Workers Free account. The deployment output includes the public address you reuse throughout the rest of this step.
- Deploy the Worker for the first time by running this command:
npm run deploy
What does this command do?
The deploy script uploads the Worker described by wrangler.jsonc. Cloudflare publishes it through a public workers.dev address.
The terminal prints a deployment URL after the upload succeeds. That URL is the public entry point for your API.
- Record the deployment URL here: https://deploygate-api.your-subdomain.workers.dev.
Before you check the public endpoint, do you expect the same health response you saw locally?
- Enter https://deploygate-api.your-subdomain.workers.dev followed by /health in your browser address bar.
You should see {"service":"deploygate-api","status":"healthy"}. Your API is now responding from Cloudflare's serverless environment.
Live health check not responding?
- Confirm that the address starts with the exact workers.dev URL printed by the deployment.
- Confirm that the address ends with /health.
- Ask for help with the deployment here.
Store scoped deployment credentials
The pipeline needs permission to update the Worker without using your browser session. A worker-scoped Editor token follows the least-privilege principle because it grants only the access needed for the existing Worker.
Keep the credential contained
This part handles a live Cloudflare API token. Keep it only in the GitHub repository secret named CLOUDFLARE_API_TOKEN.
Do not put the token or account ID in source files. Keep both values out of terminal history. Keep both values out of screenshots and workflow logs.
A public GitHub repository gives the pipeline a remote home. Standard GitHub-hosted runners remain free for this project while the repository stays public.
- Select the menu in the upper-right corner of GitHub.
- Click New repository.
- Enter deploygate-api in the Repository name field.
- Choose Public as the repository visibility.
- Leave Add README off.
- Leave the .gitignore choice unchanged.
- Leave the license choice unchanged.
- Click Create repository.
- Record the HTTPS repository URL from Quick setup here: REMOTE-URL.
The repository is intentionally empty. Your local project supplies the README, workflow, source code, tests, and existing configuration in one first commit.
- Switch back to the Cloudflare dashboard from earlier.
- Select Workers & Pages.
- Locate Account Details.
- Record the value beside Account ID here: your Cloudflare account ID.
Why create the Worker first?
Creating a new Worker requires broader product-level access. Your browser-authenticated deployment has already completed that task.
The pipeline only updates the existing Worker. That narrower job can use Editor access scoped to deploygate-api.
- Select Manage Account in Cloudflare.
- Select Account API Tokens.
- Click Create Token.
- Configure the token with Editor access to the existing deploygate-api Worker.
- Click Continue to summary.
- Review the Worker scope.
- Click Create Token.
- Copy the generated token to your clipboard.
The token is now on your clipboard. The next actions place it directly into GitHub's encrypted secret storage.
- Return to the empty deploygate-api repository on GitHub.
- Select the repository's Settings tab.
- Select Secrets and variables in the sidebar.
- Select Actions.
- Select Secrets.
- Click New repository secret.
- Enter CLOUDFLARE_API_TOKEN in the Name field.
- Paste the token into the Secret field.
- Click Add secret.
- Click New repository secret again.
- Enter CLOUDFLARE_ACCOUNT_ID in the Name field.
- Enter your Cloudflare account ID in the Secret field.
- Click Add secret.
You should see two secret names in the repository settings. GitHub masks their values so the credential stays out of your project files.
Secret names not appearing?
- Confirm that you created repository secrets inside the deploygate-api repository.
- Check the spelling of CLOUDFLARE_API_TOKEN.
- Check the spelling of CLOUDFLARE_ACCOUNT_ID.
- Ask for help with the secret configuration here.
Build and run the delivery pipeline
The repository needs two new files. README.md documents the API contract. .github/workflows/ci.yml defines the automated test and deployment sequence.
- Create README.md inside the deploygate-api folder using your existing editor's file sidebar.
- Paste the project overview into README.md from the code below:
# DeployGate API
DeployGate evaluates whether a software release is ready to ship. It demonstrates API development, automated tests, GitHub Actions, secret handling, and serverless deployment to Cloudflare Workers.
## Release contract
Send a JSON object to `POST /evaluate` with three boolean checks:
- `testsPassed`
- `securityScanPassed`
- `approvalReceived`
A passing release returns `200` and `ready`. A valid but failed release returns `422` and `blocked`. Invalid input returns `400`.
What does this documentation cover?
This section names DeployGate's purpose. It also records the three checks that form the release contract.
The status summary gives API callers a quick map of ready, blocked, and invalid responses.
- Save README.md.
- Confirm that the file shows the Release contract heading with all three check names.
README formatting looks broken?
- Confirm that the main title starts with one # character.
- Confirm that Release contract starts with two # characters.
- Ask for help with the Markdown here.
- Append the local commands and delivery explanation to README.md using the code below:
## Run locally
```bash
npm install
npm test
npm run dev
```
In another terminal:
```bash
curl http://localhost:8787/health
curl --json '{"testsPassed":true,"securityScanPassed":false,"approvalReceived":true}' http://localhost:8787/evaluate
```
## Delivery flow
1. A pull request runs the automated tests.
2. A push to `main` runs the tests again.
3. Only a successful push workflow deploys the Worker.
4. Cloudflare exposes the API on its `workers.dev` URL.
## Architecture
```text
API client -> Cloudflare Worker -> evaluateRelease()
Git push -> GitHub Actions -> Node.js tests -> Wrangler -> Cloudflare Workers
```
## Security
Cloudflare credentials are stored only as GitHub Actions repository secrets named `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`.
What does this documentation add?
The local section gives another developer the commands needed to run and test the API. The delivery section shows how a change moves through tests before deployment.
The security note records the approved storage location for both Cloudflare values.
- Save README.md.
- Confirm that the file ends with the Security section.
Missing part of the README?
- Confirm that you appended the second chunk below the release contract.
- Check that every triple-backtick code fence has a matching closing fence.
- Ask for help comparing the file here.
The workflow uses continuous integration to run tests for pull requests and pushes. Its deployment step runs only after a successful push workflow reaches that point.
- Create ci.yml inside the existing .github/workflows directory using your editor's file sidebar.
- Paste the workflow trigger and test steps into .github/workflows/ci.yml from the code below:
name: Test and Deploy
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
test-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Node.js
uses: actions/setup-node@v7.0.0
with:
node-version: 24.21.0
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
How does the test gate work?
- The triggers start the workflow for pull requests and pushes to main.
- The job checks out the repository with actions/checkout@v7.0.1.
- The setup action provides Node.js 24.21.0 and enables the npm cache.
- The final two steps install the locked dependencies and run all four tests.
- Save .github/workflows/ci.yml.
- Confirm that the workflow lists Run tests after Install dependencies.
Workflow indentation looks uneven?
- Use spaces for every indentation level in ci.yml.
- Align each uses or run key beneath its step name.
- Ask for help with the YAML structure here.
- Append the Cloudflare deployment step to .github/workflows/ci.yml using the code below:
- name: Deploy to Cloudflare Workers
if: github.event_name == 'push'
uses: cloudflare/wrangler-action@v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4.143.0"
command: deploy
How does deployment stay controlled?
The condition limits deployment to push events. Pull requests still run the tests without changing the live Worker.
The Wrangler action reads both values from repository secrets. It deploys with Wrangler 4.143.0 after the preceding test step succeeds.
- Save .github/workflows/ci.yml.
- Confirm that Deploy to Cloudflare Workers is the final workflow step.
Deployment step not aligned?
- Align the deployment step with the earlier checkout and test steps.
- Confirm that both secret references match the repository secret names exactly.
- Ask for help with this workflow step here.
✔️ Awesome, I've got everything!
- Save README.md.
- Save .github/workflows/ci.yml.
ⓧ I'd like to double check the full code
Compare each project file with the complete versions below. The existing source files and tests remain unchanged.
name: Test and Deploy
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
test-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Node.js
uses: actions/setup-node@v7.0.0
with:
node-version: 24.21.0
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Deploy to Cloudflare Workers
if: github.event_name == 'push'
uses: cloudflare/wrangler-action@v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4.143.0"
command: deploy
node_modules/
# DeployGate API
DeployGate evaluates whether a software release is ready to ship. It demonstrates API development, automated tests, GitHub Actions, secret handling, and serverless deployment to Cloudflare Workers.
## Release contract
Send a JSON object to `POST /evaluate` with three boolean checks:
- `testsPassed`
- `securityScanPassed`
- `approvalReceived`
A passing release returns `200` and `ready`. A valid but failed release returns `422` and `blocked`. Invalid input returns `400`.
## Run locally
```bash
npm install
npm test
npm run dev
```
In another terminal:
```bash
curl http://localhost:8787/health
curl --json '{"testsPassed":true,"securityScanPassed":false,"approvalReceived":true}' http://localhost:8787/evaluate
```
## Delivery flow
1. A pull request runs the automated tests.
2. A push to `main` runs the tests again.
3. Only a successful push workflow deploys the Worker.
4. Cloudflare exposes the API on its `workers.dev` URL.
## Architecture
```text
API client -> Cloudflare Worker -> evaluateRelease()
Git push -> GitHub Actions -> Node.js tests -> Wrangler -> Cloudflare Workers
```
## Security
Cloudflare credentials are stored only as GitHub Actions repository secrets named `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`.
{
"name": "deploygate-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"test": "node --test",
"login": "wrangler login",
"deploy": "wrangler deploy",
"cleanup": "wrangler delete"
},
"devDependencies": {
"wrangler": "4.143.0"
}
}
const REQUIRED_CHECKS = [
"testsPassed",
"securityScanPassed",
"approvalReceived",
];
export function evaluateRelease(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
return {
valid: false,
errors: ["Body must be a JSON object"],
};
}
const errors = REQUIRED_CHECKS.filter(
(check) => typeof input[check] !== "boolean",
).map((check) => `${check} must be a boolean`);
if (errors.length > 0) {
return {
valid: false,
errors,
};
}
const failedChecks = REQUIRED_CHECKS.filter((check) => !input[check]);
return {
valid: true,
ready: failedChecks.length === 0,
failedChecks,
};
}
import { evaluateRelease } from "./evaluate.js";
const JSON_HEADERS = {
"content-type": "application/json; charset=UTF-8",
};
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: JSON_HEADERS,
});
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return jsonResponse({
service: "deploygate-api",
status: "healthy",
});
}
if (request.method === "POST" && url.pathname === "/evaluate") {
let payload;
try {
payload = await request.json();
} catch {
return jsonResponse({ error: "Body must contain valid JSON" }, 400);
}
const result = evaluateRelease(payload);
if (!result.valid) {
return jsonResponse(
{
error: "Validation failed",
details: result.errors,
},
400,
);
}
return jsonResponse(
{
status: result.ready ? "ready" : "blocked",
failedChecks: result.failedChecks,
},
result.ready ? 200 : 422,
);
}
return jsonResponse({ error: "Route not found" }, 404);
},
};
import test from "node:test";
import assert from "node:assert/strict";
import worker from "../src/index.js";
import { evaluateRelease } from "../src/evaluate.js";
test("marks a release ready when every check passes", () => {
const result = evaluateRelease({
testsPassed: true,
securityScanPassed: true,
approvalReceived: true,
});
assert.deepEqual(result, {
valid: true,
ready: true,
failedChecks: [],
});
});
test("rejects a payload with missing checks", () => {
const result = evaluateRelease({ testsPassed: true });
assert.equal(result.valid, false);
assert.deepEqual(result.errors, [
"securityScanPassed must be a boolean",
"approvalReceived must be a boolean",
]);
});
test("returns 422 and failed checks for a blocked release", async () => {
const request = new Request("https://deploygate.example/evaluate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
testsPassed: true,
securityScanPassed: false,
approvalReceived: true,
}),
});
const response = await worker.fetch(request, {}, {});
assert.equal(response.status, 422);
assert.deepEqual(await response.json(), {
status: "blocked",
failedChecks: ["securityScanPassed"],
});
});
test("returns a healthy service response", async () => {
const request = new Request("https://deploygate.example/health");
const response = await worker.fetch(request, {}, {});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
service: "deploygate-api",
status: "healthy",
});
});
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "deploygate-api",
"main": "src/index.js",
"compatibility_date": "2026-09-29",
"workers_dev": true
}
The files are ready for version control. Your first Git commit captures the tested application and its delivery workflow as one project state.
- Initialize the main branch and record the first commit by running these commands:
git init -b main
git add .
git commit -m "Build DeployGate API"
What do these commands do?
- The first command creates a local Git repository with main as its initial branch.
- The second command stages every project file that is not excluded by .gitignore.
- The final command records the staged files as the project's first snapshot.
The terminal should report a new root commit named Build DeployGate API. The summary includes README.md and .github/workflows/ci.yml among the created files.
Commit not completing?
- Complete Git's requested author identity setup if this is your first commit on the Mac.
- Confirm that the terminal still points to the deploygate-api folder.
- Ask for help with the first commit here.
- Connect the local repository to the saved GitHub URL by running this command:
git remote add origin [[REMOTE_URL="REMOTE-URL"]]
What does this command do?
This command names your empty GitHub repository origin. Future pushes can use that short remote name.
Before you push, which workflow step do you expect to run immediately before the Worker deployment?
- Push the committed main branch to GitHub by running this command:
git push -u origin main
What happens after the push?
The push uploads the project and starts the Test and Deploy workflow. GitHub installs the locked dependencies before running all four tests.
A successful test step allows the deployment step to update the existing Worker. A failed test stops the job before deployment.
This first workflow run can take a few minutes while the runner prepares Node.js and installs the dependencies. The changing status shows that GitHub is processing the pipeline.
- Open the Actions tab in the deploygate-api repository.
- Select the newest Test and Deploy workflow run.
- Confirm that Run tests completed successfully.
- Confirm that Deploy to Cloudflare Workers completed after the test step.
The completed run shows a green test step above a green deployment step. That order proves the pipeline checks release behavior before changing production.
Before you perform the final production check, do you expect the pipeline deployment to preserve the same healthy response?
- Enter https://deploygate-api.your-subdomain.workers.dev followed by /health in your browser address bar again.
You should still see {"service":"deploygate-api","status":"healthy"}. The tested GitHub commit now powers the live API.
Workflow or deployment failed?
- Confirm that both repository secret names match the workflow references exactly.
- Confirm that the Cloudflare token has Editor access to the existing deploygate-api Worker.
- Open the failed workflow step without copying credential values into your support request.
- Ask for help with the pipeline here.
That is the delivery path working. Every push to main now has to pass the release tests before GitHub Actions deploys the Worker.
Secret mission
Smoke-Test Production After Every Deployment
A green deployment only confirms that the upload completed. Add a production smoke test that calls `/health` after deployment. An unhealthy live API then fails the workflow.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the project available or pause automatic delivery. You can also delete the online resources entirely.
The Cloudflare Worker stays at $0 on Workers Free. The public GitHub repository also stays at $0 with the standard ubuntu-latest runner.
Cost warning
Workers Free includes 100,000 requests per day.
GitHub provides unlimited standard hosted-runner usage for public repositories.
Workers Paid or a GitHub larger runner can introduce charges.
Resources you used:
- The public deploygate-api GitHub repository.
- The .github/workflows/ci.yml GitHub Actions workflow.
- The public deploygate-api Cloudflare Worker.
- The account-owned Cloudflare API token.
- The CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID GitHub Actions repository secrets.
Keep everything running
No action is needed. Choose this option if you want DeployGate to remain available for future demonstrations.
- Keep the public deploygate-api repository available.
- Keep .github/workflows/ci.yml in the repository.
- Keep the deploygate-api Worker active on its existing workers.dev URL.
- Keep the account-owned Cloudflare API token active.
- Keep both GitHub Actions repository secrets protected.
Pause - I'll come back to this later
Pause automatic delivery while leaving the live API available. Your public repository also remains intact.
- In your existing editor, delete .github/workflows/ci.yml from the deploygate-api project.
- Commit the deletion to the existing local Git repository.
- Push the commit to main.
- Confirm that .github/workflows/ci.yml no longer appears on main.
- Visit /health on the existing workers.dev URL.
The workflow file is gone from main. The existing API still returns its healthy response.
Delete - I don't want to use this again
Deleting the online resources is permanent, so choose this path only when you are finished with the live API. Your local deploygate-api source code remains available.
- From the terminal in your existing deploygate-api folder, delete the Worker by running:
npm run cleanup
What does this command do?
- The npm command reads the cleanup script from package.json.
- The script asks Wrangler to delete the Worker named deploygate-api.
- The deletion also removes the associated Cloudflare developer platform resources.
- Refresh the existing workers.dev URL at /health.
You should no longer receive the healthy DeployGate response. That confirms the Worker is deleted.
Worker still available?
- Confirm that the terminal is inside your existing deploygate-api folder so npm can read package.json.
- Use the login script in package.json if Wrangler asks you to authenticate again.
- Ask for help with a failed Worker cleanup.
The Worker is now gone. The account-owned API token still exists independently in Cloudflare.
- In Cloudflare, select Manage Account.
- Open Account API Tokens.
- Open the token's three-dot menu.
- Select Delete.
The token can no longer authorize deployments. The remaining online resource is the public GitHub repository.
- Return to the public deploygate-api repository on GitHub.
- Open Settings.
- Find the repository deletion controls.
- Delete deploygate-api using the repository deletion option.
- Confirm the deletion when GitHub asks.
That closes the public repository plus its workflow. It also removes the two repository secrets stored inside it.
Nice Work!
Nice Work!
You did it! Your tested DeployGate HTTP API now moves from local code through GitHub Actions to a public Cloudflare Workers deployment.
You've learned how to:
- Built a release-readiness API that exposes GET /health plus POST /evaluate. Its decisions produce meaningful 200, 400, 404, and 422 responses.
- Turned release rules into four automated tests with Node.js built-in test tooling. The suite checks business logic plus HTTP behavior before delivery.
- Created a CI/CD pipeline that tests changes before deploying pushes to main. Scoped repository secrets keep Cloudflare credentials outside source code plus workflow logs.
- Secret Mission: Added a post-deployment production smoke test that checks the live /health endpoint. An unhealthy production response now fails the workflow.
Ready to quiz yourself?