Compare commits

...
Author SHA1 Message Date
archipelago d4018a6e73 chore: prepare release v1.8.6-alpha 2026-08-31 15:34:51 -04:00
archipelago b57cba63d1 Merge remote-tracking branch 'gitea-vps2/main'
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:12:59 -04:00
archipelago 7bc9f69b1f fix(settings): start What's New history at v1.8.0 2026-08-31 15:12:18 -04:00
lfg2025 913743923c Merge pull request 'docs: deploy handoff — companion 0.5.28 to the live surfaces' (#150) from companion/0.5.28-deploy-handoff into main 2026-08-31 19:01:58 +00:00
Dorian 241e8cfca4 docs: handoff — deploy companion 0.5.28 (vc48) to the live surfaces
For the archi-dev-box agent: companion 0.5.28 is on main (PR #149) and
Gitea raw serves it (verified byte-identical, v1+v2+v3). Remaining: the
foundation server's static /packages mirror (the real-node QR download
URL — currently 0.5.27), node web-bundle redeploys (same as the
2026-07-23 flow), and confirming the demo stack flipped after CI's
webhook redeploy. Exact commands, expected shasum, and final verify
block included.
2026-08-31 20:01:44 +01:00
archipelago 017505c431 fix(release): include every curated changelog item 2026-08-31 14:52:47 -04:00
archipelago 7a39d8fbd1 fix(settings): sort What's New history newest-first
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 14:50:55 -04:00
archipelago e3275353b9 fix(release): publish assets before exposing manifest 2026-08-31 14:45:29 -04:00
lfg2025 9f1a289d1a Merge pull request 'Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch' (#149) from companion/0.5.28-ship into main
Demo images / Build & push demo images (push) Failing after 41s
2026-08-31 18:38:43 +00:00
14 changed files with 383 additions and 1928 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## v1.8.6-alpha (2026-08-31)
- **Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.
- **What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.
- **A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines.
## v1.8.5-alpha (2026-08-30) ## v1.8.5-alpha (2026-08-30)
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one. - **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.5-alpha" version = "1.8.6-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.5-alpha" version = "1.8.6-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
@@ -0,0 +1,74 @@
# HANDOFF — deploy companion 0.5.28 (vc48) to the live surfaces
**For: the agent on archi-dev-box.** Companion 0.5.28 shipped to `main`
today (PR #149, merge `9f1a289d` — backup & restore #128, NIP-46 remote
signer #139, companion-gated install pitch #61 residual, hub sub-pages).
The dev box verified everything it can reach; three live surfaces remain,
same shape as the 2026-07-23 deploy handoff
([`HANDOFF-2026-07-23-companion-apk-deploy.md`](HANDOFF-2026-07-23-companion-apk-deploy.md)).
## Already done and verified (do not redo)
- `neode-ui/public/packages/archipelago-companion.apk` on `main` is
**0.5.28 / versionCode 48**, clean build via `Android/ship-companion.sh`,
**v1+v2+v3 signatures verified**, meta json refreshed beside it.
- Gitea raw-on-main serves it byte-identical:
`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`
(2 bytes: 28,206,999… file size ≈ 28.2 MB).
- The foundation server's **raw-proxy** path already serves 0.5.28 (verified
via `https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json`).
- Demo CI (`demo-images.yml`) fired on the push and redeploys the stack via
the Portainer webhook — should flip on its own; confirm only.
- Signing key unchanged (cert SHA-256 `d622e07e…ec2664d`), so phones update
**in place** over any 0.5.27 install.
## 1. Foundation server static `/packages/` mirror — the real-node QR URL
`https://source.archipelago-foundation.org/packages/archipelago-companion.apk`
is a **static dir** on the release server (openresty; still 0.5.27,
last-modified 2026-08-17). This is the exact URL real nodes' companion QR
downloads (`DEFAULT_DOWNLOAD_URL` in `CompanionIntroOverlay.vue`) — it must
flip before the release is done.
```bash
# Find the webroot once:
grep -rl "packages" /etc/openresty /etc/nginx 2>/dev/null
find / -name archipelago-companion.apk -not -path '/proc/*' 2>/dev/null
# Mirror the exact bytes from Gitea raw-on-main (no rebuild, no re-sign):
cd <that webroot>
curl -fsS -o archipelago-companion.apk http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk
curl -fsS -o archipelago-companion.json http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json
shasum -a 256 archipelago-companion.apk
# MUST print: fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca
```
## 2. Node web-bundle redeploys
Same as 2026-07-23: redeploy the web-ui bundle from current `main` to the
active nodes — web root `/opt/archipelago/web-ui/` (NOT a `neode-ui/`
subfolder), at minimum every node the user pairs against. The APK rides in
the bundle's `packages/` dir, so this is also what makes each node's own
served QR download 0.5.28.
## 3. Confirm the demo flipped
`curl -s http://146.59.87.168:2100/packages/archipelago-companion.json`
should read 0.5.28/48 once CI's Portainer webhook redeploy lands; trigger a
stack redeploy if it lags.
## Final verify (all three must show 0.5.28 / 48)
```bash
aapt2 dump badging <downloaded apk> | head -1 # versionCode='48' versionName='0.5.28-debug'
apksigner verify -v --min-sdk-version 21 <downloaded apk> | grep scheme # v1/v2/v3 true
curl -s https://source.archipelago-foundation.org/packages/archipelago-companion.json
curl -s http://146.59.87.168:2100/packages/archipelago-companion.json
```
Then the user's on-device end-to-end: scan the node's companion QR →
installs vc48 in place → hub → Backup & Restore / Remote Signer.
Testing notes for the new features live in the closed tracker issues
(#61/#128/#139) and `docs/companion-backup-restore.md` /
`docs/companion-nip46-remote-signer.md` (the signer's e2e harness:
`Android/tools/nip46-test-client.py`).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.5-alpha", "version": "1.8.6-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.5-alpha", "version": "1.8.6-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.5-alpha", "version": "1.8.6-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
{
"changelog": [
"**Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.",
"**What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.",
"**A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines."
],
"components": [
{
"current_version": "1.8.6-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.6-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.6-alpha",
"sha256": "6171e1f46b0e2c2754a18e668f16810338a09b71a68dc365d65173ca1e4d400a",
"size_bytes": 63930112
},
{
"current_version": "1.8.6-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.6-alpha/archipelago-frontend-1.8.6-alpha.tar.gz",
"name": "archipelago-frontend-1.8.6-alpha.tar.gz",
"new_version": "1.8.6-alpha",
"sha256": "1a5b54c516f91a4ea4491e08182f0f9521ee7e27935388814007f91695f06e0f",
"size_bytes": 97769923
}
],
"release_date": "2026-08-31",
"signature": "c21dc5cb5a6caa3253c08835b65d0417a312bc8c51acdf92df990ad2110ae762b77287c479a523a8521827ec7b4695114ff9c39924cf1d30d0d622abb53b9208",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.6-alpha"
}
+18 -5
View File
@@ -1,21 +1,22 @@
#!/bin/bash #!/bin/bash
# Validate releases/manifest.json: # Validate the live or a pending release manifest:
# - version matches core/archipelago/Cargo.toml # - version matches core/archipelago/Cargo.toml
# - changelog contains curated release notes, not raw git log output # - changelog contains curated release notes, not raw git log output
# - every component's download_url exists on disk and matches sha256/size # - every component's download_url exists on disk and matches sha256/size
# #
# Run on every push from CI, and also locally before publishing a release: # Run on every push from CI, and also locally before publishing a release:
# scripts/check-release-manifest.sh # scripts/check-release-manifest.sh [path/to/manifest.json]
# #
# Exits non-zero on any mismatch so the release process fails loud. # Exits non-zero on any mismatch so the release process fails loud.
set -eo pipefail set -eo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="$REPO_ROOT/releases/manifest.json" MANIFEST="${1:-$REPO_ROOT/releases/manifest.json}"
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST"
if [ ! -f "$MANIFEST" ]; then if [ ! -f "$MANIFEST" ]; then
echo "❌ releases/manifest.json missing" echo "❌ manifest missing: $MANIFEST"
exit 1 exit 1
fi fi
@@ -25,6 +26,18 @@ ok() { echo "✅ $*"; }
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])") MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/') CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
# A prepared release deliberately leaves the live manifest on the previous
# version. Ordinary pushes are therefore harmless: only the publisher promotes
# the pending manifest after its assets have been uploaded and downloaded back.
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ] && [ "$MANIFEST" = "$REPO_ROOT/releases/manifest.json" ]; then
PENDING="$REPO_ROOT/releases/pending/v${CARGO_VERSION}/manifest.json"
if [ -f "$PENDING" ]; then
ok "live manifest remains v${MANIFEST_VERSION} while v${CARGO_VERSION} is pending"
MANIFEST="$PENDING"
MANIFEST_VERSION="$CARGO_VERSION"
fi
fi
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)" fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
fi fi
@@ -105,4 +118,4 @@ for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
done done
echo echo
ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}" ok "$MANIFEST passes all checks — safe to publish v${MANIFEST_VERSION}"
+12 -8
View File
@@ -261,15 +261,19 @@ content = open('$CHANGELOG_FILE').read()
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)' pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
m = re.search(pattern, content, re.DOTALL) m = re.search(pattern, content, re.DOTALL)
if m: if m:
for line in m.group(1).strip().split('\n')[:10]: for line in m.group(1).splitlines():
line = line.strip() line = line.strip()
if line: if not line.startswith('- '):
print(line) continue
text = line[2:].strip()
if text.lower().startswith('validation '):
continue
print(text)
" 2>/dev/null || echo "") " 2>/dev/null || echo "")
if [ -n "$ENTRIES" ]; then if [ -n "$ENTRIES" ]; then
CHANGELOG=$(echo "$ENTRIES" | python3 -c " CHANGELOG=$(echo "$ENTRIES" | python3 -c "
import sys, json import sys, json
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()] lines = [l.strip() for l in sys.stdin if l.strip()]
print(json.dumps(lines)) print(json.dumps(lines))
") ")
fi fi
@@ -298,7 +302,7 @@ echo ""
cat "$OUTPUT_FILE" cat "$OUTPUT_FILE"
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Review the manifest above" echo " 1. Review and sign the manifest above"
echo " 2. Upload artifacts to Gitea release v$VERSION" echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest"
echo " 3. Commit manifest.json to releases/manifest.json on main" echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2"
echo " 4. Tag the release: git tag v$VERSION && git push --tags" echo " (it uploads + verifies assets before atomically promoting the manifest)"
+39 -49
View File
@@ -2,7 +2,8 @@
# create-release.sh — Full release automation for Archipelago # create-release.sh — Full release automation for Archipelago
# #
# Bumps version in Cargo.toml and package.json, generates changelog from git log, # Bumps version in Cargo.toml and package.json, generates changelog from git log,
# creates release manifest, and creates git tag. # creates a pending release manifest, and creates git tag. The live manifest is
# promoted only by publish-release-assets.sh after the assets are verified.
# #
# Usage: # Usage:
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0 # ./scripts/create-release.sh 1.0.0 # Release v1.0.0
@@ -30,9 +31,9 @@ for arg in "$@"; do
echo " 2. Bump version in Cargo.toml and package.json" echo " 2. Bump version in Cargo.toml and package.json"
echo " 3. Build backend" echo " 3. Build backend"
echo " 4. Build frontend" echo " 4. Build frontend"
echo " 5. Generate changelog from git log" echo " 5. Validate the curated changelog"
echo " 6. Create release manifest" echo " 6. Create pending release manifest"
echo " 7. Commit version bump" echo " 7. Commit release preparation"
echo " 8. Create git tag v{VERSION}" echo " 8. Create git tag v{VERSION}"
echo "" echo ""
echo "Options:" echo "Options:"
@@ -121,14 +122,13 @@ if $DRY_RUN; then
echo " 2. Update neode-ui/package.json version to $VERSION" echo " 2. Update neode-ui/package.json version to $VERSION"
echo " 3. Build backend (cargo build --release -p archipelago)" echo " 3. Build backend (cargo build --release -p archipelago)"
echo " 4. Build frontend (npm run build)" echo " 4. Build frontend (npm run build)"
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}" echo " 5. Validate the curated changelog"
echo " 6. Create release manifest" echo " 6. Create pending release manifest (the live manifest stays unchanged)"
echo " 7. Commit: 'chore: release v${VERSION}'" echo " 7. Commit: 'chore: prepare release v${VERSION}'"
echo " 8. Tag: v${VERSION}" echo " 8. Tag: v${VERSION}"
echo "" echo ""
echo "After this script, you would:" echo "After this script, publish only with:"
echo " - Push: git push && git push --tags" echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
exit 0 exit 0
fi fi
@@ -214,9 +214,13 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"
exit 1 exit 1
fi fi
echo "[6/8] Creating release manifest..." echo "[6/8] Creating pending release manifest..."
mkdir -p "$PROJECT_ROOT/releases" # Never write the fleet-visible path here. A normal `git push main` must not be
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$" # capable of advertising assets which have not been uploaded yet.
PENDING_DIR="$PROJECT_ROOT/releases/pending/v${VERSION}"
PENDING_MANIFEST="$PENDING_DIR/manifest.json"
mkdir -p "$PENDING_DIR"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PENDING_MANIFEST" 2>&1 | grep -v "^$"
# §A supply-chain: the OTA manifest must carry the release-root signature. # §A supply-chain: the OTA manifest must carry the release-root signature.
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh # Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
@@ -239,60 +243,45 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
echo " Enter by itself will NOT submit; pasting twice concatenates" echo " Enter by itself will NOT submit; pasting twice concatenates"
echo " the phrases and fails on word count." echo " the phrases and fails on word count."
echo "════════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════════"
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json" "$SIGNER" ceremony sign "$PENDING_MANIFEST"
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" "$SIGNER" ceremony verify "$PENDING_MANIFEST"
else else
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED." echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — pending manifest left UNSIGNED."
echo " This run will ABORT before committing (step 7 refuses an unsigned" echo " This run will ABORT before committing (step 7 refuses an unsigned manifest)."
echo " manifest), because nodes read releases/manifest.json from branch main" echo " Sign it, then re-run: bash scripts/sign-manifest.sh $PENDING_MANIFEST"
echo " and would refuse to auto-apply it."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
fi fi
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6c/8] Staging release artifacts for validation..." echo "[6c/8] Staging release artifacts for validation..."
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}" VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz" FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
mkdir -p "$VERSION_DIR" mkdir -p "$VERSION_DIR"
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago" install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz" install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh" "$SCRIPT_DIR/check-release-manifest.sh" "$PENDING_MANIFEST"
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER, # §A supply-chain gate, mirroring publish-release-assets.sh. The pending path
# because publishing is not the first way an unsigned manifest reaches the # prevents an ordinary main push from exposing the release, but an unsigned
# fleet. Nodes fetch releases/manifest.json straight from branch `main` # manifest is still unpublishable and must never be tagged as ready.
# (see the verification URLs printed below), so the COMMIT is what exposes
# it, not the publish. publish-release-assets.sh refusing to ship is a
# backstop that arrives one step too late: by then the unsigned manifest is
# already on main and the fleet is already refusing to auto-apply.
#
# This is why every cycle needed a manual catch. The signing block above is
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
# warning and falls through — and the commit then happened anyway. A release
# commit carrying a manifest no node will accept has no valid use, so refuse
# to create one rather than leave a tag that has to be re-cut.
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed # Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
# with the old root (z6Mkkid…q7ur) — it is the release that installed this # with the old root (z6Mkkid…q7ur) — it is the release that installed this
# pin on every node. From v1.7.123 onward the new root signs, and nodes # pin on every node. From v1.7.123 onward the new root signs, and nodes
# running .122+ reject anything signed with the old key. # running .122+ reject anything signed with the old key.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ if ! grep -q '"signature":' "$PENDING_MANIFEST" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then || ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PENDING_MANIFEST"; then
echo "" >&2 echo "" >&2
echo "Error: releases/manifest.json is NOT signed by the release root." >&2 echo "Error: the pending manifest is NOT signed by the release root." >&2
echo " Refusing to commit — nodes read this file from branch main and will" >&2 echo " Refusing to commit an unpublishable release." >&2
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
echo "" >&2 echo "" >&2
echo " Sign it, then re-run this script:" >&2 echo " Sign it, then re-run this script:" >&2
echo " bash scripts/sign-manifest.sh" >&2 echo " bash scripts/sign-manifest.sh $PENDING_MANIFEST" >&2
echo "" >&2 echo "" >&2
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2 echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
exit 1 exit 1
fi fi
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \ "$SIGNER" ceremony verify "$PENDING_MANIFEST" \
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; } || { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
echo "[7/8] Committing version bump..." echo "[7/8] Committing release preparation..."
git -C "$PROJECT_ROOT" add \ git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \ core/archipelago/Cargo.toml \
core/Cargo.lock \ core/Cargo.lock \
@@ -300,15 +289,16 @@ git -C "$PROJECT_ROOT" add \
neode-ui/package-lock.json \ neode-ui/package-lock.json \
neode-ui/public/catalog.json \ neode-ui/public/catalog.json \
CHANGELOG.md \ CHANGELOG.md \
releases/manifest.json \
release-manifest.json \
2>/dev/null || true 2>/dev/null || true
# releases/** is ignored because binaries live in Gitea attachments; force-add
# only this small signed pending manifest.
git -C "$PROJECT_ROOT" add -f "releases/pending/v${VERSION}/manifest.json"
# Cargo.lock (rewritten by the release build after the version bump) and # Cargo.lock (rewritten by the release build after the version bump) and
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in # neode-ui/public/catalog.json (regenerated by the frontend build) belong in
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree # THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
# preflight on three consecutive releases (.127-.129, 2026-08-09/10). # preflight on three consecutive releases (.127-.129, 2026-08-09/10).
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}" git -C "$PROJECT_ROOT" commit -m "chore: prepare release v${VERSION}"
echo "[8/8] Creating git tag..." echo "[8/8] Creating git tag..."
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}" git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
@@ -319,8 +309,8 @@ echo ""
echo "Artifacts:" echo "Artifacts:"
echo " - Version bumped in Cargo.toml and package.json" echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md" echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: releases/manifest.json" echo " - Pending manifest: releases/pending/v${VERSION}/manifest.json"
echo " - Release manifest copy: release-manifest.json" echo " - Live manifest: unchanged until assets pass publication verification"
echo " - Staged artifacts: releases/v${VERSION}/" echo " - Staged artifacts: releases/v${VERSION}/"
echo " - Git tag: v${VERSION}" echo " - Git tag: v${VERSION}"
echo "" echo ""
+46 -10
View File
@@ -16,14 +16,26 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}" VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
BACKEND="$VERSION_DIR/archipelago" BACKEND="$VERSION_DIR/archipelago"
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz" FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
PENDING_MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/manifest.json"
LIVE_MANIFEST="$PROJECT_ROOT/releases/manifest.json"
if [ -f "$PENDING_MANIFEST" ]; then
MANIFEST="$PENDING_MANIFEST"
PROMOTE_MANIFEST=1
else
# Backward compatibility for releases prepared before pending manifests.
MANIFEST="$LIVE_MANIFEST"
PROMOTE_MANIFEST=0
fi
fail() { echo "Error: $*" >&2; exit 1; } fail() { echo "Error: $*" >&2; exit 1; }
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing" [ -f "$MANIFEST" ] || fail "release manifest missing: $MANIFEST"
MANIFEST_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")
[ "$MANIFEST_VERSION" = "$VERSION" ] || fail "requested v$VERSION but $MANIFEST describes v$MANIFEST_VERSION"
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND" [ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND" [ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
"$SCRIPT_DIR/check-release-manifest.sh" "$SCRIPT_DIR/check-release-manifest.sh" "$MANIFEST"
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes # §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
# with the pinned release-root anchor refuse to auto-apply unsigned manifests, # with the pinned release-root anchor refuse to auto-apply unsigned manifests,
@@ -32,11 +44,11 @@ fail() { echo "Error: $*" >&2; exit 1; }
# Release root ROTATED 2026-08-05; see create-release.sh. New root from # Release root ROTATED 2026-08-05; see create-release.sh. New root from
# v1.7.123 onward. # v1.7.123 onward.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ grep -q '"signature":' "$MANIFEST" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \ && grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$MANIFEST" \
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh" || fail "$MANIFEST is not signed by the release root — run: bash scripts/sign-manifest.sh $MANIFEST"
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \ "$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$MANIFEST" \
|| fail "manifest signature failed cryptographic verification" || fail "manifest signature failed cryptographic verification"
fi fi
@@ -130,12 +142,36 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
# hand during recovery. It fails hard on the first bad asset — the previous # hand during recovery. It fails hard on the first bad asset — the previous
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only # inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
# the subshell and let this script march on to "published and verified". # the subshell and let this script march on to "published and verified".
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \ "$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run." || fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
# Assets are proven fetchable — only now does the manifest become live. # Assets are proven fetchable — only now may the manifest become live. First
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..." # incorporate concurrent work, then promote in a dedicated commit. Until the
git -C "$PROJECT_ROOT" push "$REMOTE" main # final push succeeds the remote still serves the previous manifest.
echo "Assets verified. Synchronizing main before manifest promotion..."
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
if [ "$PROMOTE_MANIFEST" = "1" ]; then
cp "$MANIFEST" "$LIVE_MANIFEST"
cp "$MANIFEST" "$PROJECT_ROOT/release-manifest.json"
git -C "$PROJECT_ROOT" add releases/manifest.json release-manifest.json
git -C "$PROJECT_ROOT" rm -f -- "releases/pending/v${VERSION}/manifest.json"
git -C "$PROJECT_ROOT" commit -m "chore: publish release v${VERSION}"
fi
echo "Publishing verified manifest to main (this makes v${VERSION} live)..."
# A concurrent push can race the fetch above. Merge and retry without ever
# force-pushing; the remote remains on its old, working manifest meanwhile.
for attempt in 1 2 3; do
if git -C "$PROJECT_ROOT" push "$REMOTE" HEAD:main; then
break
fi
[ "$attempt" -lt 3 ] || fail "main advanced repeatedly; assets are safe but manifest was not promoted"
echo "main advanced during publication; merging and retrying..."
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
done
echo "Release v${VERSION} published and verified on $REMOTE." echo "Release v${VERSION} published and verified on $REMOTE."
+9 -6
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-step OTA-manifest signer (counterpart to sign-catalog.sh). # One-step OTA-manifest signer (counterpart to sign-catalog.sh).
# #
# Run: bash scripts/sign-manifest.sh # Run: bash scripts/sign-manifest.sh [path/to/manifest.json]
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D. # Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
# #
# Signs releases/manifest.json in place and cryptographically verifies the # Signs the requested manifest (live by default) and cryptographically verifies the
# result against the pinned release-root anchor. The mnemonic is read from the # result against the pinned release-root anchor. The mnemonic is read from the
# terminal only (never stored, never in shell history, never passed to Claude). # terminal only (never stored, never in shell history, never passed to Claude).
# #
@@ -18,7 +18,9 @@
set -euo pipefail set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$REPO/releases/manifest.json" MANIFEST="${1:-$REPO/releases/manifest.json}"
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO/$MANIFEST"
[ -f "$MANIFEST" ] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; }
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in # Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
# the earlier catalog ceremony). Prefer the repo's release build. # the earlier catalog ceremony). Prefer the repo's release build.
@@ -41,9 +43,10 @@ echo "════════════════════════
echo echo
if "$BIN" ceremony verify "$MANIFEST"; then if "$BIN" ceremony verify "$MANIFEST"; then
echo "✅ SUCCESS — manifest signed by the pinned release root." echo "✅ SUCCESS — manifest signed by the pinned release root: $MANIFEST"
echo " Commit + push releases/manifest.json (and release-manifest.json if present)." if [ "$MANIFEST" = "$REPO/releases/manifest.json" ]; then
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true cp "$MANIFEST" "$REPO/release-manifest.json"
fi
else else
echo "❌ Signature did NOT verify against the pinned release-root anchor." echo "❌ Signature did NOT verify against the pinned release-root anchor."
echo " Do NOT commit. Check the mnemonic and re-run." echo " Do NOT commit. Check the mnemonic and re-run."
+106 -28
View File
@@ -11,8 +11,9 @@ a block (from the curated CHANGELOG bullets) and inserts it newest-first.
python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing
Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is
user-facing. Only CHANGELOG versions are managed; older hand-written blocks user-facing. The visible history deliberately starts at v1.8.0-alpha; older
(pre-CHANGELOG history) are never touched or removed. blocks are removed so this remains a concise product history rather than an
unbounded archive.
""" """
import re import re
import sys import sys
@@ -25,6 +26,7 @@ MODAL = REPO / "neode-ui/src/views/settings/AccountInfoSection.vue"
MONTHS = ["", "January", "February", "March", "April", "May", "June", "July", MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"] "August", "September", "October", "November", "December"]
MIN_VISIBLE_VERSION = (1, 8, 0)
HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)") HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)")
@@ -47,7 +49,10 @@ def parse_changelog():
if m: if m:
ver, y, mo, d = m.groups() ver, y, mo, d = m.groups()
cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []} cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []}
entries.append(cur) if version_key(ver) >= MIN_VISIBLE_VERSION:
entries.append(cur)
else:
cur = None
continue continue
if cur is not None and line.startswith("- "): if cur is not None and line.startswith("- "):
text = line[2:].strip() text = line[2:].strip()
@@ -67,14 +72,71 @@ def undated_versions():
return found return found
def existing_versions(): def ordered_versions():
text = MODAL.read_text() """Return modal versions in display order (top to bottom)."""
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text)) return re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", MODAL.read_text())
def version_key(version):
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", version)
return tuple(map(int, match.groups()))
def sort_modal_blocks(entries):
"""Re-render current release-note blocks newest-first and remove old history."""
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- (v\d+\.\d+\.\d+\S*) -->\s*$")
blocks = []
for start, line in enumerate(lines):
match = marker.match(line)
if not match:
continue
depth = 0
opened = False
for index in range(start + 1, len(lines)):
for tag in re.findall(r"</?div\b[^>]*>", lines[index]):
if tag.startswith("</"):
depth -= 1
else:
depth += 1
opened = True
if opened and depth == 0:
blocks.append((start, index + 1, match.group(1), lines[start:index + 1]))
break
else:
raise RuntimeError(f"unclosed What's New block for {match.group(1)}")
if not blocks:
return False
for previous, following in zip(blocks, blocks[1:]):
gap = "".join(lines[previous[1]:following[0]])
if gap.strip():
raise RuntimeError("unexpected content between What's New release blocks")
by_version = {entry["ver"]: entry for entry in entries}
retained = [b for b in blocks if version_key(b[2]) >= MIN_VISIBLE_VERSION]
sorted_segments = [
render_block(by_version[b[2]]).splitlines(keepends=True)
if b[2] in by_version else b[3]
for b in sorted(retained, key=lambda b: version_key(b[2]), reverse=True)
]
output = lines[:blocks[0][0]]
for segment in sorted_segments:
output.extend(segment)
output.extend(lines[blocks[-1][1]:])
changed = output != lines
if changed:
MODAL.write_text("".join(output))
return changed
def to_html(text): def to_html(text):
text = text.replace("`", "") # drop markdown code ticks (plain prose) text = text.replace("`", "")
return html.escape(text, quote=False) # & < > (Vue template-safe) escaped = html.escape(text, quote=False) # & < > (Vue template-safe)
escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", r"<em>\1</em>", escaped)
return escaped
def render_block(entry): def render_block(entry):
@@ -113,36 +175,52 @@ def main():
return 1 return 1
entries = parse_changelog() entries = parse_changelog()
have = existing_versions() displayed = ordered_versions()
have = set(displayed)
missing = [e for e in entries if e["ver"] not in have] missing = [e for e in entries if e["ver"] not in have]
expected_order = sorted(displayed, key=version_key, reverse=True)
out_of_order = displayed != expected_order
too_old = [v for v in displayed if version_key(v) < MIN_VISIBLE_VERSION]
if not missing: if not missing and not out_of_order and not too_old:
print("What's New modal is in sync with CHANGELOG.md " changed = False if check else sort_modal_blocks(entries)
f"({len(entries)} changelog versions, all present).") if changed:
print("Re-rendered What's New blocks from the curated changelog.")
else:
print("What's New modal is in sync with CHANGELOG.md "
f"({len(entries)} changelog versions, all present and newest-first).")
return 0 return 0
names = ", ".join(e["ver"] for e in missing) names = ", ".join(e["ver"] for e in missing)
if check: if check:
print("FAIL: these CHANGELOG versions have no block in the Settings " if missing:
f"What's New modal: {names}", file=sys.stderr) print("FAIL: these CHANGELOG versions have no block in the Settings "
f"What's New modal: {names}", file=sys.stderr)
if out_of_order:
print("FAIL: What's New entries are not newest-first; the modal currently "
f"opens at {displayed[0]} instead of {expected_order[0]}", file=sys.stderr)
if too_old:
print("FAIL: What's New contains entries older than the v1.8.0 history floor: "
+ ", ".join(too_old), file=sys.stderr)
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr) print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
return 1 return 1
# Insert missing blocks newest-first, immediately before the newest existing if missing:
# block marker (the first "<!-- v... -->" line in the file). # Insert before the first block; the full sort below makes this safe even
lines = MODAL.read_text().splitlines(keepends=True) # when a historical hand-written block was accidentally left at the top.
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$") lines = MODAL.read_text().splitlines(keepends=True)
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None) marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
if idx is None: idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
print("ERROR: could not find an existing version block marker in the modal.", if idx is None:
file=sys.stderr) print("ERROR: could not find an existing version block marker in the modal.",
return 2 file=sys.stderr)
return 2
lines.insert(idx, "".join(render_block(e) for e in missing))
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
# newest-first: sort missing by their order in `entries` (already newest-first) if sort_modal_blocks(entries):
block_text = "".join(render_block(e) for e in missing) print("Normalized What's New blocks (v1.8.0+ only, newest-first).")
lines.insert(idx, block_text)
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
return 0 return 0