Files
OneCAD-Draft/.github/workflows/pages.yml
T
Afonsop 6af8d8ba8c feat: restore-all session recovery, ?file= deep links, offline PWA
Session recovery: more than one pending snapshot adds a 'Restore all'
button to the Recover Drawing modal — every snapshot goes through the
exact same guarded path as a single restore (deleted only after its
own open succeeds). The open-tab list and active tab persist to
localStorage on web (open/save/close) and the remembered tab is
re-selected once a restore batch settles; the modal notes how many
drawings the session had. Nothing is auto-opened.

Deep links: ?file=<url|name> opens a drawing at boot. http(s) URLs
must end in .dwg/.dxf and are fetched with the same streaming,
64 MB-capped, 8 s-deadline reader the remote images use, then go
through the guarded open queue; cross-origin opens announce the
origin first. Bare names resolve from the browser's recent storage,
with a hint when absent. Everything else (file:, javascript:, drive
letters, non-drawing tails) is refused with a console note.

Offline PWA: the app was installable (manifest) but downloaded its
55 MB bundle on every visit. A cache-first service worker now serves
the whole bundle from Cache Storage after one online visit —
navigations network-first so updates land, hashed assets
stale-while-revalidate, non-GET/cross-origin passthrough, old caches
cleaned on activate, and the waiting-worker update flow documented.
The Pages workflow stamps the cache version from the bundle hash and
fails if sw.js is missing or unstamped.

(cherry picked from commit 5f9b345e)

Ported onto main (#1717 review): the cache-version stamp moved from the
skipped build-web.sh wrapper into the Pages workflow; the tab-close hook
lands in main's on_tab_close, which also cancels pinned control requests.
2026-10-12 12:02:05 +03:00

430 lines
16 KiB
YAML

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: 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
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<hash>`.
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
# 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