Compare commits
11
Commits
892397589f
..
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cf611d44c | ||
|
|
1e3dd2dd76 | ||
|
|
45322db41a | ||
|
|
2a1d73af25 | ||
|
|
e83c7fdb18 | ||
|
|
587c19c2a5 | ||
|
|
c862ed5806 | ||
|
|
1d79acb083 | ||
|
|
5edf594eac | ||
|
|
94dba571c1 | ||
|
|
de32699539 |
No files matched your search
+49
-1
@@ -7,9 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
## [1.1.1] - 2026-08-15
|
||||
|
||||
### Added
|
||||
- Update release-stable.sh
|
||||
- Enhance release script with preflight checks and metadata
|
||||
- Migrate dock/folder/mime entries from brave to brave-origin on upgrade
|
||||
- Skip Brave Origin startup dialog in Hub launcher
|
||||
- Switch default browser to Brave Origin (brave-origin)
|
||||
- Add Bitcoin Core Tor IBD gossip control
|
||||
- Replace Bitcoin Knots with Bitcoin Core
|
||||
|
||||
### Fixed
|
||||
- Persistent browser profile so logout survives window reopen
|
||||
- Recover from expired sessions and preserve logout
|
||||
- Prevent Bitcoin Core switch from hanging the Hub UI
|
||||
|
||||
### Documentation
|
||||
- Update README ISO download links to v1.1.0
|
||||
[1.1.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.1
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Replaced Brave Browser with Brave Origin (the new `brave-origin` nixpkgs
|
||||
package) as the desktop's default browser: installed package, dock
|
||||
favorite, Browsers app-folder entry, `xdg.mime.defaultApplications`, and
|
||||
the `$BROWSER` session variable now all point at Brave Origin, and the
|
||||
Sovran Hub launcher runs it.
|
||||
- On installs upgrading from the old Brave package, the desktop theme init
|
||||
now migrates `brave-browser.desktop` → `brave-origin.desktop` in the dock
|
||||
favorites, the Browsers app folder, and the user's `mimeapps.list`, so
|
||||
the dock icon and default-browser handler survive the switch without a
|
||||
manual re-pin.
|
||||
|
||||
### Fixed
|
||||
- The Hub launcher now uses a persistent per-user browser profile
|
||||
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||
profile that was deleted on exit. The throwaway profile wiped the
|
||||
`hub_manual_logout` marker cookie on every close, so `/auto-login`
|
||||
silently logged the user straight back in after they signed out and
|
||||
reopened the Hub window. The persistent profile makes explicit logout
|
||||
stick (until the next password login) while still skipping Brave Origin's
|
||||
one-time startup dialog.
|
||||
- The Hub now redirects to the login page when an API request finds an
|
||||
expired browser session, instead of leaving the dashboard tile grid in a
|
||||
loading state while `/api/services` continues returning 401 every five
|
||||
seconds.
|
||||
- Explicitly logging out now suppresses the desktop launcher's local
|
||||
auto-login after the Hub window is closed and reopened. A successful
|
||||
password login clears that preference.
|
||||
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
|
||||
browser login survives the Hub service restart that `nixos-rebuild switch`
|
||||
performs during activation. Previously the in-memory session store was
|
||||
|
||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso.sha256) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||
@@ -185,7 +185,7 @@ hardware you control.
|
||||
### Your desktop
|
||||
|
||||
- [GNOME](https://www.gnome.org) desktop
|
||||
- [Brave](https://brave.com) and
|
||||
- [Brave Origin](https://brave.com/origin/) and
|
||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||
- File management, email, calendar, and office applications
|
||||
- System monitoring and administration tools
|
||||
@@ -341,8 +341,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
### 1. Download the ISO and checksum
|
||||
|
||||
- [Download Sovran_SystemsOS-1.1.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.0.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso.sha256)
|
||||
- [Download Sovran_SystemsOS-1.1.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256)
|
||||
|
||||
The download may take some time. Do not rename or modify the ISO before
|
||||
verifying it, and keep both files in the same folder.
|
||||
@@ -360,16 +360,16 @@ checksum exactly.
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.1.0.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.1.1.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.1.0.iso: OK
|
||||
Sovran_SystemsOS-1.1.1.iso: OK
|
||||
```
|
||||
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.0.iso` and compare the output
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.1.iso` and compare the output
|
||||
against the checksum file manually.
|
||||
|
||||
</details>
|
||||
@@ -380,11 +380,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.0.iso
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.1.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.1.0.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.1.1.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -394,7 +394,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.0.iso -Algorithm SHA256
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.1.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
@@ -409,7 +409,7 @@ match exactly.
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
connect the USB drive.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.0.iso`.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.1.iso`.
|
||||
3. Choose **Select target**, select the USB drive, and review your selection
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
@@ -852,7 +852,7 @@ primary location for collaboration. Please read our
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
@@ -491,6 +491,8 @@ if [[ -d /home ]]; then
|
||||
--exclude='.config/chromium/*/Code Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
|
||||
--exclude='.local/share/baloo/' \
|
||||
--exclude='.thumbnails/' \
|
||||
--exclude='.xsession-errors' \
|
||||
|
||||
@@ -142,9 +142,13 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
||||
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
|
||||
FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
|
||||
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
|
||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||
SESSION_COOKIE_NAME = "hub_session"
|
||||
SESSION_MAX_AGE = 86400 # 24 hours
|
||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||
SESSION_COOKIE_NAME = "hub_session"
|
||||
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
|
||||
SESSION_MAX_AGE = 86400 # 24 hours
|
||||
# Chromium limits persistent cookies to roughly 400 days. This marker only
|
||||
# suppresses desktop auto-login until the next successful password login.
|
||||
MANUAL_LOGOUT_MAX_AGE = 400 * 86400
|
||||
|
||||
# Sessions are persisted here so logins survive a restart of the Hub service.
|
||||
# nixos-rebuild switch restarts sovran-hub-web.service during activation (its
|
||||
@@ -2517,6 +2521,10 @@ async def auto_login_redirect(request: Request):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if client_ip not in ("127.0.0.1", "::1"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
# An explicit logout must take precedence over the desktop launcher's
|
||||
# localhost auto-login, including after the Hub window is closed/reopened.
|
||||
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1":
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
token = _create_session()
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
@@ -2553,17 +2561,27 @@ async def api_login(req: LoginRequest, request: Request):
|
||||
samesite="lax",
|
||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||
)
|
||||
# A successful password login explicitly reverses a prior manual logout.
|
||||
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
async def api_logout(request: Request):
|
||||
"""Clear the session cookie and destroy the server-side session."""
|
||||
"""Destroy the session and prevent desktop auto-login until password login."""
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token:
|
||||
_destroy_session(token)
|
||||
response = JSONResponse({"ok": True})
|
||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||
response.set_cookie(
|
||||
key=MANUAL_LOGOUT_COOKIE_NAME,
|
||||
value="1",
|
||||
max_age=MANUAL_LOGOUT_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
|
||||
|
||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||
|
||||
// Avoid issuing multiple redirects when several startup requests discover an
|
||||
// expired session at the same time.
|
||||
let _authRedirectInProgress = false;
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
const res = await fetch(path, options || {});
|
||||
if (res.status === 401) {
|
||||
if (!_authRedirectInProgress) {
|
||||
_authRedirectInProgress = true;
|
||||
window.location.replace("/login");
|
||||
}
|
||||
throw new Error("Unauthenticated");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = res.status + " " + res.statusText;
|
||||
try {
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave dua
|
||||
synadm brave-origin dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox wp-cli axel
|
||||
|
||||
@@ -282,17 +282,31 @@ let
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
||||
# deterministic and GNOME Shell can match the window to the .desktop
|
||||
# entry (fixes generic gear icon appearing in the dock).
|
||||
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
||||
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
||||
# (hub_manual_logout) and the session cookie live in this profile.
|
||||
# Launching from a fresh/ephemeral profile every time throws away the
|
||||
# marker, so /auto-login would mint a new session and silently log the
|
||||
# user straight back in after they signed out and reopened the window.
|
||||
# A stable directory also keeps the Wayland app_id deterministic so
|
||||
# GNOME Shell can match the window to the .desktop entry (dock icon).
|
||||
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
||||
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
|
||||
# way of backups and survives reboots and window close/reopen.
|
||||
if [ -n "$XDG_STATE_HOME" ]; then
|
||||
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
|
||||
else
|
||||
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
|
||||
fi
|
||||
mkdir -p "$HUB_DATA"
|
||||
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
|
||||
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||
brave --app=http://localhost:8937/auto-login \
|
||||
# With a persistent profile Brave Origin's one-time "Proceed with Origin
|
||||
# for free on Linux" onboarding dialog only appears once; keep skipping it
|
||||
# anyway so it can never block auto-login (Linux-only switch).
|
||||
brave-origin --app=http://localhost:8937/auto-login \
|
||||
--skip-origin-startup-dialog \
|
||||
--class=sovran-hub \
|
||||
--user-data-dir="$HUB_DATA" \
|
||||
--password-store=basic \
|
||||
|
||||
@@ -44,6 +44,35 @@ let
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Brave Origin migration (existing installs upgrading from `brave`) ──
|
||||
# The old package's brave-browser.desktop no longer exists, so GNOME drops
|
||||
# it from the dock and the Browsers folder. Swap the id in place, but only
|
||||
# when the stale value is still present (never touch user customizations).
|
||||
if [ -f "$USER_DB" ]; then
|
||||
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
|
||||
if [ -n "$FAVS" ]; then
|
||||
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
|
||||
if [ "$NEW_FAVS" != "$FAVS" ]; then
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
|
||||
fi
|
||||
fi
|
||||
|
||||
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
|
||||
if [ -n "$BROWSER_APPS" ]; then
|
||||
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
|
||||
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# A previous fresh install wrote the user's mimeapps.list with the old id;
|
||||
# XDG gives it precedence over the new system-wide default, so rewrite it.
|
||||
MIME_LIST="$HOME/.config/mimeapps.list"
|
||||
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
|
||||
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
|
||||
fi
|
||||
|
||||
# Already applied — skip
|
||||
if [ -f "$STAMP" ]; then
|
||||
exit 0
|
||||
@@ -61,11 +90,11 @@ let
|
||||
mkdir -p "$HOME/.config"
|
||||
cat > "$HOME/.config/mimeapps.list" << EOF
|
||||
[Default Applications]
|
||||
text/html=brave-browser.desktop
|
||||
x-scheme-handler/http=brave-browser.desktop
|
||||
x-scheme-handler/https=brave-browser.desktop
|
||||
x-scheme-handler/about=brave-browser.desktop
|
||||
x-scheme-handler/unknown=brave-browser.desktop
|
||||
text/html=brave-origin.desktop
|
||||
x-scheme-handler/http=brave-origin.desktop
|
||||
x-scheme-handler/https=brave-origin.desktop
|
||||
x-scheme-handler/about=brave-origin.desktop
|
||||
x-scheme-handler/unknown=brave-origin.desktop
|
||||
EOF
|
||||
|
||||
${pkgs.dconf}/bin/dconf load / << EOF
|
||||
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
|
||||
[org/gnome/shell]
|
||||
disabled-extensions=['just-perfection-desktop@just-perfection']
|
||||
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
||||
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||
favorite-apps=['brave-origin.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||
welcome-dialog-last-shown-version='48.4'
|
||||
|
||||
[org/gnome/desktop/app-folders]
|
||||
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Browsers]
|
||||
name='Browsers'
|
||||
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||
apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Office]
|
||||
name='Office'
|
||||
@@ -269,7 +298,7 @@ in
|
||||
];
|
||||
|
||||
favorite-apps = [
|
||||
"brave-browser.desktop"
|
||||
"brave-origin.desktop"
|
||||
"org.gnome.Settings.desktop"
|
||||
"org.gnome.Nautilus.desktop"
|
||||
"sovran-hub.desktop"
|
||||
@@ -292,7 +321,7 @@ in
|
||||
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
||||
name = "Browsers";
|
||||
apps = [
|
||||
"brave-browser.desktop"
|
||||
"brave-origin.desktop"
|
||||
"firefox.desktop"
|
||||
"org.gnome.Epiphany.desktop"
|
||||
];
|
||||
@@ -433,13 +462,13 @@ in
|
||||
];
|
||||
|
||||
xdg.mime.defaultApplications = {
|
||||
"text/html" = "brave-browser.desktop";
|
||||
"x-scheme-handler/http" = "brave-browser.desktop";
|
||||
"x-scheme-handler/https" = "brave-browser.desktop";
|
||||
"x-scheme-handler/about" = "brave-browser.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-browser.desktop";
|
||||
"text/html" = "brave-origin.desktop";
|
||||
"x-scheme-handler/http" = "brave-origin.desktop";
|
||||
"x-scheme-handler/https" = "brave-origin.desktop";
|
||||
"x-scheme-handler/about" = "brave-origin.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-origin.desktop";
|
||||
};
|
||||
|
||||
environment.sessionVariables.BROWSER = "brave-browser";
|
||||
environment.sessionVariables.BROWSER = "brave-origin";
|
||||
|
||||
}
|
||||
+272
-188
@@ -20,6 +20,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Always operate from the repository root, regardless of the caller's cwd.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -62,13 +67,28 @@ echo
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Get latest tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# GitHub release tags are fetched into a private namespace instead of
|
||||
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
|
||||
# point to different objects; sharing refs/tags/ would make either fetch fail
|
||||
# with "would clobber existing tag".
|
||||
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
|
||||
|
||||
get_latest_tag() {
|
||||
local exclude="${1:-}"
|
||||
if [[ -n "$exclude" ]]; then
|
||||
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
|
||||
else
|
||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
||||
fi
|
||||
local tag
|
||||
|
||||
while IFS= read -r tag; do
|
||||
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$tag"
|
||||
return 0
|
||||
done < <(git for-each-ref \
|
||||
--sort=-version:refname \
|
||||
--format='%(refname:strip=3)' \
|
||||
"${GITHUB_TAG_NAMESPACE}/v*")
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -90,10 +110,84 @@ suggest_next_version() {
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 0: Fetch everything
|
||||
# Step 0: Preflight and fetch everything
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}"
|
||||
git fetch --all --tags 2>/dev/null || true
|
||||
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
|
||||
|
||||
# A release must start from an exact, committed, reproducible tree. This also
|
||||
# prevents release metadata generated below from being mixed with local work.
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
|
||||
if ! git remote | grep -q -x "$remote"; then
|
||||
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for command_name in gh curl; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo
|
||||
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
|
||||
echo
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
|
||||
# Never fetch either host's tags into refs/tags/. Historical tags with the same
|
||||
# name differ between the hosts, and Git correctly refuses to clobber them.
|
||||
git fetch "$GITHUB_REMOTE" --prune --no-tags
|
||||
git fetch "$GITEA_REMOTE" --prune --no-tags
|
||||
git fetch "$GITHUB_REMOTE" --prune --no-tags \
|
||||
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
|
||||
|
||||
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
|
||||
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
|
||||
|
||||
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
|
||||
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
|
||||
|
||||
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
|
||||
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
|
||||
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
|
||||
echo " Local HEAD : $HEAD_COMMIT" >&2
|
||||
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
|
||||
echo "Update/check out the synchronized commit, then run the script again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
|
||||
|
||||
LATEST_TAG=$(get_latest_tag)
|
||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||
@@ -117,6 +211,23 @@ fi
|
||||
VERSION="${VERSION#v}"
|
||||
TAG="v${VERSION}"
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_has_tag() {
|
||||
local remote="$1"
|
||||
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
|
||||
}
|
||||
|
||||
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
|
||||
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
|
||||
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
|
||||
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
|
||||
echo "Refusing to move or overwrite an existing release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Get release message
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -193,20 +304,26 @@ generate_release_notes() {
|
||||
printf '%s' "$notes"
|
||||
}
|
||||
|
||||
# Build the notes from commits since the previous tag (exclude the target tag if already present)
|
||||
# Build notes from the previous canonical GitHub tag. Use its private ref so a
|
||||
# conflicting local or Gitea tag with the same name cannot select the wrong
|
||||
# commit.
|
||||
PREV_TAG=$(get_latest_tag "$TAG")
|
||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
||||
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
||||
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
|
||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
|
||||
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
|
||||
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
|
||||
else
|
||||
COMMIT_RANGE="HEAD"
|
||||
COMMIT_RANGE_DISPLAY="HEAD"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${NC}"
|
||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
|
||||
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||
|
||||
# Let the user review/edit the generated notes before publishing
|
||||
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||
{
|
||||
echo "## Sovran_SystemsOS ${TAG}"
|
||||
echo
|
||||
@@ -246,214 +363,182 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Push tested code to Gitea (stable & staging-dev)
|
||||
# Step 1: Prepare and commit all release metadata
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}"
|
||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||
git push "${GITEA_REMOTE}" HEAD:stable --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (stable) failed.${NC}"
|
||||
git push "${GITEA_REMOTE}" HEAD:staging-dev --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (staging-dev) failed.${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea push.${NC}"
|
||||
fi
|
||||
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Create annotated tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
|
||||
git tag -f -a "${TAG}" -m "${RELEASE_MESSAGE}
|
||||
|
||||
- Stable release of Sovran_SystemsOS
|
||||
- See CHANGELOG.md for full details" || true
|
||||
|
||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||
git push "${GITEA_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITEA_REMOTE} failed.${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea tag push.${NC}"
|
||||
fi
|
||||
|
||||
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
||||
git push "${GITHUB_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITHUB_REMOTE} failed.${NC}"
|
||||
fi
|
||||
|
||||
# Update VERSION file for ISO builds
|
||||
# VERSION drives ISO naming.
|
||||
echo "${VERSION}" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
|
||||
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2b: Auto-update README.md ISO download references
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
|
||||
|
||||
# Update every versioned ISO filename in README.md.
|
||||
README_FILE="README.md"
|
||||
if [ -f "$README_FILE" ]; then
|
||||
# Extract the version currently used in the README's ISO filenames,
|
||||
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
|
||||
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
|
||||
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
|
||||
|
||||
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
|
||||
echo -e " ${GREEN}✓${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
|
||||
|
||||
git add "$README_FILE"
|
||||
git commit -m "docs: update README ISO download links to ${TAG}" || true
|
||||
echo -e " ${GREEN}✓${NC} Committed README update"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md not found — skipping"
|
||||
if [[ ! -f "$README_FILE" ]]; then
|
||||
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
if [[ -z "$OLD_ISO_VER" ]]; then
|
||||
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp"
|
||||
mv "$README_FILE.tmp" "$README_FILE"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Auto-update CHANGELOG.md
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
||||
|
||||
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Create new changelog entry from the generated notes (no placeholders)
|
||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||
|
||||
${RELEASE_NOTES}
|
||||
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||
"
|
||||
|
||||
# Prepend to changelog (after the header)
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Backup
|
||||
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
|
||||
|
||||
# Insert new section after the first --- line
|
||||
awk -v new_entry="$NEW_ENTRY" '
|
||||
BEGIN { printed=0 }
|
||||
/^---$/ && !printed {
|
||||
print
|
||||
print ""
|
||||
print new_entry
|
||||
printed=1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||
rm -f "${CHANGELOG_FILE}.bak"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
|
||||
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
awk -v new_entry="$NEW_ENTRY" '
|
||||
BEGIN { printed=0 }
|
||||
/^---$/ && !printed {
|
||||
print
|
||||
print ""
|
||||
print new_entry
|
||||
printed=1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { if (!printed) exit 2 }
|
||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
|
||||
rm -f "${CHANGELOG_FILE}.tmp"
|
||||
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||
|
||||
# Commit the changelog update
|
||||
git add "$CHANGELOG_FILE"
|
||||
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
|
||||
if git diff --cached --quiet; then
|
||||
echo " (No changes to commit in changelog)"
|
||||
else
|
||||
git commit -m "docs: update CHANGELOG.md for ${TAG}"
|
||||
echo -e " ${GREEN}✓${NC} Committed changelog update"
|
||||
|
||||
# Ask if user wants to push
|
||||
echo
|
||||
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
|
||||
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
|
||||
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
||||
echo -e "${BLUE}Pushing changelog commit to GitHub main...${NC}"
|
||||
if git push "${GITHUB_REMOTE}" HEAD:main; then
|
||||
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub main (${GITHUB_REMOTE})"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Push to GitHub main failed — verify credentials or remote name.${NC}"
|
||||
fi
|
||||
fi
|
||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||
echo -e "${BLUE}Pushing changelog commit to Gitea staging-dev...${NC}"
|
||||
if git push "${GITEA_REMOTE}" HEAD:staging-dev; then
|
||||
echo -e " ${GREEN}✓${NC} Changelog pushed to Gitea staging-dev (${GITEA_REMOTE})"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Push to Gitea staging-dev failed.${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " (Changelog commit left local — remember to push later)"
|
||||
fi
|
||||
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git commit -m "chore(release): prepare ${TAG}"
|
||||
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||
|
||||
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
|
||||
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Create GitHub Release via gh CLI
|
||||
# Step 2: Publish the final release commit to every release branch
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
||||
echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
|
||||
|
||||
if command -v gh &>/dev/null; then
|
||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||
if gh release create "${TAG}" \
|
||||
--repo naturallaw777/Sovran_SystemsOS \
|
||||
--title "${TAG} — ${title_suffix}" \
|
||||
--notes-file "${NOTES_FILE}"; then
|
||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} GitHub release creation failed (check 'gh auth status' or create via web GUI)"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release (create via GitHub web GUI)"
|
||||
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||
git push "$GITHUB_REMOTE" HEAD:main
|
||||
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||
|
||||
# Verify all three branch tips before creating an immutable release tag.
|
||||
remote_branch_commit() {
|
||||
local remote="$1"
|
||||
local branch="$2"
|
||||
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||
}
|
||||
|
||||
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||
echo " Expected : $RELEASE_COMMIT" >&2
|
||||
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Create Gitea Release via API
|
||||
# Step 3: Tag the final release commit and publish the tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}"
|
||||
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||
|
||||
# ── Gitea Token Handling ─────────────────────────────────────────────────────
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo
|
||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
||||
echo
|
||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
||||
GITEA_TOKEN=""
|
||||
fi
|
||||
- Stable release of Sovran_SystemsOS
|
||||
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
|
||||
|
||||
git push "$GITHUB_REMOTE" "$TAG"
|
||||
git push "$GITEA_REMOTE" "$TAG"
|
||||
|
||||
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
|
||||
remote_tag_commit() {
|
||||
local remote="$1"
|
||||
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
|
||||
}
|
||||
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
|
||||
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
|
||||
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Create GitHub release via gh CLI
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
|
||||
|
||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||
gh release create "$TAG" \
|
||||
--repo naturallaw777/Sovran_SystemsOS \
|
||||
--title "${TAG} — ${title_suffix}" \
|
||||
--notes-file "$NOTES_FILE"
|
||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Create Gitea release via API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
|
||||
|
||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||
|
||||
# Build JSON safely because release notes may contain quotes and newlines.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "$TAG — Stable Release" \
|
||||
--arg body "$RELEASE_BODY" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
else
|
||||
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
|
||||
fi
|
||||
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
|
||||
|
||||
# Build JSON payload safely (release body may contain quotes/newlines)
|
||||
if command -v jq &>/dev/null; then
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "${TAG}" \
|
||||
--arg name "${TAG} — Stable Release" \
|
||||
--arg body "${RELEASE_BODY}" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
else
|
||||
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "${TAG}" "${NOTES_FILE}")
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" \
|
||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||
elif echo "$RESPONSE" | grep -q "write:repository"; then
|
||||
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
|
||||
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
||||
echo " Response: $RESPONSE"
|
||||
fi
|
||||
if [[ "$HTTP_STATUS" != "201" ]]; then
|
||||
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||
cat "$RESPONSE_FILE" >&2
|
||||
rm -f "$RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$RESPONSE_FILE"
|
||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Final Summary
|
||||
@@ -468,8 +553,7 @@ echo " • Build the installer ISO:"
|
||||
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||
echo " • Package, verify, and upload ISO to CDN:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
||||
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
||||
echo " • Verify the ISO download and checksum from the public CDN"
|
||||
echo " • Verify releases on both GitHub and Gitea"
|
||||
echo
|
||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||
|
||||
@@ -365,6 +365,100 @@ class TestAuthExemptPaths(unittest.TestCase):
|
||||
self.assertIn("/api/ping", self._get_exempt_paths())
|
||||
|
||||
|
||||
class TestFrontendAuthRecovery(unittest.TestCase):
|
||||
"""Expired browser sessions must not leave the Hub polling forever."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(
|
||||
_REPO_ROOT, "app", "sovran_systemsos_web", "static", "js", "helpers.js"
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.helpers = f.read()
|
||||
|
||||
def test_api_fetch_redirects_unauthorized_response_to_login(self):
|
||||
self.assertRegex(self.helpers, r"res\.status\s*===\s*401")
|
||||
self.assertIn('window.location.replace("/login")', self.helpers)
|
||||
|
||||
def test_unauthorized_response_does_not_use_local_auto_login(self):
|
||||
# Remote clients must still authenticate with the Hub password.
|
||||
self.assertNotIn('window.location.replace("/auto-login")', self.helpers)
|
||||
|
||||
|
||||
class TestManualLogoutPersistence(unittest.TestCase):
|
||||
"""Explicit logout must take precedence over desktop auto-login."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.server = f.read()
|
||||
|
||||
def _between(self, start, end):
|
||||
return self.server.split(start, 1)[1].split(end, 1)[0]
|
||||
|
||||
def test_auto_login_honors_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
'@app.get("/auto-login")',
|
||||
"class LoginRequest",
|
||||
)
|
||||
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
|
||||
self.assertIn('RedirectResponse(url="/login"', route)
|
||||
|
||||
def test_logout_sets_persistent_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
'@app.post("/api/logout")',
|
||||
"def _get_sovran_version",
|
||||
)
|
||||
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route)
|
||||
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
|
||||
self.assertIn("httponly=True", route)
|
||||
|
||||
def test_password_login_clears_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
'@app.post("/api/login")',
|
||||
'@app.post("/api/logout")',
|
||||
)
|
||||
self.assertIn(
|
||||
"response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route
|
||||
)
|
||||
|
||||
|
||||
class TestHubBrowserProfilePersistence(unittest.TestCase):
|
||||
"""The desktop launcher must keep a persistent browser profile.
|
||||
|
||||
The hub_manual_logout marker (and the session cookie) are stored in this
|
||||
profile. If the launcher used an ephemeral /tmp profile that it deleted on
|
||||
exit, closing and reopening the Hub window would wipe the marker and
|
||||
/auto-login would silently log the user back in without a password.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(
|
||||
_REPO_ROOT, "modules", "core", "sovran-hub.nix"
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.wrapper = f.read()
|
||||
|
||||
def test_profile_is_not_under_tmp(self):
|
||||
# The profile must live in a persistent per-user location, not /tmp.
|
||||
self.assertNotIn("/tmp/sovran-hub-brave", self.wrapper)
|
||||
|
||||
def test_profile_is_not_deleted_on_exit(self):
|
||||
# There must be no trap that removes the user-data-dir on exit.
|
||||
self.assertNotRegex(self.wrapper, r"rm\s+-rf\s+.*HUB_DATA")
|
||||
self.assertNotIn("trap '", self.wrapper)
|
||||
|
||||
def test_profile_is_persistent_per_user_location(self):
|
||||
self.assertIn("sovran-hub-browser", self.wrapper)
|
||||
# It should honour XDG_STATE_HOME (standard, persistent per-user dir).
|
||||
self.assertIn("XDG_STATE_HOME", self.wrapper)
|
||||
|
||||
def test_launcher_still_uses_user_data_dir(self):
|
||||
self.assertIn("--user-data-dir=", self.wrapper)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent session store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user