forked from 1b-agent/opencadstudio-upstream
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.
430 lines
16 KiB
YAML
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
|