# 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 }} - name: Build web bundle run: trunk build --release --public-url / # 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 # Publish one themed growth chart for stars and release downloads. - name: Generate project growth charts env: GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }} run: python3 scripts/generate-star-history.py --output-dir dist # 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