Skip to content

Personal Deploy

Personal Deploy #33

name: Personal Deploy
on:
workflow_dispatch:
inputs:
image_ref:
description: "Released image tag, full image ref, or sha256 digest"
required: true
type: string
environment_name:
description: "GitHub environment name for personal deployment"
required: false
default: personal-production
type: string
confirm_release_id:
description: "Release tag to deploy, for example v0.1.0"
required: true
type: string
env:
REGISTRY: ghcr.io
IMAGE_NAME: g1331/autorouter
concurrency:
group: personal-deploy-${{ inputs.environment_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy Released Image
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment_name }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Validate release input
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git fetch --tags origin
git rev-parse "refs/tags/${{ inputs.confirm_release_id }}" >/dev/null
gh release view "${{ inputs.confirm_release_id }}" --repo "${{ github.repository }}" >/dev/null
- name: Resolve image reference
id: image
shell: bash
run: |
set -euo pipefail
RAW_REF="${{ inputs.image_ref }}"
if [[ "${RAW_REF}" == ghcr.io/* ]]; then
IMAGE="${RAW_REF}"
elif [[ "${RAW_REF}" == sha256:* ]]; then
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${RAW_REF}"
else
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${RAW_REF}"
fi
echo "image=${IMAGE}" >> "${GITHUB_OUTPUT}"
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SERVER_PORT || 22 }}
script: |
set -e
DEPLOY_DIR="${{ secrets.DEPLOY_DIR || '/opt/autorouter' }}"
RELEASE_REF="${{ inputs.confirm_release_id }}"
IMAGE="${{ steps.image.outputs.image }}"
COMPOSE_URL="https://raw.githubusercontent.com/${{ github.repository }}/${{ inputs.confirm_release_id }}/docker-compose.yml"
echo "Deploying ${IMAGE} from ${RELEASE_REF} to ${DEPLOY_DIR}"
mkdir -p "${DEPLOY_DIR}"
cd "${DEPLOY_DIR}"
curl -fsSL -o docker-compose.yml "${COMPOSE_URL}"
if [ ! -f .env ]; then
GENERATED_PG_PASS=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
GENERATED_ENCRYPTION_KEY=$(openssl rand -base64 32)
cat > .env << EOF
# Auto-generated by GitHub Actions personal deployment
AUTOROUTER_IMAGE=${IMAGE}
POSTGRES_USER=autorouter
POSTGRES_PASSWORD=${GENERATED_PG_PASS}
POSTGRES_DB=autorouter
DATABASE_URL=postgresql://autorouter:${GENERATED_PG_PASS}@db:5432/autorouter
ENCRYPTION_KEY=${GENERATED_ENCRYPTION_KEY}
ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }}
PORT=3331
EOF
fi
if grep -q "^AUTOROUTER_IMAGE=" .env; then
sed -i "s|^AUTOROUTER_IMAGE=.*|AUTOROUTER_IMAGE=${IMAGE}|" .env
else
echo "AUTOROUTER_IMAGE=${IMAGE}" >> .env
fi
if [ -n "${{ secrets.ADMIN_TOKEN }}" ]; then
if grep -q "^ADMIN_TOKEN=" .env; then
sed -i "s|^ADMIN_TOKEN=.*|ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }}|" .env
else
echo "ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }}" >> .env
fi
else
echo "ERROR: ADMIN_TOKEN secret is not set in GitHub."
exit 1
fi
docker pull "${IMAGE}"
docker compose up -d --remove-orphans
- name: Verify deployment
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SERVER_PORT || 22 }}
script: |
set -e
DEPLOY_DIR="${{ secrets.DEPLOY_DIR || '/opt/autorouter' }}"
EXPECTED_VERSION="${{ inputs.confirm_release_id }}"
cd "${DEPLOY_DIR}"
PORT=$(grep "^PORT=" .env | cut -d= -f2-)
if [ -z "${PORT}" ]; then
PORT=3331
fi
for _ in $(seq 1 12); do
if docker ps --filter "name=^/autorouter$" --format "{{.Status}}" | grep -q "healthy"; then
break
fi
sleep 5
done
STATUS=$(docker ps --filter "name=^/autorouter$" --format "{{.Status}}")
if [ -z "${STATUS}" ]; then
echo "autorouter container is not running"
docker compose logs --tail=100
exit 1
fi
HEALTH_JSON=$(curl -fsS "http://localhost:${PORT}/api/health")
APP_VERSION=$(printf "%s" "${HEALTH_JSON}" | tr -d '\n' | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
if [ "${APP_VERSION}" != "${EXPECTED_VERSION#v}" ]; then
echo "Unexpected application version: ${APP_VERSION}"
printf "%s\n" "${HEALTH_JSON}"
exit 1
fi
curl -fsS -H "Authorization: Bearer ${{ secrets.ADMIN_TOKEN }}" "http://localhost:${PORT}/api/admin/health?active_only=true" >/dev/null
docker exec -i \
-e AUTOROUTER_ADMIN_TOKEN="${{ secrets.ADMIN_TOKEN }}" \
-e AUTOROUTER_APP_PORT="${PORT}" \
-e AUTOROUTER_SMOKE_PREFIX="deploy-smoke-${{ github.run_id }}" \
autorouter node <<'NODE'
const http = require("node:http");
const { setTimeout: delay } = require("node:timers/promises");
const appPort = process.env.PORT || process.env.AUTOROUTER_APP_PORT || "3000";
const baseUrl = `http://127.0.0.1:${appPort}`;
const adminToken = process.env.AUTOROUTER_ADMIN_TOKEN;
const smokePrefix = process.env.AUTOROUTER_SMOKE_PREFIX || `deploy-smoke-${Date.now()}`;
const mockPort = 3101;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function fetchJson(url, init, expectedStatuses) {
const response = await fetch(url, init);
const bodyText = await response.text();
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const parsedBody =
bodyText && contentType.includes("application/json")
? JSON.parse(bodyText)
: bodyText || null;
if (!expectedStatuses.includes(response.status)) {
throw new Error(
`Unexpected response ${response.status} for ${init?.method || "GET"} ${url}: ${
typeof parsedBody === "string" ? parsedBody : JSON.stringify(parsedBody)
}`
);
}
return parsedBody;
}
async function main() {
assert(adminToken, "Missing AUTOROUTER_ADMIN_TOKEN");
const requests = [];
const server = http.createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
const bodyText = Buffer.concat(chunks).toString("utf8");
let payload = {};
try {
payload = bodyText ? JSON.parse(bodyText) : {};
} catch {
payload = {};
}
const pathname = new URL(request.url || "/", "http://127.0.0.1").pathname;
const authHeader = String(request.headers.authorization || "");
requests.push({ pathname, authHeader, payload });
if (pathname !== "/deploy/v1/chat/completions") {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: { message: `Unhandled path ${pathname}` } }));
return;
}
assert(
authHeader === "Bearer deploy-smoke-upstream",
"Upstream auth header was not rewritten to the upstream credential"
);
if (payload.stream === true) {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
response.write(
`data: ${JSON.stringify({
id: "deploy-stream-1",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant" } }],
})}\n\n`
);
await delay(30);
response.write(
`data: ${JSON.stringify({
id: "deploy-stream-1",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { content: "deploy stream ok" } }],
})}\n\n`
);
await delay(30);
response.write(
`data: ${JSON.stringify({
usage: {
prompt_tokens: 6,
completion_tokens: 3,
total_tokens: 9,
},
})}\n\n`
);
response.end("data: [DONE]\n\n");
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
id: "deploy-chat-1",
object: "chat.completion",
model: "gpt-4.1-smoke",
choices: [
{
index: 0,
message: { role: "assistant", content: "deploy completion ok" },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 6,
completion_tokens: 4,
total_tokens: 10,
},
})
);
});
const resources = {
apiKeyIds: [],
upstreamIds: [],
};
try {
await new Promise((resolve, reject) => {
server.listen(mockPort, "127.0.0.1", resolve);
server.on("error", reject);
});
const adminHeaders = {
"content-type": "application/json",
authorization: `Bearer ${adminToken}`,
};
const upstream = await fetchJson(
`${baseUrl}/api/admin/upstreams`,
{
method: "POST",
headers: adminHeaders,
body: JSON.stringify({
name: `${smokePrefix}-upstream`,
base_url: `http://127.0.0.1:${mockPort}/deploy/v1`,
api_key: "deploy-smoke-upstream",
timeout: 2,
weight: 1,
priority: 0,
route_capabilities: ["openai_chat_compatible"],
}),
},
[201]
);
resources.upstreamIds.unshift(upstream.id);
const apiKey = await fetchJson(
`${baseUrl}/api/admin/keys`,
{
method: "POST",
headers: adminHeaders,
body: JSON.stringify({
name: `${smokePrefix}-key`,
access_mode: "restricted",
upstream_ids: [upstream.id],
}),
},
[201]
);
resources.apiKeyIds.unshift(apiKey.id);
const proxyHeaders = {
"content-type": "application/json",
authorization: `Bearer ${apiKey.key_value}`,
};
const completionResponse = await fetch(
`${baseUrl}/api/proxy/v1/chat/completions`,
{
method: "POST",
headers: proxyHeaders,
body: JSON.stringify({
model: "gpt-4.1-smoke",
messages: [{ role: "user", content: "deployment smoke completion" }],
}),
}
);
assert(
completionResponse.status === 200,
`Deployment smoke completion returned ${completionResponse.status}`
);
const completionBody = await completionResponse.json();
assert(
completionBody?.choices?.[0]?.message?.content === "deploy completion ok",
"Deployment smoke completion body mismatch"
);
const streamResponse = await fetch(`${baseUrl}/api/proxy/v1/chat/completions`, {
method: "POST",
headers: proxyHeaders,
body: JSON.stringify({
model: "gpt-4.1-smoke",
stream: true,
messages: [{ role: "user", content: "deployment smoke stream" }],
}),
});
assert(
streamResponse.status === 200,
`Deployment smoke stream returned ${streamResponse.status}`
);
assert(
String(streamResponse.headers.get("content-type") || "")
.toLowerCase()
.includes("text/event-stream"),
"Deployment smoke stream did not return SSE content type"
);
const streamText = await streamResponse.text();
assert(streamText.includes("deploy stream ok"), "Deployment smoke stream body mismatch");
assert(streamText.includes("[DONE]"), "Deployment smoke stream missing [DONE]");
assert(requests.length >= 2, "Deployment smoke mock upstream did not receive requests");
} finally {
for (const apiKeyId of resources.apiKeyIds) {
try {
await fetchJson(
`${baseUrl}/api/admin/keys/${apiKeyId}`,
{
method: "DELETE",
headers: { authorization: `Bearer ${adminToken}` },
},
[204]
);
} catch {
// Best effort cleanup only.
}
}
for (const upstreamId of resources.upstreamIds) {
try {
await fetchJson(
`${baseUrl}/api/admin/upstreams/${upstreamId}`,
{
method: "DELETE",
headers: { authorization: `Bearer ${adminToken}` },
},
[204]
);
} catch {
// Best effort cleanup only.
}
}
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}).catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
NODE
- name: Write workflow summary
shell: bash
run: |
{
echo "## Personal Deployment Complete"
echo
echo "- Release tag: \`${{ inputs.confirm_release_id }}\`"
echo "- Image: \`${{ steps.image.outputs.image }}\`"
echo "- Environment: \`${{ inputs.environment_name }}\`"
echo "- Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
} >> "${GITHUB_STEP_SUMMARY}"