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
31 changes: 31 additions & 0 deletions .github/workflows/availability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Check production availability

on:
schedule:
- cron: '7,22,37,52 * * * *'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: production-availability
cancel-in-progress: false

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Check public website and documentation
run: |
for attempt in 1 2 3; do
if node scripts/check-availability.mjs; then exit 0; fi
if [ "$attempt" -eq 3 ]; then exit 1; fi
echo "Retrying public availability checks in 20 seconds (attempt $attempt)."
sleep 20
done
5 changes: 5 additions & 0 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ caches may retain the preceding version for up to five minutes.

## 8. Monitor cost and availability

The [operations guide](operations.md) describes the scheduled public checks,
verified failure-notification setting and email Worker diagnostics. The
availability workflow runs every fifteen minutes and does not require a
Cloudflare token. Budget notifications remain separate.

The migrated archive contains 52,385 documentation files and 1.139 GB of object
data. R2 Standard includes 10 GB-month, one million Class A operations, and ten
million Class B operations each month. The initial direct uploads and checksum
Expand Down
77 changes: 77 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Production availability checks

The `Check production availability` GitHub workflow checks the public site at
minutes 7, 22, 37 and 52 of every hour. It uses Node 24 without installing
dependencies, deploying anything, or accessing Cloudflare credentials.

The checks cover the Astro home and download pages, a classic URL redirect
with its query string, the users archive, robots rules, the documentation
sitemap, latest and versioned reference URLs, and PDF headers plus a 16-byte
range. When the selected production `DOC_REVISIONS` entry is present, they also
check the modeling manual and its search JavaScript. Version and revision come
from `workers/docs/wrangler.jsonc`; update that configuration when promoting
documentation. Each run makes ten requests, or twelve with the modeling manual,
and caps each response read at 1 MB. It never downloads the complete PDF.

Transient failures get two retries, twenty seconds apart. Persistent failures
fail the workflow and leave the failing path in its log. Check the failed path,
the latest Pages and Worker deployments, and Cloudflare Workers logs. A version
or revision mismatch can mean the checked-in promotion has not reached
production yet. Use the deployment runbook for rollback; this workflow never
changes traffic automatically.

## Run manually

From the website checkout:

```sh
node scripts/check-availability.mjs
gh workflow run availability.yml --repo Gecode/gecode.github.io --ref main
gh run list --repo Gecode/gecode.github.io --workflow availability.yml --limit 5
```

Use `scripts/docs/smoke-worker.mjs` for the broader documentation checks after a
deployment. The scheduled checks deliberately use a smaller request set.

## Make failures reach a maintainer

The responsible maintainer must enable GitHub Actions email or web notifications
and select failed workflows only. Scheduled-run notifications go to the user
who created or last changed the schedule, or who re-enabled it. Repository
workflow success alone does not establish that anyone receives failure alerts.
Confirm that maintainer's settings before relying on this workflow.
See [GitHub workflow notifications](https://docs.github.com/en/actions/concepts/workflows-and-actions/notifications-for-workflow-runs).

On 5 September 2026, the authenticated `zayenz` account's settings were checked:
Actions email notifications were enabled for failed workflows only. Keep that
account as the schedule owner, or verify the replacement owner's settings.

GitHub schedules can be delayed or dropped under load. Public-repository
schedules are automatically disabled after sixty days without repository
activity. Check that this workflow remains enabled during routine maintenance;
re-enable it when necessary. This is a periodic availability check, with no
guaranteed detection time, and cannot detect every regional or intermittent
failure. See [GitHub schedule limitations](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule).

Cloudflare's budget notification remains separate from availability monitoring.
A passive origin alert can complement these checks, but it does not cover
every failure generated by the documentation Worker or R2. A Workers
Observability notification also needs an actual alert rule; the notification
destination alone does not monitor errors.

## Email forwarding

The email Worker waits for every configured forward to finish and reports any
forwarding failure. Native Worker logging is enabled; the handler does not log
message bodies. Deploy it separately from the website and documentation:

```sh
gh workflow run workers.yml --repo Gecode/gecode.github.io --ref main \
-f operation=deploy -f environment=production -f worker=email
```

The routing tests cover address selection and partial forwarding failure.
They do not establish inbox delivery. Use Cloudflare Email Routing delivery
analytics for that check; its zone-level Analytics Read permission is separate
from the website deployment token. A failed availability run does not diagnose
mail delivery, because the public HTTP checks exercise only the website.
112 changes: 112 additions & 0 deletions scripts/check-availability.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";

const origin = "https://www.gecode.dev";
const config = JSON.parse(await readFile("workers/docs/wrangler.jsonc", "utf8")).env.production.vars;
const version = config.LATEST_DOC_VERSION;
const revision = JSON.parse(config.DOC_REVISIONS ?? "{}")[version];
assert(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(version), "Invalid production documentation version");
assert(revision === undefined || /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(revision),
"Invalid production documentation revision");

async function bytes(response) {
const chunks = [];
let length = 0;
for await (const chunk of response.body) {
length += chunk.length;
assert(length <= 1_000_000, "Availability response exceeded 1 MB");
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

async function check(path, status, verify, options = {}) {
const response = await fetch(`${origin}${path}`, {
redirect: "manual", signal: AbortSignal.timeout(10_000), ...options,
});
try {
assert.equal(response.status, status, `${path}: HTTP status`);
await verify(response);
console.log(`${options.method ?? "GET"} ${path}: ${status}`);
} catch (error) {
throw new Error(`${path}: ${error.message}`, { cause: error });
} finally {
if (!response.bodyUsed) await response.body?.cancel();
}
}

function docsHeaders(response, relative, indexable) {
assert.equal(response.headers.get("x-gecode-documentation-version"), version, "Selected version");
assert.equal(response.headers.get("x-gecode-documentation-revision"), revision ?? "legacy", "Selected revision");
assert.equal(/\bnoindex\b/i.test(response.headers.get("x-robots-tag") ?? ""), !indexable, "Indexing policy");
assert.equal(response.headers.get("link"), indexable
? `<${origin}/doc/latest/${relative}>; rel="canonical"` : null, "Documentation canonical");
}

for (const path of ["/", "/download/"]) {
await check(path, 200, async (response) => {
assert.match(response.headers.get("content-type"), /text\/html/);
const html = (await bytes(response)).toString();
assert.match(html, /<meta name="generator" content="Astro /, "Astro page");
assert(html.includes(`<link rel="canonical" href="${origin}${path}">`), "Website canonical");
assert(!/\bnoindex\b/i.test(response.headers.get("x-robots-tag") ?? ""), "Website must remain indexable");
});
}
await check("/download.html?availability=1", 308, (response) => {
assert.equal(response.headers.get("location"), `${origin}/download/?availability=1`);
});
await check("/users-archive/index.html", 200, async (response) => {
assert.match(response.headers.get("content-type"), /text\/html/);
assert.match((await bytes(response)).toString(), /Gecode users archive/);
});
await check("/robots.txt?availability=1", 200, async (response) => {
assert.match(response.headers.get("content-type"), /text\/plain/);
const text = (await bytes(response)).toString();
assert(text.includes(`Sitemap: ${origin}/doc/sitemap.xml`));
assert.doesNotMatch(text, /^Disallow:\s*\/(?:\s*$|doc(?:\/latest)?\/?\s*$|doc-latest)/m,
"Robots rules must allow latest documentation crawling");
});
await check("/doc/latest/reference/index.html", 200, async (response) => {
docsHeaders(response, "reference/index.html", true);
assert.match(response.headers.get("content-type"), /text\/html/);
assert.match((await bytes(response)).toString(), /<html\b/i);
});
await check(`/doc/${version}/reference/index.html`, 200, (response) => {
docsHeaders(response, "reference/index.html", false);
assert.match(response.headers.get("content-type"), /text\/html/);
}, { method: "HEAD" });
await check("/doc/sitemap.xml", 200, async (response) => {
assert.match(response.headers.get("content-type"), /xml/);
const xml = (await bytes(response)).toString();
assert.match(xml, /<sitemapindex\b/);
const locations = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
assert(locations.length > 0 && locations.every((url) => url.startsWith(`${origin}/doc/latest/`)),
"Documentation sitemap must advertise latest URLs");
});
const pdf = `/doc/${version}/MPG.pdf`;
await check(pdf, 200, (response) => {
docsHeaders(response, "MPG.pdf", false);
assert.match(response.headers.get("content-type"), /application\/pdf/);
assert(Number(response.headers.get("content-length")) > 16, "PDF size");
}, { method: "HEAD" });
await check(pdf, 206, async (response) => {
docsHeaders(response, "MPG.pdf", false);
assert.equal(response.headers.get("content-length"), "16");
assert.match(response.headers.get("content-range"), /^bytes 0-15\/\d+$/);
const body = await bytes(response);
assert.equal(body.length, 16);
assert.equal(body.subarray(0, 5).toString(), "%PDF-");
}, { headers: { Range: "bytes=0-15" } });

if (revision) {
await check("/doc/latest/modeling/index.html", 200, async (response) => {
docsHeaders(response, "modeling/index.html", true);
assert.match(response.headers.get("content-type"), /text\/html/);
assert.match((await bytes(response)).toString(), /pagefind/i, "Modeling search UI");
});
await check("/doc/latest/modeling/pagefind/pagefind.js", 200, async (response) => {
assert.match(response.headers.get("content-type"), /(?:text|application)\/javascript/);
assert((await bytes(response)).length > 0, "Modeling search runtime");
});
}
console.log(`Production availability passed: documentation ${version}, revision ${revision ?? "legacy"}.`);
3 changes: 2 additions & 1 deletion workers/email/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"name": "gecode-email-routing",
"main": "src/index.ts",
"compatibility_date": "2026-08-08",
"workers_dev": false
"workers_dev": false,
"observability": { "enabled": true }
}
Loading