Files

221 lines
9.3 KiB
YAML

name: Publish REA website
on:
workflow_dispatch:
inputs:
target:
description: Publish both hosts, or Pages while configuring Cloudflare
type: choice
required: true
default: both
options:
- both
- pages
permissions:
contents: read
concurrency:
group: website-pages
cancel-in-progress: false
jobs:
prepare:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pages: read
steps:
- name: Validate the publication target
env:
PUBLISH_TARGET: ${{ inputs.target }}
run: |
case "$PUBLISH_TARGET" in
both|pages) ;;
*) echo "::error::Unknown publication target: $PUBLISH_TARGET"; exit 1 ;;
esac
- name: Require Cloudflare publishing credentials
if: inputs.target == 'both'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [[ -z "$CLOUDFLARE_API_TOKEN" || -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then
echo "::error::Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in repository Actions secrets before publishing."
exit 1
fi
- name: Check out the repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Python for website assets
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: website/requirements.txt
- name: Set up Node.js for the website Worker checks
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Install sharing-image dependencies
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libcairo2 fonts-dejavu-core
python3 -m pip install -r website/requirements.txt
- name: Check shared HTML, links, assets and contributor workflows
run: python3 scripts/website.py check
- name: Record the published commit
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path
Path("website/_site/deployment.json").write_text(
json.dumps({"commit": os.environ["GITHUB_SHA"]}) + "\n",
encoding="utf-8",
)
PY
- name: Configure Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
# upload-pages-artifact excludes dot directories, including .well-known.
- name: Package the website including agent discovery
run: |
tar --dereference --hard-dereference --directory website/_site \
--exclude=.git --exclude=.github -cf "$RUNNER_TEMP/artifact.tar" .
tar -tf "$RUNNER_TEMP/artifact.tar" \
./.well-known/ard.json ./.well-known/ai-catalog.json \
./.well-known/agent-skills/index.json > /dev/null
- name: Upload the website
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: github-pages
path: ${{ runner.temp }}/artifact.tar
retention-days: 1
if-no-files-found: error
deploy:
needs: prepare
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
actions: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out publishing configuration
if: inputs.target == 'both'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Node.js for Wrangler
if: inputs.target == 'both'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Download the verified website
if: inputs.target == 'both'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: github-pages
path: ${{ runner.temp }}/website-archive
- name: Extract the shared publishing artifact
if: inputs.target == 'both'
run: |
mkdir -p "$RUNNER_TEMP/rea-website-public"
tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
- name: Publish the website to Cloudflare
if: inputs.target == 'both'
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4.149.0"
packageManager: npm
command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
- name: Publish to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
- name: Verify the selected published revisions
env:
PAGES_URL: ${{ steps.deployment.outputs.page_url }}
PUBLISH_TARGET: ${{ inputs.target }}
run: |
python3 -u - <<'PY'
import hashlib
import json
import os
import sys
import time
from urllib.error import URLError
from urllib.parse import urljoin, urlsplit
from urllib.request import Request, urlopen
def open_public(url, accept=None):
headers = {
"Cache-Control": "no-cache",
"User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)",
}
if accept:
headers["Accept"] = accept
return urlopen(Request(url, headers=headers), timeout=10)
expected = os.environ["GITHUB_SHA"]
pages_url = os.environ["PAGES_URL"]
if not pages_url:
sys.exit("GitHub Pages returned no published URL.")
target = os.environ["PUBLISH_TARGET"]
if target not in ("both", "pages"):
sys.exit("Unknown publication target: " + target)
hosts = {"GitHub Pages": pages_url}
if target == "both":
hosts["Cloudflare"] = "https://rea.tools/"
pending = dict(hosts)
for attempt in range(24):
for name, base in list(pending.items()):
url = urljoin(base, "deployment.json") + "?commit=" + expected
try:
with open_public(url) as response:
actual = json.load(response).get("commit")
if actual == expected:
print(f"{name} serves {expected}: {base}")
del pending[name]
else:
print(f"Waiting for {name}: observed revision {actual}")
except (URLError, TimeoutError, ValueError) as error:
print(f"Waiting for {name}: {error}")
if not pending:
break
if attempt < 23:
time.sleep(5)
if pending:
sys.exit("Could not verify published revision on: " + ", ".join(pending))
for name, base in hosts.items():
with open_public(urljoin(base, ".well-known/agent-skills/index.json")) as response:
skill, = json.load(response)["skills"]
# Fetch from each selected host, including the /rea/ mirror.
artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/"))
with open_public(artifact_url) as response:
digest = "sha256:" + hashlib.sha256(response.read()).hexdigest()
if digest != skill["digest"]:
sys.exit(name + " serves a skill archive with the wrong digest")
for manifest in ("ard.json", "ai-catalog.json"):
with open_public(urljoin(base, ".well-known/" + manifest)) as response:
if not json.load(response).get("entries"):
sys.exit(name + " serves an empty discovery manifest")
if name == "Cloudflare":
with open_public(base, accept="text/markdown") as response:
vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")}
if response.headers.get_content_type() != "text/markdown" or "accept" not in vary:
sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept")
if 'rel="ard"' not in response.headers.get("Link", ""):
sys.exit("Cloudflare is missing its discovery Link header")
print(name + " serves the discovery manifests and verified skill archive")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
links = " and ".join(f"[{name}]({url})" for name, url in hosts.items())
summary.write(f"Published commit `{expected}` to {links}.\n")
PY