From 569b1a659ae0d694db702c17c8c263fe765aa876 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 22 Oct 2026 13:40:19 -0400 Subject: [PATCH] ci(docs): track mainline Cloudflare deployments --- .github/workflows/production-deployment.yml | 65 +++++ README.md | 41 ++- docusaurus.config.ts | 1 + plugins/deployment-metadata/index.cjs | 31 +++ scripts/track-production-deployment.cjs | 146 +++++++++++ .../track-production-deployment.node-test.cjs | 241 ++++++++++++++++++ static/_headers | 5 +- 7 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/production-deployment.yml create mode 100644 plugins/deployment-metadata/index.cjs create mode 100644 scripts/track-production-deployment.cjs create mode 100644 scripts/track-production-deployment.node-test.cjs diff --git a/.github/workflows/production-deployment.yml b/.github/workflows/production-deployment.yml new file mode 100644 index 00000000000..8f5c9bc3e87 --- /dev/null +++ b/.github/workflows/production-deployment.yml @@ -0,0 +1,65 @@ +name: Production Deployment + +on: + push: + branches: [main] + workflow_dispatch: + pull_request: + paths: + - .github/workflows/production-deployment.yml + - scripts/track-production-deployment* + - plugins/deployment-metadata/** + - docusaurus.config.ts + - static/_headers + +permissions: + contents: read + +concurrency: + group: production-deployment-${{ github.sha }} + cancel-in-progress: false + +jobs: + test: + name: Test deployment tracking + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node --test scripts/track-production-deployment.node-test.cjs + + track: + name: Track Cloudflare production deployment + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: test + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + checks: read + deployments: write + steps: + - uses: actions/checkout@v7 + - name: Track build and verify public commit + id: deployment + uses: actions/github-script@v8 + with: + script: | + const track = require('./scripts/track-production-deployment.cjs'); + await track({ github, context, core }); + - name: Record interrupted tracking + if: cancelled() && steps.deployment.outputs.deployment_id != '' + uses: actions/github-script@v8 + env: + DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }} + with: + script: | + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: Number(process.env.DEPLOYMENT_ID), + state: 'error', + description: 'Tracking was interrupted; production state is unverified.', + auto_inactive: false, + }); diff --git a/README.md b/README.md index 0329bc5483b..decd312d1e2 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ This repository powers the public [Unraid Docs](https://docs.unraid.net) site. It contains source content, UI components, and supporting scripts that help the Unraid team publish accurate, localized, and accessible documentation for Unraid OS and connected services. -The documentation site is built on [Docusaurus 3](https://docusaurus.io/), rendered as a static site, and deployed via GitHub Pages. Remark-based linting, Crowdin integrations, and automated formatting scripts keep Markdown/MDX contributions consistent across the project. +The documentation site is built on [Docusaurus 3](https://docusaurus.io/), rendered as a static site, and deployed to Cloudflare Workers. Remark-based linting, Crowdin integrations, and automated formatting scripts keep Markdown/MDX contributions consistent across the project.

(back to top)

@@ -179,6 +179,45 @@ Additional project scripts:

(back to top)

+## Production deployment tracking + +Every push to `main` starts [Production Deployment](.github/workflows/production-deployment.yml). +The workflow creates a `Production` deployment in GitHub for that exact commit. +Cloudflare continues to build and deploy the site through its existing Git integration. +The tracking workflow does not deploy code. + +Open the repository's **Deployments** view and select **Production** to see each commit's status: + +* **Queued**: Waiting for the Cloudflare build result. +* **In progress**: Cloudflare is building the commit, or the workflow is checking the public site. +* **Success**: The public site returned the expected commit SHA and Cloudflare build ID. +* **Failure**: Cloudflare reported an unsuccessful build, including a canceled or skipped build. +* **Error**: Tracking failed or the public deployment could not be verified within the time limit. + +Each record links to the site and its GitHub tracking log. The log includes the Cloudflare build link. +The workflow waits up to 25 minutes for Cloudflare and another five minutes for public verification. +It uses the repository's automatic GitHub token. No additional Cloudflare secret is required. + +Cloudflare builds publish `deployment.json` through a Docusaurus plugin. +The file contains the commit SHA, build ID, and branch from Cloudflare's build environment. +The default locale writes the file at the site root. Local builds do not write this metadata. +The file has a `no-store` cache policy, and verification requests include the build ID. +A blocked request, stale page, or different build cannot produce a successful deployment record. + +To repeat verification, rerun the workflow for the affected commit. +**Run workflow** on `main` verifies the current mainline commit. +A repeat run creates a new tracking record. It does not rebuild or redeploy the site. +Older commits can remain unverified if a newer deployment replaced them before verification. +Search indexing has its own workflow and is not part of the production deployment status. + +Run the tracking tests without building the site: + +```bash +node --test scripts/track-production-deployment.node-test.cjs +``` + +

(back to top)

+ ## Algolia Reindex The docs search index is refreshed by the GitHub Actions workflow at [`.github/workflows/algolia-reindex.yml`](.github/workflows/algolia-reindex.yml). diff --git a/docusaurus.config.ts b/docusaurus.config.ts index b0d0ed479e7..f33a6033126 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -298,6 +298,7 @@ const config: Config = { } satisfies Preset.ThemeConfig, plugins: [ + path.resolve(__dirname, "./plugins/deployment-metadata/index.cjs"), [ path.resolve(__dirname, "./plugins/structured-data/index.mjs"), { diff --git a/plugins/deployment-metadata/index.cjs b/plugins/deployment-metadata/index.cjs new file mode 100644 index 00000000000..d1c7f6ca206 --- /dev/null +++ b/plugins/deployment-metadata/index.cjs @@ -0,0 +1,31 @@ +const { writeFile } = require("node:fs/promises"); +const path = require("node:path"); + +module.exports = function deploymentMetadata(context) { + return { + name: "deployment-metadata", + async postBuild({ outDir }) { + if ( + process.env.WORKERS_CI !== "1" || + context.i18n.currentLocale !== context.i18n.defaultLocale + ) + return; + const sha = process.env.WORKERS_CI_COMMIT_SHA; + const buildId = process.env.WORKERS_CI_BUILD_UUID; + if ( + !/^[a-f0-9]{40}$/.test(sha || "") || + !/^[a-f0-9-]{36}$/.test(buildId || "") + ) { + throw new Error("Cloudflare build identity is missing or invalid"); + } + await writeFile( + path.join(outDir, "deployment.json"), + JSON.stringify({ + sha, + buildId, + branch: process.env.WORKERS_CI_BRANCH, + }) + "\n", + ); + }, + }; +}; diff --git a/scripts/track-production-deployment.cjs b/scripts/track-production-deployment.cjs new file mode 100644 index 00000000000..9934531b81b --- /dev/null +++ b/scripts/track-production-deployment.cjs @@ -0,0 +1,146 @@ +const { appendFile } = require("node:fs/promises"); + +const SITE = "https://docs.unraid.net"; +const CHECK_NAME = "Workers Builds: unraid-docs"; +const APP = "cloudflare-workers-and-pages"; + +module.exports = async function track({ + github, + context, + core, + fetchSite = globalThis.fetch, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now = Date.now, + buildTimeout = 25 * 60_000, + siteTimeout = 5 * 60_000, +}) { + const repo = context.repo; + const sha = context.sha; + const logUrl = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`; + const { data: deployment } = await github.rest.repos.createDeployment({ + ...repo, + ref: sha, + environment: "Production", + task: "track-cloudflare", + auto_merge: false, + required_contexts: [], + production_environment: true, + description: "Track Cloudflare build and verify the public commit.", + }); + core.setOutput("deployment_id", deployment.id); + const status = (state, description) => + github.rest.repos.createDeploymentStatus({ + ...repo, + deployment_id: deployment.id, + state, + description, + environment_url: SITE, + log_url: logUrl, + // Only verified production success can retire earlier deployments. + auto_inactive: state === "success", + }); + let buildUrl = logUrl; + try { + await status("queued", "Waiting for Cloudflare to build this commit."); + const buildDeadline = now() + buildTimeout; + let check; + let building = false; + while (now() < buildDeadline) { + const checks = await github.paginate(github.rest.checks.listForRef, { + ...repo, + ref: sha, + check_name: CHECK_NAME, + filter: "latest", + per_page: 100, + }); + check = checks + .filter( + (item) => + item.name === CHECK_NAME && + item.head_sha === sha && + item.app?.slug === APP, + ) + .sort((a, b) => b.id - a.id)[0]; + if (check) { + buildUrl = check.details_url; + if (check.status === "completed") break; + if (check.status === "in_progress" && !building) { + await status("in_progress", "Cloudflare is building this commit."); + building = true; + } + } + await sleep(30_000); + } + if (check?.status !== "completed") { + throw new Error("Timed out waiting for Cloudflare's build result."); + } + core.info(`Cloudflare build: ${buildUrl}`); + if (check.conclusion !== "success") { + await status( + "failure", + `Cloudflare build concluded: ${check.conclusion}.`, + ); + core.setFailed(`Cloudflare build concluded: ${check.conclusion}`); + return; + } + const buildId = new URL(buildUrl).pathname.split("/").pop(); + if (!/^[a-f0-9-]{36}$/.test(buildId)) + throw new Error("Invalid Cloudflare build URL."); + await status( + "in_progress", + "Cloudflare succeeded; verifying the public commit.", + ); + const siteDeadline = now() + siteTimeout; + let observation = "No response"; + while (now() < siteDeadline) { + let marker; + try { + const response = await fetchSite( + `${SITE}/deployment.json?build=${buildId}`, + { + headers: { + "Cache-Control": "no-cache", + Accept: "application/json", + }, + signal: AbortSignal.timeout(15_000), + redirect: "error", + }, + ); + observation = `HTTP ${response.status}`; + if (response.ok) { + marker = await response.json(); + observation = + "Public build identity does not match this commit and build"; + } + } catch { + observation = "Public metadata request failed or returned invalid JSON"; + } + if ( + marker?.sha === sha && + marker.buildId === buildId && + marker.branch === "main" + ) { + await status( + "success", + "Cloudflare build passed and this commit is live.", + ); + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile( + process.env.GITHUB_STEP_SUMMARY, + `Production verified: \`${sha}\` at [docs.unraid.net](${SITE}).\n\n[Cloudflare build](${buildUrl})\n`, + ); + } + return; + } + core.info(observation); + await sleep(15_000); + } + throw new Error(`Public commit verification timed out. ${observation}.`); + } catch (error) { + await status( + "error", + "Deployment could not be verified. See the tracking log.", + ); + core.setFailed(error.message); + } +}; diff --git a/scripts/track-production-deployment.node-test.cjs b/scripts/track-production-deployment.node-test.cjs new file mode 100644 index 00000000000..aa0d33cf66b --- /dev/null +++ b/scripts/track-production-deployment.node-test.cjs @@ -0,0 +1,241 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { mkdtemp, readFile, rm, access } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const track = require("./track-production-deployment.cjs"); +const metadata = require("../plugins/deployment-metadata/index.cjs"); + +const sha = "a".repeat(40); +const buildId = "12345678-abcd-abcd-abcd-123456789abc"; +const buildUrl = `https://dash.cloudflare.com/account/workers/services/view/unraid-docs/production/builds/${buildId}`; +const check = { + id: 1, + name: "Workers Builds: unraid-docs", + head_sha: sha, + app: { slug: "cloudflare-workers-and-pages" }, + status: "completed", + conclusion: "success", + details_url: buildUrl, +}; +const marker = { sha, buildId, branch: "main" }; + +function harness({ checks = [[check]], responses = [marker], apiError } = {}) { + const statuses = [], + failures = [], + requests = [], + queries = [], + deployments = []; + let elapsed = 0; + const args = { + github: { + rest: { + repos: { + createDeployment: async (data) => { + deployments.push(data); + return { data: { id: 12 } }; + }, + createDeploymentStatus: async (data) => { + statuses.push(data); + }, + }, + checks: { listForRef: () => {} }, + }, + paginate: async (_method, query) => { + queries.push(query); + if (apiError) throw new Error("GitHub unavailable"); + return checks.length > 1 ? checks.shift() : checks[0]; + }, + }, + context: { + repo: { owner: "example", repo: "docs" }, + sha, + serverUrl: "https://github.com", + runId: 1, + }, + core: { + setOutput: () => {}, + setFailed: (message) => failures.push(message), + info: () => {}, + }, + now: () => elapsed, + sleep: async (ms) => { + elapsed += ms; + }, + buildTimeout: 60_000, + siteTimeout: 60_000, + fetchSite: async (url, options) => { + requests.push({ url, options }); + const data = responses.length > 1 ? responses.shift() : responses[0]; + if (data instanceof Error) throw data; + if (data === "blocked") return { ok: false, status: 403 }; + if (data === "html") + return { + ok: true, + status: 200, + json: async () => { + throw new Error("HTML"); + }, + }; + return { ok: true, status: 200, json: async () => data }; + }, + }; + return { args, statuses, failures, requests, queries, deployments }; +} + +test("waits for the exact Cloudflare check, then verifies the public build", async () => { + const h = harness({ + checks: [[], [{ ...check, status: "in_progress" }], [check]], + }); + h.args.buildTimeout = 120_000; + await track(h.args); + assert.deepEqual( + h.statuses.map((s) => s.state), + ["queued", "in_progress", "in_progress", "success"], + ); + assert.equal(h.failures.length, 0); + assert.equal(h.deployments[0].ref, sha); + assert.equal(h.deployments[0].auto_merge, false); + assert.deepEqual(h.deployments[0].required_contexts, []); + assert.ok(h.queries.every((q) => q.ref === sha)); + assert.equal( + h.requests[0].url, + `https://docs.unraid.net/deployment.json?build=${buildId}`, + ); + assert.equal(h.requests[0].options.redirect, "error"); + assert.equal( + h.statuses.at(-1).log_url, + "https://github.com/example/docs/actions/runs/1", + ); + assert.equal(h.statuses.at(-1).auto_inactive, true); +}); + +for (const conclusion of [ + "failure", + "cancelled", + "skipped", + "timed_out", + "neutral", +]) { + test(`Cloudflare ${conclusion} cannot produce a successful deployment`, async () => { + const h = harness({ checks: [[{ ...check, conclusion }]] }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "failure"); + assert.equal(h.statuses.at(-1).auto_inactive, false); + assert.equal(h.requests.length, 0); + assert.equal(h.failures.length, 1); + }); +} + +test("ignores checks from other apps, workers, or commits", async () => { + const h = harness({ + checks: [ + [ + { ...check, app: { slug: "someone-else" } }, + { ...check, name: "Workers Builds: preview" }, + { ...check, head_sha: "b".repeat(40) }, + ], + ], + }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "error"); + assert.match(h.failures[0], /Timed out/); + assert.equal(h.requests.length, 0); +}); + +test("uses the newest check when a build is retried", async () => { + const h = harness({ + checks: [[check, { ...check, id: 2, conclusion: "failure" }]], + }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "failure"); +}); + +for (const stale of [ + { ...marker, sha: "b".repeat(40) }, + { ...marker, buildId: "87654321-abcd-abcd-abcd-123456789abc" }, + { ...marker, branch: "preview" }, + null, + "blocked", + "html", + new Error("Network failure"), +]) { + test(`does not report success for an unverified public response: ${JSON.stringify(stale)}`, async () => { + const h = harness({ responses: [stale] }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "error"); + assert.match(h.failures[0], /verification timed out/); + assert.ok(h.statuses.every((s) => !s.auto_inactive)); + }); +} + +test("allows propagation delay before the exact public build appears", async () => { + const h = harness({ + responses: ["blocked", { ...marker, sha: "b".repeat(40) }, marker], + }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "success"); + assert.equal(h.requests.length, 3); +}); + +test("GitHub API errors leave production unverified", async () => { + const h = harness({ apiError: true }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "error"); + assert.match(h.failures[0], /GitHub unavailable/); +}); + +test("rejects a successful check without a valid build identity", async () => { + const h = harness({ + checks: [ + [{ ...check, details_url: "https://dash.cloudflare.com/builds/unknown" }], + ], + }); + await track(h.args); + assert.equal(h.statuses.at(-1).state, "error"); + assert.equal(h.requests.length, 0); +}); + +test("Docusaurus writes Cloudflare metadata only for the default locale", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "docs-deployment-")); + const keys = [ + "WORKERS_CI", + "WORKERS_CI_COMMIT_SHA", + "WORKERS_CI_BUILD_UUID", + "WORKERS_CI_BRANCH", + ]; + const original = Object.fromEntries( + keys.map((key) => [key, process.env[key]]), + ); + const plugin = (locale) => + metadata({ i18n: { currentLocale: locale, defaultLocale: "en" } }); + try { + delete process.env.WORKERS_CI; + await plugin("en").postBuild({ outDir: directory }); + await assert.rejects(access(path.join(directory, "deployment.json"))); + Object.assign(process.env, { + WORKERS_CI: "1", + WORKERS_CI_COMMIT_SHA: sha, + WORKERS_CI_BUILD_UUID: buildId, + WORKERS_CI_BRANCH: "main", + }); + await plugin("fr").postBuild({ outDir: directory }); + await assert.rejects(access(path.join(directory, "deployment.json"))); + await plugin("en").postBuild({ outDir: directory }); + assert.deepEqual( + JSON.parse(await readFile(path.join(directory, "deployment.json"))), + marker, + ); + delete process.env.WORKERS_CI_COMMIT_SHA; + await assert.rejects( + plugin("en").postBuild({ outDir: directory }), + /identity is missing/, + ); + } finally { + for (const key of keys) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/static/_headers b/static/_headers index 29f6047d340..0c9fc3e9bfa 100644 --- a/static/_headers +++ b/static/_headers @@ -2,4 +2,7 @@ X-Frame-Options: ALLOWALL Content-Security-Policy: frame-ancestors *; Cross-Origin-Resource-Policy: cross-origin - Cross-Origin-Opener-Policy: unsafe-none \ No newline at end of file + Cross-Origin-Opener-Policy: unsafe-none + +/deployment.json + Cache-Control: no-store