# 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] discussion: types: [created, edited, deleted, transferred, pinned, unpinned, labeled, unlabeled, locked, unlocked, category_changed, answered, unanswered] 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: Install wasm-bindgen CLI uses: taiki-e/install-action@v2 with: tool: wasm-bindgen@0.2.126 # The rust 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://.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: | if [ -z "$OCS_PATREON_TOKEN" ]; then echo '[]' > dist/supporters.json; exit 0 fi CID=$(curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \ "https://www.patreon.com/api/oauth2/v2/campaigns" \ | jq -r '.data[0].id // empty' || true) if [ -z "$CID" ]; then echo '[]' > dist/supporters.json; exit 0 fi curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \ "https://www.patreon.com/api/oauth2/v2/campaigns/$CID/members?fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents&page%5Bcount%5D=200" \ | jq -c '[.data[] | select(.attributes.patron_status=="active_patron") | select(.attributes.currently_entitled_amount_cents>0) | {name: .attributes.full_name, cents: .attributes.currently_entitled_amount_cents}] | sort_by(-.cents)' \ > dist/supporters.json || echo '[]' > dist/supporters.json echo "supporters: $(jq 'length' dist/supporters.json)" # 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