Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Local defaults: one Express process serves both frontend and API.
APP_ENV=local

# Set only when building a separate static site in staging or production.
# API_BASE_URL=https://your-api.onrender.com

# Set only on staging/production API Web Services.
# SITE_ORIGIN=https://your-static-site.onrender.com
3 changes: 3 additions & 0 deletions .github/workflows/cd-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
run: npm ci

- name: Build
env:
APP_ENV: production
API_BASE_URL: ${{ vars.API_URL }}
run: npm run build

- name: Deploy to production
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/cd-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
run: npm ci

- name: Build
env:
APP_ENV: staging
API_BASE_URL: ${{ vars.API_URL }}
run: npm run build

- name: Deploy to staging
Expand All @@ -48,7 +51,7 @@ jobs:
run: sleep 60

- name: Smoke test staging site
run: curl -sSL "${{ vars.SITE_URL }}" | grep "Hello, World!"
run: curl -fsSL "${{ vars.SITE_URL }}" | grep -F "${{ vars.API_URL }}/api/hello"

- name: Smoke test staging API
run: curl -sSL "${{ vars.API_URL }}/api/hello" | grep "Hello, World!"
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
# github-actions-demo

Test the github action workflow on a simple test
Express API plus static frontend CI/CD example.

## Local development

```sh
cp .env.example .env
npm ci
npm run build
npm start
```

`APP_ENV=local` is default. Express serves `dist` and `/api/hello` from one process, so frontend
uses same-origin `/api/hello`.

## Render: separate API and static site

Staging and production each need two Render services. Do not host frontend through API Web Service.

| Service | Render type | Build command | Start/publish setting |
| ------- | ----------- | ------------------------- | ------------------------- |
| API | Web Service | `npm ci` | `npm start` |
| Site | Static Site | `npm ci && npm run build` | Publish directory: `dist` |

Set these environment variables in Render:

| Environment | Static Site variables | API Web Service variables |
| ----------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Staging | `APP_ENV=staging`, `API_BASE_URL=https://<staging-api>.onrender.com` | `APP_ENV=staging`, `SITE_ORIGIN=https://<staging-site>.onrender.com` |
| Production | `APP_ENV=production`, `API_BASE_URL=https://github-actions-demo-tk0d.onrender.com` | `APP_ENV=production`, `SITE_ORIGIN=https://<production-site>.onrender.com` |

`API_BASE_URL` is required for staging/production static builds and must be absolute HTTP(S).
`SITE_ORIGIN` is required for staging/production API services and accepts comma-separated static
site origins. Application exits at startup/build when deployed configuration is incomplete.

GitHub `staging` and `production` environments need `SITE_URL` and `API_URL` variables plus
separate `RENDER_SITE_DEPLOY_HOOK` and `RENDER_API_DEPLOY_HOOK` secrets. The deploy workflows
trigger both independent Render services; Render builds the site using its own `API_BASE_URL`.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"main": "src/server.js",
"type": "commonjs",
"scripts": {
"build": "node scripts/build.js",
"start": "node src/server.js",
"build": "node --env-file-if-exists=.env scripts/build.js",
"start": "node --env-file-if-exists=.env src/server.js",
"test": "jest --testPathPattern=tests/unit",
"test:integration": "jest --testPathPattern=tests/integration",
"test:all": "jest",
Expand Down
21 changes: 15 additions & 6 deletions scripts/build.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
const fs = require('fs');
const path = require('path');
const { getApiBaseUrl } = require('../src/config');

const distDir = path.join(__dirname, '..', 'dist');

// Simulate a "build": in a real app this might be webpack/vite/esbuild.
// Here we just generate a static index.html that calls the API.
const html = `<!DOCTYPE html>
function createHtml(apiBaseUrl) {
const helloUrl = `${apiBaseUrl}/api/hello`;

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand All @@ -14,7 +16,7 @@ const html = `<!DOCTYPE html>
<body>
<h1 id="message">Loading...</h1>
<script>
fetch('/api/hello')
fetch(${JSON.stringify(helloUrl)})
.then((res) => res.json())
.then((data) => {
document.getElementById('message').textContent = data.message;
Expand All @@ -26,8 +28,11 @@ const html = `<!DOCTYPE html>
</body>
</html>
`;
}

function build({ apiBaseUrl = getApiBaseUrl() } = {}) {
const html = createHtml(apiBaseUrl);

function build() {
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
Expand All @@ -37,4 +42,8 @@ function build() {
console.log('Build complete: dist/index.html created');
}

build();
if (require.main === module) {
build();
}

module.exports = { createHtml, getApiBaseUrl, build };
23 changes: 20 additions & 3 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
const express = require('express');
const path = require('path');
const { getAllowedOrigins, getAppEnvironment } = require('./config');

function createApp() {
function createApp({
environment = getAppEnvironment(),
allowedOrigins = getAllowedOrigins(undefined, environment),
serveStatic = environment === 'local',
} = {}) {
const app = express();

app.use((req, res, next) => {
const origin = req.get('origin');

if (origin && allowedOrigins.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.vary('Origin');
}

next();
});

// API route - the backend part of "hello world"
app.get('/api/hello', (req, res) => {
res.json({ message: 'Hello, World!' });
});

// Serve the built static frontend from /dist
app.use(express.static(path.join(__dirname, '..', 'dist')));
if (serveStatic) {
app.use(express.static(path.join(__dirname, '..', 'dist')));
}

return app;
}
Expand Down
49 changes: 49 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const APP_ENVIRONMENTS = ['local', 'staging', 'production'];

function getAppEnvironment(value = process.env.APP_ENV) {
const environment = value || 'local';

if (!APP_ENVIRONMENTS.includes(environment)) {
throw new Error(`APP_ENV must be one of: ${APP_ENVIRONMENTS.join(', ')}.`);
}

return environment;
}

function getApiBaseUrl(value = process.env.API_BASE_URL, environment = getAppEnvironment()) {
if (!value) {
if (environment === 'local') {
return '';
}

throw new Error(`API_BASE_URL is required when APP_ENV is ${environment}.`);
}

let url;
try {
url = new URL(value);
} catch {
throw new Error('API_BASE_URL must be an absolute HTTP(S) URL.');
}

if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('API_BASE_URL must use HTTP or HTTPS.');
}

return url.toString().replace(/\/$/, '');
}

function getAllowedOrigins(value = process.env.SITE_ORIGIN, environment = getAppEnvironment()) {
const origins = (value || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);

if (environment !== 'local' && origins.length === 0) {
throw new Error(`SITE_ORIGIN is required when APP_ENV is ${environment}.`);
}

return origins;
}

module.exports = { getAllowedOrigins, getApiBaseUrl, getAppEnvironment };
30 changes: 29 additions & 1 deletion tests/integration/api.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ const { createApp } = require('../../src/app');
// Integration test: exercises the real Express routing + middleware stack,
// not just an isolated function. This is what should run in CI before merge.
describe('GET /api/hello (integration)', () => {
const app = createApp();
const app = createApp({
environment: 'staging',
allowedOrigins: ['https://github-actions-demo-site-staging.onrender.com'],
serveStatic: false,
});

it('responds with 200 and the hello world message', async () => {
const response = await request(app).get('/api/hello');
Expand All @@ -17,4 +21,28 @@ describe('GET /api/hello (integration)', () => {
const response = await request(app).get('/api/hello');
expect(response.headers['content-type']).toMatch(/json/);
});

it('allows requests from configured static site origin', async () => {
const response = await request(app)
.get('/api/hello')
.set('Origin', 'https://github-actions-demo-site-staging.onrender.com');

expect(response.headers['access-control-allow-origin']).toBe(
'https://github-actions-demo-site-staging.onrender.com'
);
});

it('does not allow requests from unconfigured origins', async () => {
const response = await request(app)
.get('/api/hello')
.set('Origin', 'https://untrusted.example');

expect(response.headers['access-control-allow-origin']).toBeUndefined();
});

it('does not serve frontend assets', async () => {
const response = await request(app).get('/');

expect(response.status).toBe(404);
});
});
15 changes: 15 additions & 0 deletions tests/unit/app.unit.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
const { createApp } = require('../../src/app');
const { build } = require('../../scripts/build');
const request = require('supertest');

describe('createApp (unit)', () => {
it('returns an Express app instance', () => {
const app = createApp();
expect(typeof app).toBe('function'); // Express apps are callable functions
expect(typeof app.get).toBe('function');
});

it('serves frontend only in local environment', async () => {
build({ apiBaseUrl: '' });

const localApp = createApp({ environment: 'local' });
const deployedApp = createApp({
environment: 'staging',
allowedOrigins: ['https://site.example.com'],
});

expect((await request(localApp).get('/')).status).toBe(200);
expect((await request(deployedApp).get('/')).status).toBe(404);
});
});
31 changes: 31 additions & 0 deletions tests/unit/build.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const { createHtml, getApiBaseUrl } = require('../../scripts/build');

describe('getApiBaseUrl', () => {
it('uses same-origin requests when API_BASE_URL is unset', () => {
expect(getApiBaseUrl('', 'local')).toBe('');
});

it('removes a trailing slash from an API URL', () => {
expect(getApiBaseUrl('https://api.example.com/')).toBe('https://api.example.com');
});

it('rejects non-HTTP API URLs', () => {
expect(() => getApiBaseUrl('ftp://api.example.com')).toThrow(
'API_BASE_URL must use HTTP or HTTPS.'
);
});

it('requires API_BASE_URL outside local development', () => {
expect(() => getApiBaseUrl('', 'staging')).toThrow(
'API_BASE_URL is required when APP_ENV is staging.'
);
});
});

describe('createHtml', () => {
it('uses configured API URL in client request', () => {
expect(createHtml('https://api.example.com')).toContain(
'fetch("https://api.example.com/api/hello")'
);
});
});
Loading