Compare commits

...

1 Commits

Author SHA1 Message Date
bherbruck 699eb837b5 Web app: stable, release candidate and edge builds at /app/, /app-rc/ and /app-edge/
The site now serves the web app in three channels. /app/ is built from the latest release
instead of main, /app-rc/ from the newest release candidate and /app-edge/ from main. Each
pre-release channel keeps its designs, settings and recovery copies apart from the release's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:47:00 -05:00
13 changed files with 231 additions and 27 deletions
+6 -2
View File
@@ -1,5 +1,6 @@
# The documentation site on GitHub Pages: the book (docs/book) at https://docs.solidcraft.io/
# and the bundled web app at /app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# and the web app at /app/ (the latest release), /app-rc/ (the newest release candidate) and
# /app-edge/ (this commit). Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
@@ -69,7 +70,10 @@ jobs:
mkdir -p site
cp -r target/book/. site/
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
cp -r dist/web site/app
- name: Add the web app channels (stable and release candidate from their releases, edge from this build)
env:
GH_TOKEN: ${{ github.token }}
run: bash packaging/web-channels.sh site dist/web
- uses: actions/configure-pages@v5
id: pages
- name: Install Flatpak repository tools
+1 -1
View File
@@ -40,7 +40,7 @@ js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemGetDirectoryOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
+2
View File
@@ -0,0 +1,2 @@
This build keeps its designs and settings apart per release channel (src/channel.rs).
packaging/web-channels.sh only offers a release candidate that has this file.
+1
View File
@@ -34,6 +34,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/io.tangencylabs.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/io.tangencylabs.solidcraft.png">
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="channel-storage.txt" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/512x512" />
+61
View File
@@ -0,0 +1,61 @@
//! Release channels. The site serves the stable web app at `/app/`, the release candidate at
//! `/app-rc/` and the bleeding-edge build at `/app-edge/`: one origin, so one set of browser
//! storage. A channel that kept its designs and settings in the same places could save a design
//! in a file format the stable app can't open, or overwrite stable's settings. So each
//! pre-release channel keeps everything under its own name, and stable (and any copy served from
//! another path: the release zip, `trunk serve`) keeps the names it has always used, so what
//! people have saved at `/app/` is untouched.
//!
//! The channel comes from the directory the app is served from, not from the build, so a build
//! taken from a release zip is as separate as one built on the spot.
/// The channel the app is served as, from the page's path: `"rc"`, `"edge"`, or `""` (stable).
pub fn of_path(pathname: &str) -> &'static str {
match pathname.trim_end_matches("index.html").trim_end_matches('/').rsplit('/').next() {
Some("app-rc") => "rc",
Some("app-edge") => "edge",
_ => "",
}
}
/// `name` as this channel stores it: unchanged for stable, `rc:name` and `edge:name` otherwise.
pub fn name_in(channel: &str, name: &str) -> String {
if channel.is_empty() { name.to_owned() } else { format!("{channel}:{name}") }
}
#[cfg(target_arch = "wasm32")]
pub fn current() -> &'static str {
web_sys::window().and_then(|w| w.location().pathname().ok()).map_or("", |p| of_path(&p))
}
/// A storage key (local storage, IndexedDB database name) of the running channel.
#[cfg(target_arch = "wasm32")]
pub fn key(name: &str) -> String {
name_in(current(), name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_follows_the_directory() {
assert_eq!(of_path("/app/"), "");
assert_eq!(of_path("/app/index.html"), "");
assert_eq!(of_path("/app-rc/"), "rc");
assert_eq!(of_path("/app-rc"), "rc");
assert_eq!(of_path("/app-rc/index.html"), "rc");
assert_eq!(of_path("/app-edge/"), "edge");
assert_eq!(of_path("/"), "");
assert_eq!(of_path("/solidcraft-web-0.30.1/"), "");
assert_eq!(of_path("/app/app-rc/"), "rc", "only the directory's own name counts");
assert_eq!(of_path("/app-rc/app/"), "");
}
#[test]
fn stable_keeps_its_names() {
assert_eq!(name_in("", "solidcraft.prefs"), "solidcraft.prefs");
assert_eq!(name_in("rc", "solidcraft.prefs"), "rc:solidcraft.prefs");
assert_ne!(name_in("rc", "x"), name_in("edge", "x"));
}
}
+1 -1
View File
@@ -467,7 +467,7 @@ mod web {
async fn open_db() -> Result<IdbDatabase, String> {
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(err)?.ok_or("this browser has no IndexedDB")?;
let request: IdbOpenDbRequest = factory.open_with_u32(DB, 1).map_err(err)?;
let request: IdbOpenDbRequest = factory.open_with_u32(&crate::channel::key(DB), 1).map_err(err)?;
let target = request.clone();
let upgrade = Closure::once_into_js(move || {
if let Ok(db) = target.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
+21 -6
View File
@@ -16,7 +16,9 @@ use solidcraft_engine::io::vfs;
use crate::disk;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
use web_sys::{
FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWritableFileStream,
};
/// Where OPFS files appear to the engine.
pub const OPFS: &str = disk::OPFS_PREFIX;
@@ -111,7 +113,7 @@ thread_local! {
static LOCAL: Cell<bool> = const { Cell::new(false) };
}
/// Local storage keys of stored files.
/// Local storage keys of stored files (under the channel's name, see `channel.rs`).
const LOCAL_PREFIX: &str = "solidcraft.file.";
/// Local-storage files kept by earlier releases (compatibility shim, docs/compatibility.md):
/// still listed and opened; removing one removes either key.
@@ -169,7 +171,17 @@ fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
async fn root() -> Result<FileSystemDirectoryHandle, String> {
let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
let dir = dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())?;
// Stable keeps its files in the root, where they have always been; a pre-release channel has a folder of its own.
match crate::channel::current() {
"" => Ok(dir),
channel => {
let opts = FileSystemGetDirectoryOptions::new();
opts.set_create(true);
let sub = JsFuture::from(dir.get_directory_handle_with_options(&format!("channel-{channel}"), &opts)).await.map_err(err)?;
sub.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "not a folder".to_string())
}
}
}
pub(crate) async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
@@ -191,7 +203,7 @@ async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool)
async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
if local_only() {
return local()?
.set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
.set_item(&crate::channel::key(&format!("{LOCAL_PREFIX}{name}")), &encode(bytes))
.map_err(|e| format!("local storage is full or blocked: {}", err(e)));
}
let dir = root().await?;
@@ -207,7 +219,7 @@ async fn remove_file(name: &str) -> Result<(), String> {
if local_only() {
let st = local()?;
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
return st.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
return st.remove_item(&crate::channel::key(&format!("{LOCAL_PREFIX}{name}"))).map_err(err);
}
let dir = root().await?;
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
@@ -220,7 +232,10 @@ fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let mut out = Vec::new();
for i in 0..st.length().unwrap_or(0).min(10_000) {
let Some(k) = st.key(i).ok().flatten() else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX).or_else(|| k.strip_prefix(LEGACY_LOCAL_PREFIX)) else { continue };
let own = crate::channel::key(LOCAL_PREFIX);
// The old names belong to stable; a pre-release channel has none.
let legacy = crate::channel::current().is_empty().then_some(LEGACY_LOCAL_PREFIX);
let Some(name) = k.strip_prefix(own.as_str()).or_else(|| legacy.and_then(|l| k.strip_prefix(l))) else { continue };
if let Some(v) = st.get_item(&k).ok().flatten() {
out.push((name.to_string(), decode(&v), 0.0));
}
+26 -15
View File
@@ -9,6 +9,8 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod channel;
#[cfg(any(target_arch = "wasm32", test))]
mod disk;
#[cfg(target_arch = "wasm32")]
mod easyprint;
@@ -152,8 +154,15 @@ impl PrefsWriter {
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
/// data), leave this tab's settings as they are.
#[cfg(any(target_arch = "wasm32", test))]
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
fn settings_written(own_key: &str, key: Option<&str>, value: Option<String>) -> Option<String> {
// Tabs of another channel write to the same storage, under their own key.
value.filter(|_| key == Some(own_key))
}
/// The settings key of `channel` (see [`channel`]).
#[cfg(target_arch = "wasm32")]
fn prefs_key() -> String {
channel::key(solidcraft_ui_egui::prefs::STORAGE_KEY)
}
#[cfg(target_arch = "wasm32")]
@@ -262,7 +271,7 @@ mod web {
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
let ctx = ctx.clone();
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
if let Some(prefs) = crate::settings_written(&crate::prefs_key(), e.key().as_deref(), e.new_value()) {
inbox.set(Some(prefs));
ctx.request_repaint();
}
@@ -335,7 +344,7 @@ mod web {
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored =
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(&crate::prefs_key(), &prefs));
if !matches!(stored, Some(Ok(()))) {
log::warn!("settings: the browser's storage did not take them; they are saved again later");
self.prefs_writer = crate::PrefsWriter::default();
@@ -834,14 +843,15 @@ mod web {
self.store_prefs(&ctx);
}
fn persist_egui_memory(&self) -> bool {
self.launch.persists()
// eframe keeps this under one fixed key: only stable uses it, so a pre-release never rewrites it.
self.launch.persists() && crate::channel::current().is_empty()
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
if !self.launch.persists() {
return;
}
let prefs = self.persisted_prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
storage.set_string(&crate::prefs_key(), prefs);
}
}
@@ -851,7 +861,7 @@ mod web {
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
/// has no query, so the choice has to outlive the page it was made on.
const WEBGL_KEY: &str = "solidcraft.webgl";
const WEBGL_KEY: &str = "solidcraft.webgl"; // stored under crate::channel::key
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
fn webgl() -> bool {
@@ -859,17 +869,17 @@ mod web {
let q = query();
if crate::query_flag(&q, "webgl") {
if let Some(s) = &storage {
let _ = s.set_item(WEBGL_KEY, "1");
let _ = s.set_item(&crate::channel::key(WEBGL_KEY), "1");
}
return true;
}
if crate::query_flag(&q, "webgpu") {
if let Some(s) = &storage {
let _ = s.remove_item(WEBGL_KEY);
let _ = s.remove_item(&crate::channel::key(WEBGL_KEY));
}
return false;
}
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
storage.and_then(|s| s.get_item(&crate::channel::key(WEBGL_KEY)).ok().flatten()).is_some()
}
/// Fetch font bytes asynchronously through the browser.
@@ -968,7 +978,7 @@ mod web {
if launch.persists() {
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
}
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(&crate::channel::key(k)))) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
@@ -1171,9 +1181,10 @@ mod tests {
#[test]
fn only_settings_another_tab_wrote_are_loaded() {
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
assert_eq!(settings_written(key, Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(key, Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(key, Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(key, None, None), None, "clearing all storage leaves this tab's settings");
assert_eq!(settings_written(key, Some("rc:solidcraft.prefs"), Some("{}".into())), None, "another channel's settings are not loaded");
}
}
+2 -1
View File
@@ -29,4 +29,5 @@ done < <(find . -name '*.html' | sort)
for u in "${urls[@]}"; do echo " <url><loc>$u</loc></url>"; done
echo '</urlset>'
} > sitemap.xml
printf 'User-agent: *\nAllow: /\n\nSitemap: %s/sitemap.xml\n' "$base" > robots.txt
# The release candidate and edge web apps can break: keep them out of search results.
printf 'User-agent: *\nAllow: /\nDisallow: /app-rc/\nDisallow: /app-edge/\n\nSitemap: %s/sitemap.xml\n' "$base" > robots.txt
+9
View File
@@ -110,6 +110,15 @@ The same app runs in the browser: [open SolidCraft on the web](../app/) (`?sampl
part, `?webgl` forces WebGL2 where WebGPU is missing or misbehaves; the browser remembers it until
`?webgpu`, and an app installed from Chrome or Edge starts with it too). Designs are saved in the
browser's private file storage, which needs HTTPS or localhost.
The web app at `/app/` is always the latest release. Two more builds are there to try early:
the [release candidate](../app-rc/) (the newest `-rc` pre-release; a page saying so appears
until the first one is out) and the [edge build](../app-edge/) (the current `main`, rebuilt on
every change). **Both can break, and a design saved in them may not open in the release.** Each
keeps its designs, settings and recovery copies apart from the release's, so trying one never
changes what the release sees, and nothing is copied between them: to move a design, save it to
a file in one and open the file in the other. Search engines are told to skip both.
Planned worker and tiered-storage integration is
described in [web-runtime.md](https://github.com/tangency-labs/solidcraft/blob/main/docs/web-runtime.md)
(design; not yet wired in the shipped wasm build).
+7 -1
View File
@@ -13,7 +13,11 @@ Every platform and package. The links point at the latest release.
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
On Arch Linux: `sudo pacman -U` the [package][linux-arch] (ARM64: [package][linux-arm64-arch]).
- **Web:** [try it in your browser][web-app], no install.
- **Web:** [try it in your browser][web-app], no install. This is the latest release. To try
what is coming, there are two more builds: the [release candidate][web-rc] (the newest
`-rc` pre-release) and the [edge build][web-edge] (the current `main`, rebuilt on every
change). Both can break, and a design saved in them may not open in the release, so keep a copy
of anything you care about. Each keeps its own designs and settings, apart from the release's.
## Code signing
@@ -51,3 +55,5 @@ Install details, upgrading from earlier builds and building from source are in
[linux-arm64-arch]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.pkg.tar.zst
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://solidcraft.io/app
[web-rc]: https://docs.solidcraft.io/app-rc/
[web-edge]: https://docs.solidcraft.io/app-edge/
+9
View File
@@ -33,6 +33,15 @@ service worker after the code that registered it is gone, so to switch it off de
`apps/solidcraft-web/sw.js` unbuilt (as `trunk serve` builds do): it deletes what it kept and
unregisters. A site without `build.json` is also safe: launches then go to the network.
The site serves the web app three ways (`packaging/web-channels.sh`, run by `pages.yml`):
`/app/` is the web zip attached to the latest release that is not a pre-release, `/app-rc/` the
zip of the newest published `v*-rc.N` pre-release (a page saying there is none, when it is not
newer than the release), and `/app-edge/` the build of `main`. Publishing a release or
pre-release deploys the site again through `flatpak-pages.yml`. The directories are separate
because a service worker controls everything beneath its own, and each pre-release channel keeps
its browser storage under its own names (`apps/solidcraft-web/src/channel.rs`), so a design saved
in one can't reach another. Nightly tags (`vX.Y.Z-nightly...`) are not release candidates.
## Nightly prereleases
`release.yml` also runs at midnight in `America/New_York` (Eastern time, following daylight
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Puts the three web app channels into the Pages site (usage: web-channels.sh <site> <edge-build>).
#
# <site>/app/ stable: the web build attached to the latest release that is not a pre-release
# <site>/app-rc/ release candidate: the web build of the newest published v*-rc.* pre-release
# <site>/app-edge/ edge: <edge-build>, built from main in this run
#
# Stable and the release candidate are not rebuilt: the release workflow already attaches
# solidcraft-web-<version>.zip (packaging/web/package.sh, built by that tag's own workflow), and
# a deploy takes that zip as it is. Rebuilding old tags here would need each tag's own build steps
# (they changed: the kernel moved from SMLib to solidcore). The channels are not nested, because
# the app's service worker controls everything under its own directory (apps/solidcraft-web/sw.js).
#
# No stable release or no web zip on it fails the deploy: Pages replaces the whole site, and an
# empty /app/ would take the web app away. A missing, outdated or too-old release candidate
# leaves a small page at /app-rc/ instead. Needs gh and GH_TOKEN; GITHUB_REPOSITORY names the repository.
set -euo pipefail
site="${1:?site directory required}"
edge="${2:?edge build directory required}"
repo="${GITHUB_REPOSITORY:?}"
# Version order, newest last: 0.31.0-rc.2 sorts after 0.31.0-rc.1 and before 0.31.0.
version_sort() { sed 's/-/~/' | sort -V | sed 's/~/-/'; }
# Unpack the web zip of release $1 into directory $2. Fails when the release has none.
fetch_build() {
local tag="$1" dest="$2" tmp
tmp="$(mktemp -d)"
gh release download "$tag" --repo "$repo" --pattern "solidcraft-web-*.zip" --dir "$tmp"
gh release download "$tag" --repo "$repo" --pattern SHA256SUMS.txt --dir "$tmp"
local zip
zip="$(find "$tmp" -name 'solidcraft-web-*.zip' | head -n1)"
test -n "$zip"
(cd "$tmp" && awk -v file="$(basename "$zip")" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' SHA256SUMS.txt | sha256sum --check -)
unzip -q "$zip" -d "$tmp/unpacked"
mkdir -p "$dest"
cp -R "$tmp"/unpacked/*/. "$dest"/
test -s "$dest/index.html"
test -s "$dest/sw.js"
rm -rf "$tmp"
}
# A page for a channel that has no build to offer.
placeholder() {
local dir="$1" why="$2"
mkdir -p "$dir"
cat >"$dir/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>SolidCraft release candidate</title></head><body style="font-family:sans-serif;max-width:40em;margin:3em auto;padding:0 1em"><h1>No release candidate right now</h1><p>$why</p><p><a href="../app/">Open SolidCraft (the latest release)</a> or <a href="../app-edge/">the edge build</a>.</p></body></html>
HTML
}
stable="$(gh api "repos/$repo/releases?per_page=100" \
--jq '[.[] | select(.draft == false and .prerelease == false)][0].tag_name // empty')"
if [ -z "$stable" ]; then
echo "error: no published release, so there is no stable web app to deploy" >&2
exit 1
fi
echo "stable: $stable"
fetch_build "$stable" "$site/app"
# Edge: the build of this run.
mkdir -p "$site/app-edge"
cp -R "$edge"/. "$site/app-edge"/
# Release candidate: the newest published pre-release v*-rc.N, if it is newer than stable and is
# built with per-channel browser storage (apps/solidcraft-web/src/channel.rs): a build without it
# would share stable's saved designs.
rc="$(gh api "repos/$repo/releases?per_page=100" \
--jq '.[] | select(.draft == false and .prerelease == true) | .tag_name' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' | version_sort | tail -n1 || true)"
if [ -z "$rc" ]; then
placeholder "$site/app-rc" "There is no release candidate at the moment. One is published shortly before each release."
elif [ "$(printf '%s\n%s\n' "${stable#v}" "${rc#v}" | version_sort | tail -n1)" = "${stable#v}" ]; then
echo "rc: $rc is not newer than $stable, skipped"
placeholder "$site/app-rc" "The last release candidate ($rc) is already part of the release. The next one appears before the next release."
else
echo "rc: $rc"
fetch_build "$rc" "$site/app-rc"
if [ ! -f "$site/app-rc/channel-storage.txt" ]; then
echo "rc: $rc is built without separate browser storage, skipped"
rm -rf "$site/app-rc"
placeholder "$site/app-rc" "The last release candidate ($rc) predates separate storage for pre-release builds, so it is not offered here. The next one is."
fi
fi