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
65 changes: 65 additions & 0 deletions .github/workflows/production-deployment.yml
Original file line number Diff line number Diff line change
@@ -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,
});
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

Expand Down Expand Up @@ -179,6 +179,45 @@ Additional project scripts:

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## 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
```

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Algolia Reindex

The docs search index is refreshed by the GitHub Actions workflow at [`.github/workflows/algolia-reindex.yml`](.github/workflows/algolia-reindex.yml).
Expand Down
1 change: 1 addition & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
{
Expand Down
31 changes: 31 additions & 0 deletions plugins/deployment-metadata/index.cjs
Original file line number Diff line number Diff line change
@@ -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",
);
},
};
};
146 changes: 146 additions & 0 deletions scripts/track-production-deployment.cjs
Original file line number Diff line number Diff line change
@@ -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);
}
};
Loading
Loading