cad-editor/.github/workflows/pages.yml
2026-08-04 12:17:21 +03:00

310 lines
12 KiB
YAML

# Build the wasm web app and publish it to GitHub Pages on every release
# (issue #45). No threads are used on wasm (rayon runs sequentially), so the
# COOP/COEP headers GitHub Pages can't set are not needed.
name: Deploy web (GitHub Pages)
run-name: ${{ github.event.release.tag_name || github.ref_name }} Web
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
discussions: read
pages: write
id-token: write
# Allow only one Pages deployment at a time.
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Install trunk
uses: taiki-e/install-action@v2
with:
tool: trunk
- name: Read wasm-bindgen version
id: wasm-bindgen-version
shell: bash
run: |
VERSION=$(sed -n '/name = "wasm-bindgen"/{n;s/version = "\(.*\)"/\1/p;q;}' Cargo.lock)
test -n "$VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Install wasm-bindgen CLI
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }}
# The rust <link> in index.html sets `data-cargo-no-default-features`,
# so this drops the `solid3d` feature (truck-meshalgo → lzma-sys C deps
# that can't cross-compile to wasm). `--public-url` matches the project
# page sub-path: https://<user>.github.io/OpenCADStudio/.
- name: Build web bundle
run: trunk build --release --public-url /OpenCADStudio/
# GitHub Pages runs Jekyll, which ignores files; opt out so the
# wasm-bindgen output is served verbatim.
- run: touch dist/.nojekyll
# The web app can't call the Patreon API directly (CORS + the token would
# be exposed in the bundle), so generate the supporters list server-side
# here — token stays in the CI secret — and serve it next to the app.
# The web build fetches `supporters.json` on the same origin.
- name: Generate supporters.json
env:
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
run: |
python3 - <<'PY'
import json
import os
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
output = Path("dist/supporters.json")
token = os.environ.get("OCS_PATREON_TOKEN", "")
if not token:
output.write_text("[]\n", encoding="utf-8")
raise SystemExit(0)
headers = {
"Authorization": f"Bearer {token}",
"User-Agent": "OpenCADStudio-supporters",
}
def fetch_json(url, authenticated=True):
request = urllib.request.Request(
url,
headers=headers if authenticated else {"User-Agent": headers["User-Agent"]},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
campaigns = fetch_json("https://www.patreon.com/api/oauth2/v2/campaigns")
campaign_id = campaigns["data"][0]["id"]
params = urllib.parse.urlencode({
"include": "pledge_history",
"fields[member]": "full_name",
"fields[pledge-event]": "amount_cents,currency_code,date,payment_status",
"page[count]": "200",
})
url = (
f"https://www.patreon.com/api/oauth2/v2/campaigns/"
f"{campaign_id}/members?{params}"
)
cutoff_date = (datetime.now(timezone.utc) - timedelta(days=31)).date()
payments = []
for _ in range(50):
page = fetch_json(url)
included = {
(item.get("type"), item.get("id")): item.get("attributes", {})
for item in page.get("included", [])
if item.get("type") == "pledge-event"
}
for member in page.get("data", []):
latest = None
history = (
member.get("relationships", {})
.get("pledge_history", {})
.get("data", [])
)
for relationship in history:
event = included.get((relationship.get("type"), relationship.get("id")))
if not event or event.get("payment_status") != "Paid":
continue
cents = int(event.get("amount_cents") or 0)
currency = str(event.get("currency_code") or "").strip().upper()
date_text = str(event.get("date") or "")
try:
paid_at = datetime.fromisoformat(date_text.replace("Z", "+00:00"))
except ValueError:
continue
if cents <= 0 or not currency or paid_at.date() < cutoff_date:
continue
if latest is None or paid_at > latest[0]:
latest = (paid_at, cents, currency)
name = str(member.get("attributes", {}).get("full_name") or "").strip()
if latest is not None and name:
payments.append((name, latest[1], latest[2]))
url = page.get("links", {}).get("next")
if not url:
break
rates = {"USD": Decimal("1")}
if any(currency != "USD" for _, _, currency in payments):
rate_entries = fetch_json(
"https://api.frankfurter.dev/v2/rates?base=USD",
authenticated=False,
)
rates.update({
str(entry["quote"]).upper(): Decimal(str(entry["rate"]))
for entry in rate_entries
if Decimal(str(entry.get("rate", 0))) > 0
})
supporters = []
for name, cents, currency in payments:
rate = rates.get(currency)
if rate is None:
continue
usd_cents = int(
(Decimal(cents) / rate).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
)
if usd_cents > 0:
supporters.append({"name": name, "cents": usd_cents})
supporters.sort(key=lambda item: (-item["cents"], item["name"]))
output.write_text(
json.dumps(supporters, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
print(f"supporters: {len(supporters)}")
PY
# Browsers cannot reliably fetch YouTube playlist/oEmbed responses because
# of CORS. Build a same-origin listing and thumbnail directory for the
# Start page. Keep the checked-in snapshot if YouTube is temporarily
# unavailable during deployment.
- name: Generate videos.json
run: |
python3 - <<'PY'
import json
import re
import shutil
import urllib.request
from pathlib import Path
playlist = "https://youtube.com/playlist?list=PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl"
fallback = Path("web/videos.json")
output = Path("dist/videos.json")
thumbs = Path("dist/video_thumbs")
thumbs.mkdir(parents=True, exist_ok=True)
request_headers = {"User-Agent": "Mozilla/5.0"}
def fetch(url):
request = urllib.request.Request(url, headers=request_headers)
with urllib.request.urlopen(request, timeout=20) as response:
return response.read()
try:
page = fetch(playlist).decode("utf-8", errors="replace")
ids = []
for video_id in re.findall(r'"videoId":"([^"]+)"', page):
if len(video_id) == 11 and video_id not in ids:
ids.append(video_id)
if len(ids) >= 50:
break
entries = []
for video_id in ids:
try:
metadata = json.loads(fetch(
"https://www.youtube.com/oembed"
f"?url=https://youtu.be/{video_id}&format=json"
))
title = str(metadata.get("title", "")).strip()
if not title:
continue
entries.append({"id": video_id, "title": title})
(thumbs / f"{video_id}.jpg").write_bytes(fetch(
f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg"
))
except Exception as error:
print(f"video {video_id}: {error}")
if not entries:
raise RuntimeError("playlist returned no usable videos")
output.write_text(
json.dumps(list(reversed(entries)), ensure_ascii=False),
encoding="utf-8",
)
except Exception as error:
print(f"video snapshot fallback: {error}")
shutil.copyfile(fallback, output)
print(f"videos: {len(json.loads(output.read_text(encoding='utf-8')))}")
PY
# GitHub's Discussions API is authenticated GraphQL. Generate a public,
# token-free snapshot next to the web app; discussion activity (including
# pin/unpin) triggers this workflow so pinned entries stay at the top.
- name: Generate discussions.json
env:
GH_TOKEN: ${{ github.token }}
run: |
QUERY='
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
pinnedDiscussions(first: 10) {
nodes {
discussion {
number title url updatedAt
author { login }
}
}
}
discussions(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number title url updatedAt
author { login }
}
}
}
}'
gh api graphql \
-f query="$QUERY" \
-F owner="$GITHUB_REPOSITORY_OWNER" \
-F name="${GITHUB_REPOSITORY#*/}" \
| jq -c '
.data.repository as $repo
| [$repo.pinnedDiscussions.nodes[].discussion.number] as $pinned
| (
[$repo.pinnedDiscussions.nodes[].discussion + {pinned: true}]
+ [
$repo.discussions.nodes[]
| select((.number as $number | $pinned | index($number)) == null)
| . + {pinned: false}
]
)
| map({
number,
title,
url,
author: (.author.login // ""),
updated_at: .updatedAt,
pinned
})' \
> dist/discussions.json
echo "discussions: $(jq 'length' dist/discussions.json)"
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-22.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4