Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
GOOGLE_CALENDAR_API_KEY=
SENTRY_DSN=
NODE_ENV=
RESTART_TOKEN=your-secret-token-here
RESTART_SCRIPT=/path/to/poke-docker.sh
107 changes: 107 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Release Deployment

on:
release:
types: [published]

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
environment: release
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Extract and verify version
id: version
run: |
TAG="${{ github.event.release.tag_name }}"
VERSION="${TAG#v}"
echo "Release tag version: $VERSION"

# Extract version from package.json
PACKAGE_VERSION=$(node -p "require('./package.json').version")
echo "package.json version: $PACKAGE_VERSION"

# Verify they match
if [ "$VERSION" != "$PACKAGE_VERSION" ]; then
echo "❌ Error: Release tag version ($VERSION) does not match package.json version ($PACKAGE_VERSION)"
echo "Please ensure package.json version is updated before creating a release"
exit 1
fi

echo "✅ Version verified: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Trigger server restart
run: |
echo "Triggering server restart..."
curl -X POST \
-H "Authorization: Bearer ${{ secrets.RESTART_TOKEN }}" \
-f \
${{ secrets.SERVER_URL }}/restart
echo "Restart command sent successfully"

- name: Wait for server to restart
run: |
echo "Waiting 10 seconds for server to restart..."
sleep 10

- name: Verify deployment
run: |
EXPECTED_VERSION="${{ steps.version.outputs.version }}"
echo "Expected version: $EXPECTED_VERSION"

MAX_ATTEMPTS=24
SLEEP_INTERVAL=5

for i in $(seq 1 $MAX_ATTEMPTS); do
echo "Attempt $i/$MAX_ATTEMPTS: Checking health endpoint..."

# Fetch health endpoint
RESPONSE=$(curl -s ${{ secrets.SERVER_URL }}/health || echo "")

if [ -z "$RESPONSE" ]; then
echo "⚠️ No response from server"
else
# Extract version from response
ACTUAL_VERSION=$(echo "$RESPONSE" | jq -r '.version')
echo "Server reported version: $ACTUAL_VERSION"

if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
echo "✅ Deployment verified: version $ACTUAL_VERSION matches expected $EXPECTED_VERSION"
exit 0
else
echo "⚠️ Version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
fi
fi

if [ $i -lt $MAX_ATTEMPTS ]; then
echo "Waiting ${SLEEP_INTERVAL}s before next attempt..."
sleep $SLEEP_INTERVAL
fi
done

echo "❌ Deployment verification failed after $((MAX_ATTEMPTS * SLEEP_INTERVAL))s"
exit 1

- name: Notify Telegram - Success
if: success()
run: |
curl -X POST \
-H "Content-Type: multipart/form-data" \
-F chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-F text="✅ ccc-server ${{ github.event.release.tag_name }} deployed successfully" \
https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage

- name: Notify Telegram - Failure
if: failure()
run: |
curl -X POST \
-H "Content-Type: multipart/form-data" \
-F chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-F text="❌ ccc-server ${{ github.event.release.tag_name }} deployment FAILED. Check GitHub Actions logs: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,106 @@ mise run test:carleton-college
TDD workflow

This repository practices TDD for agentic development: write a failing AVA test next to the implementation (`*.test.ts`), run `mise run test`, implement until green, then run smoke tests for integration checks.

## Deployment

### Automated Release Workflow

The repository uses GitHub Actions to automate server restarts after new releases are published.

#### Prerequisites

The following secrets must be configured in GitHub:

- **`RESTART_TOKEN`** - Secret token for authenticating restart requests (must match the server's `RESTART_TOKEN` environment variable)
- **`SERVER_URL`** - Base URL of the production server (e.g., `https://api.example.com`)
- **`TELEGRAM_TOKEN`** - Bot token for Telegram notifications
- **`TELEGRAM_CHAT_ID`** - Chat ID for Telegram notifications

#### Environment Protection

The workflow uses the `release` environment with manual approval requirements:

1. Go to **Settings > Environments > release**
2. Enable **Required reviewers**
3. Add authorized users who can approve deployments

#### How it Works

1. When a new release is published on GitHub, the workflow:
- Verifies the release tag matches the version in `package.json`
- Waits for manual approval (if configured)
- Triggers a server restart via `POST /restart`
- Polls the `/health` endpoint to verify the new version is running
- Sends a Telegram notification on success or failure

2. The server must have:
- `RESTART_TOKEN` environment variable set (matching GitHub secret)
- A process manager (systemd, PM2, Docker) that automatically restarts the process on exit

#### Releasing a New Version

Before creating a release:

1. Update the version in `package.json`:
```bash
npm version patch # or minor, or major
```

2. Commit the version change:
```bash
git add package.json package-lock.json
git commit -m "Bump version to X.Y.Z"
git push
```

3. Create a GitHub release with a tag matching the package.json version (e.g., `v1.2.3`)

The workflow will verify that the release tag matches the package.json version before proceeding with the deployment.

#### Health Check Endpoint

`GET /health`

Returns the current version from `package.json` and status.

Returns:
```json
{
"version": "1.2.3",
"status": "ok"
}
```

#### Restart Endpoint

`POST /restart`

Headers:
```
Authorization: Bearer <RESTART_TOKEN>
```

Returns:
```json
{
"message": "Server restart initiated"
}
```

**Restart Behavior:**

The restart endpoint supports two modes:

1. **Custom Script (Docker/docker-compose deployments)**: Set `RESTART_SCRIPT` environment variable to point to a script that handles the restart. For docker-compose deployments, this script should pull new images and restart containers:

```bash
# Example: /home/user/poke-docker.sh
#!/bin/sh
cd /home/user/ccc-server
docker-compose pull && docker-compose down && docker-compose up -d
```

Set `RESTART_SCRIPT=/home/user/poke-docker.sh` in your environment.

2. **Process Exit (Default)**: If no `RESTART_SCRIPT` is configured, the server will exit gracefully. The process manager (systemd, PM2, or Docker's restart policy) should restart it automatically. **Note**: This won't pull new Docker images, so it's only suitable for non-containerized deployments.
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "@frogpond/ccc-server",
"version": "0.1.0",
"description": "",
"keywords": [],
"author": "Frog Pond Labs, LLC",
Expand Down
31 changes: 31 additions & 0 deletions scripts/poke-docker.sh.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/sh
# Example restart script for docker-compose deployments
# This script should be placed on the server (outside the container)
# and referenced via RESTART_SCRIPT environment variable

# Ensure script is run by the correct user
if [ "$(whoami)" != "your-username" ]; then
>&2 echo "Error: This script must be run by your-username"
exit 1
fi

# Enable debugging
set -x

# Change to the directory containing docker-compose.yml
dir=/home/your-username/ccc-server

cd "$dir" || exit 1

# Pull latest images, stop containers, and restart them
docker-compose pull && docker-compose down && docker-compose up -d

exit_code=$?

if [ $exit_code -eq 0 ]; then
echo "Successfully restarted containers with latest images"
else
echo "Failed to restart containers (exit code: $exit_code)"
fi

exit $exit_code
18 changes: 18 additions & 0 deletions source/ccc-server/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {test} from 'node:test'
import type {TestContext} from 'node:test'
import {health} from './health.ts'
import type {Context} from './context.ts'

void test('health endpoint should return version and status', async (t: TestContext) => {
const ctx = {body: null} as Context
await health(ctx)

t.assert.ok(ctx.body !== null, 'Response body should not be null')
t.assert.equal(typeof ctx.body, 'object', 'Response should be an object')

const body = ctx.body as {version: string; status: string}
t.assert.ok(body.version, 'Response should include version')
t.assert.equal(body.status, 'ok', 'Status should be "ok"')
t.assert.equal(typeof body.version, 'string', 'Version should be a string')
t.assert.match(body.version, /^\d+\.\d+\.\d+/, 'Version should match semver pattern')
})
29 changes: 29 additions & 0 deletions source/ccc-server/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {readFile} from 'node:fs/promises'
import {fileURLToPath} from 'node:url'
import {dirname, join} from 'node:path'
import type {Context} from './context.ts'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

let cachedVersion: string | null = null

async function getVersion(): Promise<string> {
if (cachedVersion) {
return cachedVersion
}

const packageJsonPath = join(__dirname, '..', '..', 'package.json')
const packageJson = await readFile(packageJsonPath, 'utf-8')
const parsed = JSON.parse(packageJson) as {version: string}
cachedVersion = parsed.version
return cachedVersion
}

export async function health(ctx: Context): Promise<void> {
const version = await getVersion()
ctx.body = {
version,
status: 'ok',
}
}
Loading