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
54 changes: 52 additions & 2 deletions .github/workflows/jekyll-gh-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ name: Deploy fastled.io
on:
push:
branches: ["gh-pages"]
schedule:
- cron: '47 10 * * *'
workflow_dispatch:
inputs:
docs_only:
description: Deploy only if the upstream documentation publication changed
type: boolean
default: false

permissions:
contents: read
Expand All @@ -13,9 +20,48 @@ concurrency:
cancel-in-progress: false

jobs:
build:
check:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
publication: ${{ steps.docs.outputs.publication }}
changed: ${{ steps.docs.outputs.changed }}
deploy: ${{ steps.docs.outputs.deploy }}
release_id: ${{ steps.docs.outputs.release_id }}
tag: ${{ steps.docs.outputs.tag }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Check the published documentation generation
id: docs
env:
DOCS_ONLY: ${{ inputs.docs_only || false }}
run: python3 scripts/import_docs.py check

record-check:
needs: check
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Record a successful documentation check at most monthly
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.check.outputs.release_id }}
RELEASE_TAG: ${{ needs.check.outputs.tag }}
RELEASE_CHANGED: ${{ needs.check.outputs.changed }}
run: python3 scripts/record_check.py

build:
needs: check
if: needs.check.outputs.deploy == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
pages: read
Expand All @@ -29,7 +75,11 @@ jobs:
- name: Stage static site
run: |
mkdir _site
git archive --format=tar HEAD -- . ':!.github' ':!README.md' | tar -xf - -C _site
git archive --format=tar HEAD -- . ':!.github' ':!README.md' ':!scripts' ':!.gitignore' | tar -xf - -C _site
- name: Import the verified documentation publication
env:
PUBLICATION_JSON: ${{ needs.check.outputs.publication }}
run: python3 scripts/import_docs.py import --site _site
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
_site/
__pycache__/
6 changes: 0 additions & 6 deletions 404.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not found — FastLED</title>
<script>
const match = window.location.pathname.match(/^\/(?:fastled-fastled\.github\.io\/)?docs(?:\/(.*))?$/);
if (match) {
window.location.replace('https://fastled.github.io/dox/' + (match[1] || '') + window.location.search + window.location.hash);
}
</script>
</head>
<body>
<h1>Page not found</h1>
Expand Down
55 changes: 44 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,57 @@
# fastled.io

This repository hosts the static [FastLED website](https://fastled.io/).
Its deployment workflow uploads the tracked site files directly to GitHub Pages.
Its publishing workflow assembles the website and documentation into a GitHub
Pages deployment. Generated documentation is never committed to Git.

## API documentation

The API reference is published separately at https://fastled.github.io/dox/ by
[FastLED/dox](https://github.com/FastLED/dox), which checks daily for a new stable
FastLED release. This repository contains no generated Doxygen reference.
[FastLED/dox](https://github.com/FastLED/dox) builds the documentation from the
exact FastLED release tag. Its standalone site remains available at
https://fastled.github.io/dox/.

The homepage links directly to the new reference. `/docs/` redirects there;
the custom 404 page redirects old `/docs/...` deep links to corresponding pages,
preserving their query parameters and fragments. GitHub Pages serves those deep
redirects with HTTP 404; browsers follow the JavaScript redirect.
This website imports that same generated output under
https://fastled.io/docs/, so matching existing documentation URLs serve their
content directly. Navigation, search, styles, scripts, and images come from the
upstream bundle. The website does not run Doxygen or check out FastLED master.

### Publication and update checks

Dox publishes a small [publication manifest](https://fastled.github.io/dox/publication.json)
with the source release, publication generation, and SHA-256/size of a versioned
GitHub release asset. These downloads outlive the one-day Pages Actions artifact.
`scripts/import_docs.py` verifies the download and its embedded release metadata
before adding the documentation to `_site/docs/`. Import errors fail the workflow
before deployment, leaving the live website intact.

The existing publishing workflow checks daily at **10:47 UTC**. A changed
publication triggers deployment, including an intentional rebuild of the same
release tag. Unchanged documentation checks skip build and deployment. Normal
website pushes still deploy the website with the latest published documentation.
Use **Run workflow → docs_only: true** for an immediate check that deploys only
if documentation changed.

As in dox, `scripts/record_check.py` records a successful check at most every
30 days on `automation/release-checks`. This meaningful audit keeps the scheduled
workflow active during long periods without website edits. Its write permission
is restricted to the audit job and it does not publish the site.

## Local verification

```sh
uv run --no-project python -m unittest discover -s scripts -p 'test_*.py'
```

The importer uses Python 3.12+ and the standard library. A docs import reads the
validated manifest from `PUBLICATION_JSON` and stages into the directory passed
to `python3 scripts/import_docs.py import --site _site`.

## Site migration

The non-documentation site was copied from `FastLED/FastLED`'s former `gh-pages`
branch at `2c2c9e489231912576eed208caeeed1a3392792f`, preserving PaletteKnife's
newer script fixes in this repository. Generated documentation and its history
were excluded. The Pages custom domain is `fastled.io`; DNS remains on GitHub
Pages. Migration history is tracked in
[FastLED/FastLED#4878](https://github.com/FastLED/FastLED/issues/4878).
were excluded. The Pages custom domain remains `fastled.io` with the existing
GitHub Pages DNS records. See
[FastLED/FastLED#4878](https://github.com/FastLED/FastLED/issues/4878) and
[issue #9](https://github.com/FastLED/fastled-fastled.github.io/issues/9).
17 changes: 0 additions & 17 deletions docs/index.html

This file was deleted.

190 changes: 190 additions & 0 deletions scripts/import_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Import a verified FastLED/dox publication into the website deployment."""

import argparse
import hashlib
import json
import os
import re
import tarfile
import tempfile
import urllib.error
import urllib.request
from pathlib import Path, PurePosixPath
from urllib.parse import urlencode

UPSTREAM = "https://fastled.github.io/dox/publication.json"
CURRENT = "https://fastled.io/docs/publication.json"
MAX_ARCHIVE = 2 * 1024**3
MAX_EXTRACTED = 10 * 1024**3


def validate_manifest(manifest: dict) -> None:
try:
generation = manifest["generation"]
release = manifest["release"]
archive = manifest["archive"]
valid = (
manifest["schema_version"] == 1
and isinstance(generation, str)
and re.fullmatch(r"[0-9]+-[0-9]+", generation)
and release["generation"] == generation
and type(release["id"]) is int
and release["id"] > 0
and re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", release["tag_name"])
and re.fullmatch(r"[0-9a-f]{40}", release["source_sha"])
and isinstance(release["doxygen_version"], str)
and release["doxygen_version"]
and archive["url"]
== f"https://github.com/FastLED/dox/releases/download/docs-{generation}/docs.tar.gz"
and re.fullmatch(r"[0-9a-f]{64}", archive["sha256"])
and type(archive["size"]) is int
and 0 < archive["size"] <= MAX_ARCHIVE
)
except (KeyError, TypeError):
valid = False
if not valid:
raise ValueError("Invalid documentation publication manifest")


def fetch_manifest(url: str, *, missing_ok: bool = False) -> dict | None:
request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"})
try:
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read(65537)
except urllib.error.HTTPError as error:
if error.code == 404 and missing_ok:
return None
raise
if len(data) > 65536:
raise ValueError("Documentation manifest exceeds 64 KiB")
manifest = json.loads(data)
validate_manifest(manifest)
return manifest


def check_publication(upstream: str, current: str) -> tuple[dict, bool]:
manifest = fetch_manifest(upstream)
deployed = fetch_manifest(current, missing_ok=True)
return manifest, manifest != deployed
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def download_archive(manifest: dict, destination: Path) -> None:
validate_manifest(manifest)
archive = manifest["archive"]
size = 0
digest = hashlib.sha256()
with urllib.request.urlopen(archive["url"], timeout=60) as response:
with destination.open("wb") as output:
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > archive["size"]:
raise ValueError("Documentation download exceeds manifest size")
digest.update(chunk)
output.write(chunk)
if size != archive["size"] or digest.hexdigest() != archive["sha256"]:
raise ValueError("Documentation download checksum or size mismatch")


def stage_archive(archive: Path, site: Path, manifest: dict) -> None:
validate_manifest(manifest)
if archive.stat().st_size != manifest["archive"]["size"]:
raise ValueError("Documentation archive size mismatch")
with archive.open("rb") as source:
if (
hashlib.file_digest(source, "sha256").hexdigest()
!= manifest["archive"]["sha256"]
):
raise ValueError("Documentation archive checksum mismatch")
site.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix="dox-import-", dir=site.parent
) as temporary:
staging = Path(temporary) / "docs"
staging.mkdir()
with tarfile.open(archive, "r:gz") as tar:
members = tar.getmembers()
total = 0
for member in members:
path = PurePosixPath(member.name)
if (
path.is_absolute()
or ".." in path.parts
or "\\" in member.name
or not (member.isfile() or member.isdir())
or (member.isfile() and not path.parts)
):
raise ValueError(
f"Unsafe documentation archive entry: {member.name}"
)
if member.isfile():
total += member.size
if total > MAX_EXTRACTED:
raise ValueError(
"Documentation archive expands beyond Pages size limit"
)
tar.extractall(staging, members=members, filter="data")
actual_release = json.loads((staging / "release.json").read_text())
if actual_release != manifest["release"]:
raise ValueError("Archive release metadata does not match publication")
for required in ("index.html", "annotated.html", "search/search.js"):
path = staging / required
if not path.is_file() or path.stat().st_size == 0:
raise ValueError(f"Missing required documentation output: {required}")
(staging / "publication.json").write_text(json.dumps(manifest, indent=2) + "\n")
destination = site / "docs"
backup = Path(temporary) / "previous"
if destination.exists():
destination.rename(backup)
try:
staging.rename(destination)
except OSError:
if backup.exists():
backup.rename(destination)
raise
print(
f"Staged FastLED {actual_release['tag_name']} publication {manifest['generation']}"
)


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("check", "import"))
parser.add_argument("--site", type=Path, default=Path("_site"))
args = parser.parse_args()
if args.command == "check":
query = urlencode(
{
"run": os.environ["GITHUB_RUN_ID"],
"attempt": os.environ["GITHUB_RUN_ATTEMPT"],
}
)
manifest, changed = check_publication(
UPSTREAM + "?" + query, CURRENT + "?" + query
)
docs_only = os.environ["GITHUB_EVENT_NAME"] == "schedule" or (
os.environ.get("DOCS_ONLY") == "true"
)
outputs = {
"publication": json.dumps(manifest, separators=(",", ":")),
"changed": str(changed).lower(),
"deploy": str(changed or not docs_only).lower(),
"release_id": str(manifest["release"]["id"]),
"tag": manifest["release"]["tag_name"],
}
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
for key, value in outputs.items():
output.write(f"{key}={value}\n")
print(
f"Upstream publication {manifest['generation']}; update required: {outputs['deploy']}"
)
else:
manifest = json.loads(os.environ["PUBLICATION_JSON"])
validate_manifest(manifest)
with tempfile.TemporaryDirectory(prefix="dox-download-") as temporary:
archive = Path(temporary) / "docs.tar.gz"
download_archive(manifest, archive)
stage_archive(archive, args.site, manifest)


if __name__ == "__main__":
main()
Loading
Loading