Compare commits

..
14 Commits
Author SHA1 Message Date
Sovran Systems 670cfb9acd Merge pull request #369 from naturallaw777/arena/019fb396-sovran-systemsos
Seat new domains in running Caddy + unify Njal.la/router guidance across onboarding & feature modals
2026-07-30 11:52:43 -05:00
naturallaw777andarena-agent 2015a3ecd4 Reload Caddy immediately when a domain (or ACME email) is saved
The Caddyfile is generated at runtime by caddy-generate-config.service
from /var/lib/domains/*, but the generator only re-runs when caddy.service
starts fresh. Saving a domain while Caddy is already running therefore
never seats the new virtual host — no proxying and no ACME cert — and the
Hub's reachability check shows a misleading 'ports 80/443' router error
until the next reboot or rebuild.

api_domains_set and api_domains_set_email now restart the generator and
reload Caddy (ExecReload: caddy reload --force, no dropped connections)
right after saving. Entirely skipped when Caddy is inactive — e.g. Node
role before its first domain-based service is enabled — because the
rebuild that enables the service starts caddy.service for the first time,
runs the generator first (requiredBy), and seats the already-saved domain
on its own. Best-effort throughout: a domain save never fails because of
a Caddy reload issue.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 16:50:32 +00:00
naturallaw777andarena-agent 2622223033 Unify Njal.la + router port-forwarding guidance across onboarding and feature enable modals
Single source of truth (static/js/domain-prereqs.js) for the domain
prerequisite instructions so all three surfaces read identically:

- Server + Desktop first-boot onboarding wizard (step 3)
- Lightning Wallet Connections (NWC) enable modal (Node-only mode)
- BTCPay Server (web) enable modal (Node-only mode)

Both feature-enable flows share openDomainSetupModal(), which now drops
its role-branched intro and 'Option A/B' blocks in favor of the shared
renderers. All surfaces now consistently cover:

  1. A domain from Njal.la (account, subdomain-vs-separate-domain,
     Dynamic record with host-part-only Name field, auto-filled IP,
     DDNS curl command)
  2. Router access — forward ports 80 & 443 (TCP) to this computer's
     internal IP, once, for HTTPS/SSL (with CGNAT note)
  3. How to get Njal.la working, step by step

The reconfigure/troubleshooting modal gains the same router reminder,
since 'domain not reachable' is often the port forwarding rather than
DNS. domain-prereqs.js is loaded via asset_version cache busting and
ships automatically (installPhase copies the package wholesale).

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 16:45:36 +00:00
Sovran Systems d0ec6dd760 Merge pull request #368 from naturallaw777/arena/019fb370-sovran-systemsos
Enhance Lightning Wallet Connect modal UI/UX
2026-07-30 09:47:45 -05:00
naturallaw777andarena-agent 90b1c45a0d Enhance Lightning Wallet Connect modal with professional benefits grid and refined messaging
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 14:46:09 +00:00
Sovran Systems 0ac1529a1d Merge pull request #367 from naturallaw777/arena/019faeb9-sovran-systemsos
docs: real v1.0.4 changelog + auto-generated release notes in release script
2026-07-29 11:38:42 -05:00
naturallaw777andarena-agent 1b579271e5 docs: write real v1.0.4 changelog and auto-generate release notes in release script
- CHANGELOG.md: replace v1.0.4 placeholder section with the actual changes
  (Lightning Wallet Connections/NWC, Hub version badges, backup overhaul,
  automated releases, security hardening, fixes)
- release-stable.sh: generate categorized Keep-a-Changelog release notes
  from commits since the last tag (feat/fix/docs/security grouping),
  let the user review/edit before publishing, and use the same notes for
  CHANGELOG.md, the GitHub release, and the Gitea release
- Fix broken /api/ path in the v1.0.4 changelog release link
- Build Gitea API payload with jq/python so multi-line notes are JSON-safe

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 16:36:35 +00:00
Sovran Systems c6b4b89be3 Merge pull request #366 from naturallaw777/arena/019fae93-sovran-systemsos
fix(hub): resolve 'vdev' version badge and align it under the Hub title
2026-07-29 11:07:52 -05:00
naturallaw777andarena-agent a8ff366992 fix(hub): resolve 'vdev' version badge and align it under the Hub title
- The Hub header badge could render 'vdev' because the runtime
  /etc/nixos/VERSION lookup fell back to the literal string 'dev' when
  the file was missing (e.g. dev/test environments, or before the
  Nix-generated config carried a version at all).
- modules/core/sovran-hub.nix now reads the repo's VERSION file at
  Nix eval time and bakes a real semantic version (sovran_version)
  into the generated config.json and a VERSION file shipped with the
  package, so the Hub always has a solid value to display.
- server.py's _get_sovran_version() now reads that baked-in
  sovran_version first, and explicitly rejects a literal 'dev' value
  from any of its file-based fallbacks so the badge never shows
  'vdev' again.
- templates/index.html + header.css: wrapped the title and the
  version badge in a '.title-group' column so the version badge sits
  directly underneath 'Sovran_SystemsOS Hub', left-aligned with it.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 16:06:49 +00:00
Sovran Systems ddba59cce8 Merge pull request #365 from naturallaw777/arena/019fae73-sovran-systemsos
fix(scripts): auto-detect git remotes and clean backup files in release-stable.sh
2026-07-29 10:30:10 -05:00
naturallaw777andarena-agent e12e9ba01b fix(scripts): align release script with Gitea staging-dev and stable branch workflow
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 15:28:50 +00:00
naturallaw777andarena-agent 853c3edf92 fix(scripts): auto-detect git remotes and clean up temp files in release-stable.sh
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 15:22:58 +00:00
naturallaw777 4a6ba3eacc docs: update CHANGELOG.md for v1.0.4 2026-07-29 10:19:00 -05:00
naturallaw777 537d4781f2 chore: bump VERSION to v1.0.4 for ISO naming 2026-07-29 10:19:00 -05:00
13 changed files with 587 additions and 127 deletions

No files matched your search

+46
View File
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.0.4] - 2026-07-29
### Added
- **Lightning Wallet Connections (NWC)** — Hub-managed Nostr Wallet Connect powered by Alby Hub + LND:
- Create, view, and delete wallet connections directly from the Sovran Hub with a tabbed modal UI
- Downloadable/printable LNURL QR codes for connecting external wallets
- Channel liquidity guide and onboarding guidance for new LND nodes
- Unique-hostname enforcement with conflict validation and UI guidance
- Official NWC branding and logo
- **Version visibility across the Hub**:
- Sovran_SystemsOS version badge displayed under the Hub title
- Version numbers shown on all Hub service tiles and next to service modal titles
- Deployed PHP app versions surfaced in service titles
- Build-time version reference file (`VERSION`) so version lookups are instantaneous and consistent
- **Automated stable release workflow** (`scripts/release-stable.sh`) with versioned ISO naming from the `VERSION` file
- `CONTRIBUTING.md` and expanded project documentation
### Changed
- **Manual Backup overhauled**: replaced tar+DB+LND archive approach with a reliable ext4 + rsync workflow, including
mount checks, path safety, atomic completion markers, stale-marker cleanup, and behavioral test coverage
- **Port-forwarding UX simplified**: removed onboarding Step 4 and the misleading local "ready" status;
Njal.la DDNS now runs automatically when the feature is enabled
- README restructured for clarity, links, and accuracy; added router/ISP port-forwarding requirements
for Server + Desktop mode; acknowledged LiveKit and Alby Hub
- Updated nixpkgs and Bitcoin clients
- Repository cleanup: removed unused `.github`, `.tests`, `nix/`, and `docs/ai` directories
### Fixed
- NWC wallet certificate path wiring (now uses the nix-bitcoin LND cert path)
- Deterministic LND/Alby Hub port collision
- Alby Hub executable resolution and v1.23.0 patches regenerated against exact upstream source
- Manual Backup exit-code failures (bash/gawk in service PATH, tar tolerance, flock, browser-cache exclusions)
- rsync destination-directory failures in backups (auto `mkdir -p`, mount check, 19 behavioral tests)
- `sovran-hosts-update` converted to `writeShellApplication` with explicit runtime inputs
- Incorrect `lib.mkIf` usage in the NWC wallets module
- Duplicate systemd LND strings
- `git fetch` tag-clobber errors against Gitea (now uses `--force`)
### Security
- Hardened Lightning Wallet Connections (NWC): restricted `ReadOnlyPaths` for the unlock password,
fixed an Authorization-header bug, eliminated stack-trace exposure flagged by CodeQL,
and tightened credential access and amount validation
[1.0.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.4
## [1.0.3] - 2026-07-29
### Added
+1 -1
View File
@@ -1 +1 @@
1.0.3
1.0.4
+80 -10
View File
@@ -86,6 +86,12 @@ NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
# Caddy and its runtime Caddyfile generator (see modules/core/caddy.nix).
# caddy-generate-config.service rewrites /run/caddy/Caddyfile from
# /var/lib/domains/*; caddy.service serves that generated config.
CADDY_GENERATE_UNIT = "caddy-generate-config.service"
CADDY_UNIT = "caddy.service"
# Domain keys that produce a public HTTPS virtual host via Caddy
_SERVICE_DOMAIN_KEYS = frozenset([
"matrix", "wordpress", "nextcloud", "btcpayserver",
@@ -2275,18 +2281,39 @@ async def api_logout(request: Request):
def _get_sovran_version() -> str:
"""Read the OS version from the VERSION file."""
"""Read the OS version to display in the Hub header.
Resolution order (first match wins):
1. ``sovran_version`` baked into the Nix-generated config.json — this
is the authoritative, build-time value and is what's used on a
real Sovran_SystemsOS install.
2. ``/etc/nixos/VERSION`` — present on an installed system.
3. A ``VERSION`` file shipped next to this package (installed by the
Nix build) or in the repo root (local development checkout).
4. A hard-coded fallback so the badge never renders a broken/empty
value such as the old "vdev" placeholder.
"""
try:
with open("/etc/nixos/VERSION", "r") as f:
return f.read().strip()
except FileNotFoundError:
try:
with open("VERSION", "r") as f: # fallback for development
return f.read().strip()
except FileNotFoundError:
return "dev"
version = load_config().get("sovran_version")
if version:
return str(version).strip().lstrip("vV")
except Exception:
return "dev"
pass
for candidate in (
"/etc/nixos/VERSION",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION"),
"VERSION",
):
try:
with open(candidate, "r") as f:
version = f.read().strip().lstrip("vV")
if version and version.lower() != "dev":
return version
except (FileNotFoundError, OSError):
continue
return "1.0.0"
@app.get("/", response_class=HTMLResponse)
@@ -4329,6 +4356,33 @@ def _run_njalla_ddns() -> None:
pass
def _reload_caddy_for_domain_change() -> None:
"""Regenerate Caddy's runtime Caddyfile and reload it (best-effort).
The Caddyfile is generated at runtime by caddy-generate-config.service
from /var/lib/domains/* (see modules/core/caddy.nix). The generator only
runs before caddy.service starts — nothing re-runs it while Caddy is up.
So when a domain is saved while Caddy is already running, the new virtual
host never gets seated: no proxying and no ACME certificate, and the
Hub's reachability check wrongly reports a "ports 80/443" error until
the next reboot or a rebuild that happens to start Caddy fresh. Restart
the generator, then reload Caddy so the change takes effect immediately.
Entirely skipped when Caddy is not active — e.g. the Node role before
its first domain-based service is enabled. In that case the rebuild
that enables the service starts caddy.service for the first time, which
runs the generator first (requiredBy caddy.service) and seats the
already-saved domain on its own.
"""
try:
if sysctl.is_active(CADDY_UNIT) != "active":
return
sysctl.run_action("restart", CADDY_GENERATE_UNIT)
sysctl.run_action("reload", CADDY_UNIT)
except Exception:
pass
# Hostname characters: letters, digits, hyphens only within labels; dots separate labels.
# Each label must start and end with a letter or digit; no consecutive dots.
_HOSTNAME_RE = re.compile(
@@ -4450,6 +4504,16 @@ async def api_domains_set(req: DomainSetRequest):
if req.domain_name in _SERVICE_DOMAIN_KEYS:
_trigger_hosts_update()
# If Caddy is already running, regenerate its runtime Caddyfile and reload
# so the saved domain's virtual host is seated immediately. Without this,
# a domain added to an already-running Caddy never gets its site block
# (no proxying, no ACME cert) and the Hub's reachability check shows a
# misleading "ports 80/443" error until reboot. No-op when Caddy is
# inactive — e.g. Node role pre-enable, where the rebuild seats it anyway.
if req.domain_name in _SERVICE_DOMAIN_KEYS:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _reload_caddy_for_domain_change)
return {"ok": True}
@@ -4465,6 +4529,12 @@ async def api_domains_set_email(req: DomainSetEmailRequest):
with open(email_path, "w") as f:
f.write(req.email.strip())
_chown_to_caddy(email_path)
# The ACME email lives in the Caddyfile's global block — regenerate and
# reload so a running Caddy picks it up (no-op when Caddy is inactive).
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _reload_caddy_for_domain_change)
return {"ok": True}
@@ -29,6 +29,14 @@
color: var(--text-primary);
}
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
}
.header-buttons {
display: flex;
align-items: center;
@@ -58,8 +66,8 @@
color: #4ade80;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 12px;
white-space: nowrap;
align-self: flex-start;
}
.os-version-badge:hover {
+56 -3
View File
@@ -1,5 +1,56 @@
/* ── NWC Benefits Grid ─────────────────────────────────────────── */
.nwc-benefits-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 14px;
}
.nwc-benefit-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 14px;
display: flex;
gap: 12px;
align-items: flex-start;
transition: background-color 0.2s;
}
.nwc-benefit-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.nwc-benefit-icon {
font-size: 1.25rem;
margin-top: 2px;
opacity: 0.9;
}
.nwc-benefit-content strong {
display: block;
font-size: 0.82rem;
color: var(--text-primary);
margin-bottom: 2px;
}
.nwc-benefit-content p {
margin: 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--text-secondary);
}
@media (max-width: 800px) {
.nwc-benefits-grid {
grid-template-columns: 1fr;
}
}
/* ── Update modal ────────────────────────────────────────────────── */
.modal-overlay {
display: none;
position: fixed;
@@ -607,10 +658,12 @@ button.btn-reboot:hover:not(:disabled) {
.nwc-tab-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 16px;
margin-bottom: 16px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.nwc-tab-intro-title {
@@ -0,0 +1,146 @@
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
forwarding" guidance that must read identically everywhere it appears:
• First-boot onboarding wizard (Server + Desktop role)
— onboarding.js step 3
• Feature-enable domain modal (Node role, and any role)
— features.js openDomainSetupModal()
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
• Domain reconfigure / troubleshooting modal
— features.js openDomainReconfigureModal()
Keep these three surfaces word-for-word consistent: always edit this
file, never fork the wording inline. Plain classic script (no modules) —
both templates load it with a plain <script> tag. */
"use strict";
/* Escape helper local to this file so it is self-contained on both pages. */
function dpEsc(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/* ── "What you'll need" intro ──────────────────────────────────────
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
the multi-service (onboarding) wording.
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
function renderDomainNeedsHtml(opts) {
var serviceName = opts.serviceName || null;
var hostExample = dpEsc(opts.hostExample || "myservice");
var html = "";
if (serviceName) {
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
+ "to this computer once. All domain-based services share these two ports; "
+ "they're required for HTTPS and SSL certificates.</li>";
html += "</ol>";
} else {
html += "<p><strong>Each service below needs two things set up:</strong></p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
+ "so a single domain can serve every service.</li>";
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
+ "to this computer once. All domain-based services share these two ports; "
+ "they're required for HTTPS and SSL certificates.</li>";
html += "</ol>";
}
return html;
}
/* ── "How to set it up at Njal.la" steps ───────────────────────────
opts.hostExample — host part used in the examples (default "call").
opts.pasteHint — where the curl command goes:
"below" (single service) or "next to its service below"
(onboarding, many services). */
function renderNjallaStepsHtml(opts) {
var hostExample = dpEsc((opts && opts.hostExample) || "call");
var pasteHint = (opts && opts.pasteHint) || "below";
var html = "";
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
+ "(For &quot;" + hostExample + ".yourdomain.com&quot; you&apos;d type just: <code>" + hostExample + "</code>.)<br>"
+ "&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>"
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
+ "</ul>"
+ "</li>";
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don&apos;t enter an IP anywhere. "
+ "It auto-fills once Sovran_SystemsOS runs the update command (on save, and again after every reboot).</li>";
html += "<li>Njal.la gives you a curl command, e.g.:<br>"
+ '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=' + hostExample + '.yourdomain.com&amp;k=abc123&amp;auto&quot;</code><br>'
+ "Copy it and paste it " + pasteHint + ".</li>";
html += "</ol>";
return html;
}
/* ── "One router task" port-forwarding box ─────────────────────────
opts.internalIp — LAN IP string, or empty/null for generic wording.
opts.plural — true for multi-service (onboarding) wording.
opts.includeSsh — also mention port 22 for SSH (onboarding).
opts.extraNote — extra sentence appended at the end (optional). */
function renderRouterPortsHtml(opts) {
opts = opts || {};
var ipPart = opts.internalIp
? " to this computer&rsquo;s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
: " to this computer&rsquo;s internal IP";
var serviceWord = opts.plural ? "services" : "service";
var html = "";
html += "🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>port forwarding</strong> settings, "
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
+ "This only needs to be done once — all domain services share these ports — "
+ "but HTTPS and SSL certificates won&rsquo;t work without them, so your " + serviceWord + " "
+ "can&rsquo;t be reached from outside your home network. "
+ "You&rsquo;ll need normal access to your router&rsquo;s settings with working port forwarding — "
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can&rsquo;t be reached from the internet. ";
if (opts.includeSsh) {
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
}
if (opts.extraNote) {
html += dpEsc(opts.extraNote);
}
return html;
}
/* ── Async helper: fill a router-box container with the internal IP ──
Renders generic text immediately, then upgrades to the concrete LAN IP
if /api/network provides one. Best-effort — never blocks the UI. */
function renderRouterPortsBox(elId, opts) {
var el = document.getElementById(elId);
if (!el) return;
el.innerHTML = renderRouterPortsHtml(opts || {});
fetch("/api/network")
.then(function(r) { return r.json(); })
.then(function(data) {
var target = document.getElementById(elId);
if (!target) return;
var ip = data && data.internal_ip;
if (ip && ip !== "unavailable") {
var next = {};
for (var k in (opts || {})) next[k] = opts[k];
next.internalIp = String(ip).trim();
target.innerHTML = renderRouterPortsHtml(next);
}
})
.catch(function() { /* generic wording already shown — fine */ });
}
+15 -35
View File
@@ -86,45 +86,17 @@ function openDomainSetupModal(feat, onSaved) {
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
var introHtml;
if (_currentRole === "node") {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
'</ol>';
} else {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
}
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
// identical wording to the Server + Desktop onboarding wizard and every other
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
var hostExample = isWalletConnections ? "lightning" : "call";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
nwcWarning +
introHtml +
'<details style="margin-top:10px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In Njal.la, open a domain you own and click &quot;Add record&quot;.</li>' +
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
'(Example only, your choice — for &quot;' + domainLabelExample + '&quot; you&apos;d type just: &nbsp;<code>' + (isWalletConnections ? 'lightning' : 'call') + '</code>)<br>' +
'&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=' + domainLabelExample + '&amp;k=abc123&amp;auto&quot;</code></li>' +
'</ol>' +
'</details>' +
'<details style="margin-top:6px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In Njal.la, buy the domain you want.</li>' +
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
'<li>Copy the curl command Njal.la gives you.</li>' +
'</ol>' +
'</details>' +
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
@@ -170,6 +142,9 @@ function openDomainSetupModal(feat, onSaved) {
});
$domainSetupModal.classList.add("open");
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
renderRouterPortsBox("domain-router-box");
}
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
@@ -218,7 +193,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
'<li>If the IP is wrong or the record is missing, update it</li>' +
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
'</ol>' +
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
@@ -263,6 +240,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
});
$domainSetupModal.classList.add("open");
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
renderRouterPortsBox("domain-router-box");
}
function closeDomainSetupModal() {
@@ -174,7 +174,7 @@ function _nwcRenderWalletState() {
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
html +=
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
'<p class="nwc-tab-intro-desc">An isolated wallet you pair to a single app, with its own Lightning Address.</p>' +
'<p class="nwc-tab-intro-desc">Create a secure, sandboxed wallet for a specific app. Each wallet gets its own Lightning Address and optional spending limit.</p>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
@@ -330,9 +330,9 @@ function _nwcRenderWalletState() {
if (!state.wallets || state.wallets.length === 0) {
html += '<div class="nwc-empty-state">' +
'<div class="nwc-empty-icon">👛</div>' +
'<div class="nwc-empty-title">No wallets yet</div>' +
'<p class="nwc-empty-desc">Create your first wallet to get a one-time pairing secret for an app, plus a reusable Lightning Address.</p>' +
'<div class="nwc-empty-icon">⚡</div>' +
'<div class="nwc-empty-title">Ready to start spending</div>' +
'<p class="nwc-empty-desc">Create your first isolated wallet to connect to apps like Zeus or Nostr. Experience faster, more secure Lightning payments today.</p>' +
'</div>';
host.innerHTML = html;
_nwcWireDomainLink();
@@ -761,9 +761,32 @@ async function openServiceDetailModal(unit, name, icon) {
if (isNwc) {
if (effectiveEnabled || data.enabled) {
html += '<div class="nwc-tab-intro">' +
'<div>' +
'<div class="nwc-tab-intro-title">Your Lightning Wallets</div>' +
'<p class="nwc-tab-intro-desc">Each wallet is isolated: pair it to one app with a one-time secret, and share its Lightning Address to get paid.</p>' +
'<div class="nwc-intro-header">' +
'<div class="nwc-tab-intro-title">Lightning Wallet Connections</div>' +
'<p class="nwc-tab-intro-desc">Powerful, isolated wallets for your daily spending and modern apps.</p>' +
'</div>' +
'<div class="nwc-benefits-grid">' +
'<div class="nwc-benefit-item">' +
'<div class="nwc-benefit-icon">🛡️</div>' +
'<div class="nwc-benefit-content">' +
'<strong>Isolated & Secure</strong>' +
'<p>Create sandboxed wallets for quick spending while keeping your main node protected. Isolated access is faster than Tor and perfect for budgeting.</p>' +
'</div>' +
'</div>' +
'<div class="nwc-benefit-item">' +
'<div class="nwc-benefit-icon">🌐</div>' +
'<div class="nwc-benefit-content">' +
'<strong>Modern Ecosystem</strong>' +
'<p>Easily spend and receive bitcoin using LNURL and Nostr (NWC) across the growing ecosystem of decentralized apps.</p>' +
'</div>' +
'</div>' +
'<div class="nwc-benefit-item">' +
'<div class="nwc-benefit-icon">📱</div>' +
'<div class="nwc-benefit-content">' +
'<strong>Zeus on the Go</strong>' +
'<p>Connect instantly to Zeus via NWC. Manage your spending and receive payments right from your phone, wherever you are.</p>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
+13 -27
View File
@@ -357,38 +357,24 @@ async function loadStep3() {
if (relevantDomains.length === 0) {
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else {
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
// identical wording to the feature-enable domain modal shown for NWC,
// BTCPay Server, and every other domain-based feature, in every role.
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
+ '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
+ '(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: <code>call</code>.)<br>'
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
+ '&#9888; Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
+ '<li>A Dynamic record has NO IP field. You don&apos;t enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
+ '</ul>'
+ '</li>'
+ '<li>Njal.la gives you a curl command like:<br>'
+ '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>'
+ '</ol>'
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
+ '</div>';
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
// Compact router note (full port guidance is shown when a feature is
// enabled, and lives on each service tile afterwards)
var routerIpPart = internalIp
? ' to this computer&rsquo;s internal IP <strong>' + escHtml(internalIp) + '</strong>'
: ' to this computer&rsquo;s internal IP';
// Router note (the same wording is shown again, per-service, whenever a
// domain-based feature is enabled, and lives on each service tile afterwards)
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>port forwarding</strong> settings, forward '
+ 'port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>'
+ routerIpPart + '. Use the <strong>same number for the internal and external port</strong>. '
+ 'These are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. '
+ 'Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. '
+ 'Element Call needs a few extra ports (some UDP), and you&rsquo;ll be shown exactly which when you enable it.'
+ renderRouterPortsHtml({
internalIp: internalIp,
plural: true,
includeSsh: true,
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
})
+ '</div>';
relevantDomains.forEach(function(d) {
+11 -7
View File
@@ -21,13 +21,16 @@
<!-- Header bar -->
<header class="header-bar">
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
<span class="title">Sovran_SystemsOS Hub</span>
<!-- OS Version Badge -->
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">
<span class="version-label">v</span>
<span class="version-number" id="version-number">{{ sovran_version }}</span>
</span>
<div class="title-group">
<span class="title">Sovran_SystemsOS Hub</span>
<!-- OS Version Badge -->
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">
<span class="version-label">v</span>
<span class="version-number" id="version-number">{{ sovran_version }}</span>
</span>
</div>
<div class="header-buttons">
<span class="role-badge" id="role-badge">Loading…</span>
@@ -309,6 +312,7 @@
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
<script src="/static/js/state.js?v={{ asset_version }}"></script>
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
<script src="/static/js/support.js?v={{ asset_version }}"></script>
@@ -125,9 +125,8 @@
<span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Domain Configuration</h2>
<p class="onboarding-step-desc">
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
Paste the curl command from your Njal.la dashboard for each service.
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
</p>
</div>
<div class="onboarding-card" id="step-3-body">
@@ -170,6 +169,7 @@
</div><!-- /panel-wrap -->
</div><!-- /shell -->
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
</body>
</html>
+12
View File
@@ -3,6 +3,16 @@
let
cfg = config.sovran_systemsOS;
# Read the OS version from the repo-root VERSION file at eval time so the
# Hub always ships a real, baked-in version string instead of relying on
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
versionFile = ../../VERSION;
sovranVersion =
if builtins.pathExists versionFile
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
else "1.0.0";
monitoredServices =
# ── Infrastructure — System Passwords (always present) ─────
[
@@ -116,6 +126,7 @@ let
role = activeRole;
services = monitoredServices;
feature_manager = true;
sovran_version = sovranVersion;
});
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
@@ -331,6 +342,7 @@ let
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
install -d $out/share/sovran-hub/icons
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
+165 -33
View File
@@ -29,11 +29,31 @@ CYAN='\033[0;36m'
NC='\033[0m'
# Configuration
GITEA_REMOTE="gitea"
GITHUB_REMOTE="origin"
GITEA_REMOTE_DEFAULT="gitea"
GITHUB_REMOTE_DEFAULT="origin"
GITEA_API_URL="https://git.sovransystems.com/api/v1"
CHANGELOG_FILE="CHANGELOG.md"
# Auto-detect remotes
detect_remote() {
local preferred="$1"
local keyword="$2"
if git remote | grep -q -x "$preferred"; then
echo "$preferred"
return
fi
local found
found=$(git remote -v | grep -i "$keyword" | head -n 1 | awk '{print $1}')
if [[ -n "$found" ]]; then
echo "$found"
return
fi
echo "$preferred"
}
GITEA_REMOTE=$(detect_remote "$GITEA_REMOTE_DEFAULT" "sovransystems\|gitea")
GITHUB_REMOTE=$(detect_remote "$GITHUB_REMOTE_DEFAULT" "github")
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
@@ -110,7 +130,7 @@ done
if [[ -z "$RELEASE_MESSAGE" ]]; then
echo
read -rp "Enter release message (or press Enter for default): " input_msg
read -rp "Enter release headline (or press Enter for default): " input_msg
if [[ -n "$input_msg" ]]; then
RELEASE_MESSAGE="$input_msg"
else
@@ -118,6 +138,91 @@ if [[ -z "$RELEASE_MESSAGE" ]]; then
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# Helper: Generate categorized release notes from commit history
# Groups commits since the last tag into Keep-a-Changelog sections based on
# conventional-commit prefixes (feat/fix/docs/security/etc.) and keywords.
# ─────────────────────────────────────────────────────────────────────────────
generate_release_notes() {
local range="$1"
local added="" changed="" fixed="" security="" docs=""
while IFS= read -r subject; do
# Skip noise commits
case "$subject" in
"Initial plan"|"initial plan") continue ;;
"Merge pull request"*|"Merge branch"*) continue ;;
"chore: bump VERSION"*|"docs: update CHANGELOG"*) continue ;;
"Address code review"*|"Address review"*|"Address validation"*) continue ;;
esac
# Strip conventional-commit prefix for display
local clean
clean="$(echo "$subject" | sed -E 's/^(feat|fix|docs|chore|refactor|test|security|perf|style|ci|build)(\([^)]*\))?!?:[[:space:]]*//')"
# Capitalize first letter
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"
case "$subject" in
security:*|security\(*) security+="- ${clean}"$'\n' ;;
feat:*|feat\(*) added+="- ${clean}"$'\n' ;;
fix:*|fix\(*|Fix\ *|fixed\ *) fixed+="- ${clean}"$'\n' ;;
docs:*|docs\(*) docs+="- ${clean}"$'\n' ;;
refactor:*|refactor\(*|chore:*|chore\(*|removed\ *|Updated\ *|updated\ *) changed+="- ${clean}"$'\n' ;;
test:*|test\(*) continue ;;
*) added+="- ${clean}"$'\n' ;;
esac
done < <(git log --no-merges --format='%s' "$range" 2>/dev/null | awk '!seen[$0]++')
local notes=""
if [[ -n "$added" ]]; then notes+=$'### Added\n'"$added"$'\n'; fi
if [[ -n "$changed" ]]; then notes+=$'### Changed\n'"$changed"$'\n'; fi
if [[ -n "$fixed" ]]; then notes+=$'### Fixed\n'"$fixed"$'\n'; fi
if [[ -n "$security" ]]; then notes+=$'### Security\n'"$security"$'\n'; fi
if [[ -n "$docs" ]]; then notes+=$'### Documentation\n'"$docs"$'\n'; fi
if [[ -z "$notes" ]]; then
notes=$'### Changed\n- Incremental stable updates\n'
fi
printf '%s' "$notes"
}
# Build the notes from commits since the previous tag
if [[ -n "$LATEST_TAG" ]] && git rev-parse -q --verify "$LATEST_TAG" >/dev/null; then
COMMIT_RANGE="${LATEST_TAG}..HEAD"
else
COMMIT_RANGE="HEAD"
fi
echo
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${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")"
{
echo "## Sovran_SystemsOS ${TAG}"
echo
echo "${RELEASE_MESSAGE}"
echo
echo "$RELEASE_NOTES"
echo "**Full changelog:** [CHANGELOG.md](https://github.com/naturallaw777/Sovran_SystemsOS/blob/main/CHANGELOG.md)"
} > "$NOTES_FILE"
echo -e " ${GREEN}✓${NC} Draft notes written to: ${CYAN}${NOTES_FILE}${NC}"
echo
echo "──────────────── Draft Release Notes ────────────────"
cat "$NOTES_FILE"
echo "──────────────────────────────────────────────────────"
echo
read -rp "Edit the notes before publishing? (y/N): " edit_confirm
if [[ "$edit_confirm" =~ ^[Yy]$ ]]; then
"${EDITOR:-nano}" "$NOTES_FILE"
RELEASE_NOTES="$(sed -n '/^###/,$p' "$NOTES_FILE" | sed '/^\*\*Full changelog/d')"
fi
RELEASE_BODY="$(cat "$NOTES_FILE")"
echo
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Preparing Release${NC}"
@@ -135,11 +240,16 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
fi
# ─────────────────────────────────────────────────────────────────────────────
# Step 1: Push main to stable
# Step 1: Push tested code to Gitea (stable & staging-dev)
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 1: Pushing main → stable on Gitea...${NC}"
git push "${GITEA_REMOTE}" main:stable --force-with-lease
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
# ─────────────────────────────────────────────────────────────────────────────
# Step 2: Create annotated tag
@@ -149,9 +259,17 @@ echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
git tag -a "${TAG}" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details"
- See CHANGELOG.md for full details" || true
git push "${GITEA_REMOTE}" "${TAG}"
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
echo "${VERSION}" > VERSION
@@ -167,19 +285,11 @@ echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
TODAY=$(date +%Y-%m-%d)
# Create new changelog entry
# Create new changelog entry from the generated notes (no placeholders)
NEW_ENTRY="## [${VERSION}] - ${TODAY}
### Added
- (Add new features here)
### Changed
- (Add changes here)
### Fixed
- (Add bug fixes here)
[${VERSION}]: ${GITEA_API_URL%/*}/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
${RELEASE_NOTES}
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
"
# Prepend to changelog (after the header)
@@ -199,6 +309,7 @@ if [ -f "$CHANGELOG_FILE" ]; then
}
{ 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
@@ -215,11 +326,24 @@ else
# Ask if user wants to push
echo
read -rp "Push the changelog commit to GitHub now? (y/N): " push_confirm
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
echo -e "${BLUE}Pushing changelog commit...${NC}"
git push "${GITHUB_REMOTE}" main
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub"
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
@@ -234,8 +358,8 @@ echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
if command -v gh &>/dev/null; then
if gh release create "${TAG}" \
--repo naturallaw777/Sovran_SystemsOS \
--title "${TAG}" \
--notes "${RELEASE_MESSAGE}" \
--title "${TAG} — ${RELEASE_MESSAGE#Sovran_SystemsOS v* — }" \
--notes-file "${NOTES_FILE}" \
--target main 2>/dev/null; then
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
else
@@ -266,16 +390,21 @@ fi
if [[ -n "${GITEA_TOKEN:-}" ]]; then
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
# 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 "{
\"tag_name\": \"${TAG}\",
\"name\": \"${TAG}\",
\"body\": \"${RELEASE_MESSAGE}\",
\"draft\": false,
\"prerelease\": false
}" \
-d "${PAYLOAD}" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
if echo "$RESPONSE" | grep -q '"id"'; then
@@ -296,8 +425,11 @@ echo -e "${GREEN}╚════════════════════
echo
echo "Next manual steps (recommended):"
echo " • Review and enhance the new section in CHANGELOG.md"
echo " • Push the changelog commit: git push origin main"
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
echo " • Verify releases on both GitHub and Gitea"
echo
echo -e "${CYAN}Tag created: ${TAG}${NC}"
git show "${TAG}" --quiet
# Clean up temp notes file
rm -f "${NOTES_FILE}"