name: Deploy web (GitHub Pages) run-name: ${{ github.event.release.tag_name || github.ref_name }} Web on: release: types: [published] workflow_call: inputs: tag: required: true type: string commit: required: true type: string workflow_dispatch: inputs: build_main: description: Build a web hotfix from main without changing the release tag type: boolean default: false permissions: contents: write 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 outputs: tag: ${{ steps.source.outputs.tag }} commit: ${{ steps.source.outputs.commit }} steps: - name: Resolve release source id: source env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} RELEASE_COMMIT: ${{ inputs.commit }} BUILD_MAIN: ${{ github.event_name == 'workflow_dispatch' && inputs.build_main }} run: | if [ -z "$RELEASE_TAG" ]; then RELEASE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) fi COMMIT=$(gh api "repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" --jq .sha) if [ -n "$RELEASE_COMMIT" ] && [ "$RELEASE_COMMIT" != "$COMMIT" ]; then echo "::error::Release tag and commit do not match" exit 1 fi if [ "$BUILD_MAIN" = "true" ]; then if [ "$GITHUB_REF" != "refs/heads/main" ]; then echo "::error::Web hotfixes must run on main" exit 1 fi COMMIT="$GITHUB_SHA" fi echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 with: ref: ${{ steps.source.outputs.commit }} # Full history so build.rs can measure the distance from the # release tag; a shallow clone would label the build `+g`. fetch-depth: 0 - name: Validate release version env: RELEASE_TAG: ${{ steps.source.outputs.tag }} run: | git merge-base --is-ancestor "$RELEASE_TAG" HEAD if [ -f scripts/release.py ]; then python3 scripts/release.py versions --tag "$RELEASE_TAG" else VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) test "v$VERSION" = "$RELEASE_TAG" fi - 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 --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html - name: Stamp the service worker cache version # web/sw.js ships a placeholder cache name; each deploy gets one # derived from the bundle contents, so the new worker drops the # previous deploy's cached assets when it activates. run: | test -f dist/app/sw.js hash=$(find dist/app -type f ! -name sw.js -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c 1-12) sed -i "s/^const CACHE_VERSION = \"[^\"]*\";/const CACHE_VERSION = \"$hash\";/" dist/app/sw.js grep -q "^const CACHE_VERSION = \"$hash\";" dist/app/sw.js - name: Check out current website uses: actions/checkout@v4 with: ref: main path: website - name: Validate website working-directory: website run: python3 scripts/test_site.py - name: Assemble landing page working-directory: website run: sh scripts/assemble-site.sh "$GITHUB_WORKSPACE/dist" - name: Record website source working-directory: website run: git rev-parse HEAD > "$GITHUB_WORKSPACE/dist/site-version.txt" - name: Record release version and source env: RELEASE_TAG: ${{ steps.source.outputs.tag }} RELEASE_COMMIT: ${{ steps.source.outputs.commit }} run: | jq -n --arg version "${RELEASE_TAG#v}" --arg commit "$RELEASE_COMMIT" \ '{version: $version, commit: $commit}' > dist/app/release.json # Attach the bundle to the release so a version can be pinned and # self-hosted; it expects to be served from /app/. Taken before the # site data below is added, and skipped for hotfixes built from main. - name: Attach web bundle to the release if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.build_main) }} env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.source.outputs.tag }} run: | ASSET="OpenCADStudio-$RELEASE_TAG-web.zip" (cd dist && zip -qr "../$ASSET" app) gh release upload "$RELEASE_TAG" "$ASSET" --clobber --repo "$GITHUB_REPOSITORY" # 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/app/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 separate light/dark charts for stars and release downloads. - name: Generate project growth charts env: GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }} run: python3 website/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/app/videos.json") thumbs = Path("dist/app/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 # Publish a public snapshot of GitHub discussions. - 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/app/discussions.json echo "discussions: $(jq 'length' dist/app/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 - name: Verify deployed release env: PAGE_URL: ${{ steps.deployment.outputs.page_url }} RELEASE_TAG: ${{ needs.build.outputs.tag }} RELEASE_COMMIT: ${{ needs.build.outputs.commit }} run: | python3 - <<'PY' import json import os import time import urllib.request expected = {"version": os.environ["RELEASE_TAG"].removeprefix("v"), "commit": os.environ["RELEASE_COMMIT"]} url = os.environ["PAGE_URL"].rstrip("/") + "/app/release.json?commit=" + expected["commit"] for attempt in range(12): try: with urllib.request.urlopen(url, timeout=20) as response: if json.load(response) == expected: print("Verified web release", expected["version"], expected["commit"]) break except (OSError, ValueError) as error: print(error) time.sleep(10) else: raise SystemExit("Deployed web version does not match the release") PY