mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
feat(geode): add a release & distribution pipeline
geode was runnable only via ./gradlew :geode:run and was absent from CI. Give it the same release process as the amy CLI (it's the same kind of application-plugin JVM module), plus the pieces a long-running server daemon needs that a one-shot CLI does not. - Main.kt: add terminal --version/-V and --help/-h flags so a packaged binary has a fast, exit-0 command (Homebrew test block, package smoke checks, Docker healthcheck). - build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image with a bundled JRE, plus config.example.toml + geode.service under share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to exclude — geode depends only on :quartz. - Dockerfile + .dockerignore: multi-stage image (gradle installDist -> temurin JRE), the primary channel for relay operators. - packaging/: systemd unit, macOS hardened-runtime entitlements, and a reference Homebrew formula. - scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the canonical geode-<version>-<family>-<arch>.<ext> scheme. - create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm bundle, with a serve+NIP-11 smoke test of the jlink image) and a docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest). - bump-homebrew-geode-formula.yml: auto-sync the reference formula on stable releases. - build.yml: run :geode:test in CI (it ran in no workflow before). - README.md + plans/2026-07-24-geode-release.md: operator docs + design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Keep the Docker build context small + reproducible: exclude VCS metadata,
|
||||
# Gradle/build outputs, IDE files, and local caches. The build stage runs a
|
||||
# fresh `./gradlew :geode:installDist` inside the image, so nothing under any
|
||||
# build/ directory needs to travel with the context.
|
||||
.git
|
||||
.github
|
||||
**/build/
|
||||
.gradle/
|
||||
**/.gradle/
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
**/.kotlin/
|
||||
local.properties
|
||||
# Docs + non-geode app modules aren't needed to build the relay, but the Gradle
|
||||
# settings reference every module, so we can't drop the module source trees;
|
||||
# only their build artifacts (covered above) are excluded.
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Test + Build Desktop (gradle)
|
||||
run: |
|
||||
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
|
||||
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :geode:test :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
|
||||
if [ "${{ runner.os }}" = "Linux" ]; then
|
||||
xvfb-run --auto-servernum $CMD
|
||||
else
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
name: Bump Homebrew Formula (geode relay)
|
||||
|
||||
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
|
||||
# artifact:
|
||||
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
|
||||
# - this workflow -> Formula `geode` (the standalone relay)
|
||||
#
|
||||
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
|
||||
# bundle, compute its sha256, and open a PR that syncs
|
||||
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
|
||||
# in-repo reference formula accurate makes the eventual homebrew-core submission a
|
||||
# copy-paste.
|
||||
#
|
||||
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
|
||||
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
|
||||
# and `geode` has never been submitted there — that first submission is a manual,
|
||||
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
|
||||
# auto-bump here — see the "TODO(bootstrap)" note at the bottom of this file.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to sync (for manual recovery)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
# The "Report failure" step opens a [release-ops] issue via
|
||||
# github.rest.issues.create, which needs issues:write.
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# Serialize per tag; do not cancel in-progress runs.
|
||||
group: bump-homebrew-geode-formula-${{ github.event.release.tag_name || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-formula:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Re-assert stable release
|
||||
uses: ./.github/actions/assert-stable-release
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
|
||||
is_draft: ${{ github.event.release.draft || 'false' }}
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.event.release.tag_name || inputs.tag }}"
|
||||
VER="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download jvm bundle and compute sha256
|
||||
id: asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.ver.outputs.tag }}"
|
||||
VER="${{ steps.ver.outputs.ver }}"
|
||||
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/geode-${VER}-jvm.tar.gz"
|
||||
echo "Fetching $URL"
|
||||
# The `released` event can fire a hair before every matrix leg finishes
|
||||
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
|
||||
ok=0
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -fsSL -o geode-jvm.tar.gz "$URL"; then ok=1; break; fi
|
||||
wait=$(( 2 ** i ))
|
||||
echo "attempt $i failed; retrying in ${wait}s"
|
||||
sleep "$wait"
|
||||
done
|
||||
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
|
||||
test -s geode-jvm.tar.gz
|
||||
SHA=$(shasum -a 256 geode-jvm.tar.gz | awk '{print $1}')
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "geode-${VER}-jvm.tar.gz -> $SHA"
|
||||
|
||||
- name: Update reference formula
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FORMULA=geode/packaging/homebrew/geode.rb
|
||||
URL="${{ steps.asset.outputs.url }}"
|
||||
SHA="${{ steps.asset.outputs.sha256 }}"
|
||||
# Rewrite the two indented lines in the formula block. Anchoring on the
|
||||
# 2-space indent avoids touching the header comment's example curl url.
|
||||
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
|
||||
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
|
||||
echo "----- $FORMULA -----"
|
||||
grep -E "^ (url|sha256) " "$FORMULA"
|
||||
|
||||
- name: Open or update the formula-sync PR
|
||||
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
|
||||
# linked into any shipped artifact). It no-ops when there is no diff.
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: main
|
||||
branch: chore/bump-geode-formula-${{ steps.ver.outputs.tag }}
|
||||
add-paths: geode/packaging/homebrew/geode.rb
|
||||
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}'
|
||||
title: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}'
|
||||
body: |
|
||||
Auto-synced `geode/packaging/homebrew/geode.rb` to the
|
||||
`${{ steps.ver.outputs.tag }}` release:
|
||||
|
||||
- `url` -> `${{ steps.asset.outputs.url }}`
|
||||
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
|
||||
|
||||
Opened by `.github/workflows/bump-homebrew-geode-formula.yml`. Merge to
|
||||
keep the reference formula ready for the homebrew-core submission/bump.
|
||||
|
||||
# TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core, add
|
||||
# a step here that opens the homebrew-core bump PR automatically (a pinned
|
||||
# macauley/action-homebrew-bump-formula, or `brew bump-formula-pr geode
|
||||
# --url=<url> --sha256=<sha>` with a HOMEBREW_TOKEN). It is intentionally
|
||||
# omitted until then because bump-formula-pr errors on a formula that is not
|
||||
# yet in the tap.
|
||||
|
||||
- name: Report failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `[release-ops] bump-homebrew-geode-formula failed for ${tag}`,
|
||||
body: [
|
||||
`geode Homebrew formula sync failed for release \`${tag}\`.`,
|
||||
``,
|
||||
`- Run: ${runUrl}`,
|
||||
`- Channel: Homebrew Formula (\`geode\` relay)`,
|
||||
``,
|
||||
`Recovery options:`,
|
||||
`1. Re-run the workflow once the underlying issue is fixed`,
|
||||
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
|
||||
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
|
||||
].join('\n'),
|
||||
labels: ['release-ops', 'bug']
|
||||
});
|
||||
@@ -518,6 +518,305 @@ jobs:
|
||||
ls -la dist >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# geode relay build matrix. Same shape as build-cli — geode is the same kind
|
||||
# of `application`-plugin JVM module — producing a self-contained bundle with a
|
||||
# minimal jlink'd JRE (no system Java required) plus native Linux packages.
|
||||
#
|
||||
# geodeImage task (all legs): geode/build/geode-image/geode/ → geode-*.tar.gz
|
||||
# jpackageDeb / jpackageRpm: geode/build/jpackage/geode_*.deb + geode-*.rpm
|
||||
# no-JRE jvm bundle (linux): geode-<ver>-jvm.tar.gz for the Homebrew formula
|
||||
#
|
||||
# Unlike build-cli there is no "no Compose UI" assertion — geode depends only on
|
||||
# :quartz and never pulls the Compose render stack. The GHCR Docker image is a
|
||||
# separate job (docker-geode) below.
|
||||
#
|
||||
# Asset naming: geode-<version>-<family>-<arch>.<ext>. See scripts/asset-name.sh.
|
||||
# ---------------------------------------------------------------------------
|
||||
build-geode:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: Resolve tag + version
|
||||
id: ver
|
||||
env:
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
TEST_TAG: ${{ github.event.inputs.test_tag || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
TAG="${TEST_TAG:-v0.0.0-dryrun}"
|
||||
else
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
fi
|
||||
VER="${TAG#v}"
|
||||
TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
|
||||
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
|
||||
if [[ "$TOML_VER" != "$VER" ]]; then
|
||||
echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install RPM tooling (linux only)
|
||||
if: matrix.family == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
|
||||
|
||||
- name: Build geode artifacts
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
with:
|
||||
max_attempts: 2
|
||||
timeout_minutes: 15
|
||||
command: ./gradlew --no-daemon :geode:${{ matrix.tasks }}
|
||||
|
||||
# Boot the bundled jlink image before shipping it. `--version` proves the
|
||||
# JVM starts and the main class loads; the serve leg proves the jlink
|
||||
# module list is complete for the real relay path (Ktor CIO + SQLite +
|
||||
# NIP-11 serialization) — a too-tight module list links fine but fails
|
||||
# here with NoClassDefFound instead of on an operator's machine.
|
||||
- name: Smoke-test the geode image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
"$IMG/bin/geode" --version
|
||||
"$IMG/bin/geode" --port 17447 &
|
||||
PID=$!
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
if curl -fsS -H 'Accept: application/nostr+json' http://127.0.0.1:17447/ -o /tmp/nip11.json; then ok=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
kill "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
[[ "$ok" == 1 ]] || { echo "::error::geode image did not serve NIP-11 within 20s"; exit 1; }
|
||||
echo "NIP-11 doc:"; head -c 400 /tmp/nip11.json; echo
|
||||
grep -q '"supported_nips"' /tmp/nip11.json || { echo "::error::NIP-11 doc missing supported_nips"; exit 1; }
|
||||
|
||||
# macOS only: import the Developer ID cert (no-op without the secret) so
|
||||
# the next step can codesign the jlink image. The jvm bundle for
|
||||
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
|
||||
- name: Import Apple Developer ID certificate (macOS leg, if configured)
|
||||
if: matrix.family == 'macos'
|
||||
id: mac_keychain
|
||||
uses: ./.github/actions/import-macos-cert
|
||||
with:
|
||||
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
# Codesign + notarize the macOS jlink image (geode-<ver>-macos-arm64.tar.gz)
|
||||
# for operators who download it directly. A loose tarball can't be stapled
|
||||
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
|
||||
# online on first run. Runs before "Collect" so the tarred image is signed.
|
||||
- name: Sign + notarize geode image (macOS leg, if configured)
|
||||
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
|
||||
env:
|
||||
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
|
||||
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
|
||||
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
|
||||
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
ENTITLEMENTS="geode/packaging/macos/geode.entitlements"
|
||||
# Sign the macOS Mach-O natives buried INSIDE the bundled jars
|
||||
# (secp256k1/sqlite) first — Apple's notary recurses into jars and
|
||||
# rejects any unsigned Mach-O, so this must run before notarize.
|
||||
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
|
||||
# Sign every loose Mach-O binary in the bundled JRE. Executables get
|
||||
# the hardened-runtime entitlements; dylibs don't.
|
||||
while IFS= read -r f; do
|
||||
case "$(file -b "$f")" in
|
||||
*Mach-O*executable*)
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
|
||||
*Mach-O*)
|
||||
codesign --force --options runtime --timestamp \
|
||||
--sign "$SIGN_IDENTITY" "$f" ;;
|
||||
esac
|
||||
done < <(find "$IMG" -type f)
|
||||
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
|
||||
# Notarize: zip the signed image, submit, wait for Apple's verdict.
|
||||
ZIP="$RUNNER_TEMP/geode-notarize.zip"
|
||||
OUT="$RUNNER_TEMP/notary-submit.json"
|
||||
ditto -c -k --keepParent "$IMG" "$ZIP"
|
||||
if ! xcrun notarytool submit "$ZIP" \
|
||||
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
|
||||
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
|
||||
echo "::warning::notarytool submit exited non-zero"
|
||||
fi
|
||||
cat "$OUT"
|
||||
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
|
||||
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "::error::Notarization status: $STATUS"
|
||||
if [ -n "$SUBMISSION_ID" ]; then
|
||||
echo "----- notary log -----"
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
|
||||
--team-id "$NOTARY_TEAM_ID" || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# jpackage pins libicu to the build host's version (libicu74 on
|
||||
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
|
||||
- name: Relax libicu dependency in .deb
|
||||
if: matrix.family == 'linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh geode/build/jpackage/*.deb
|
||||
|
||||
- name: Collect + rename assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/asset-name.sh
|
||||
source scripts/asset-name.sh
|
||||
collect_geode_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
|
||||
|
||||
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
|
||||
# cannot use the jlink tarball above (bundled runtime) nor build from
|
||||
# source (its sandbox blocks Gradle's Maven downloads). installDist
|
||||
# (bin/geode + lib/*.jar, no runtime/) is exactly that bundle. It is pure
|
||||
# JVM bytecode, so one platform-independent asset serves every OS; we cut
|
||||
# it on the linux leg only. geodeImage depends on installDist, so the
|
||||
# geode/build/install/geode tree already exists here.
|
||||
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
|
||||
if: matrix.family == 'linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
SRC="geode/build/install/geode"
|
||||
test -x "$SRC/bin/geode"
|
||||
( cd "$SRC" && tar czf "$OLDPWD/dist/geode-${VER}-jvm.tar.gz" bin lib )
|
||||
echo "Collected: dist/geode-${VER}-jvm.tar.gz"
|
||||
|
||||
- name: Enforce geode size budget (200 MB per asset)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
for f in dist/*; do
|
||||
if [[ -f "$f" ]]; then
|
||||
size=$(wc -c < "$f")
|
||||
mb=$(( size / 1048576 ))
|
||||
if (( size > 209715200 )); then
|
||||
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB geode budget"
|
||||
fail=1
|
||||
else
|
||||
echo "OK: $f — ${mb} MB"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
[[ "$fail" == 0 ]]
|
||||
|
||||
- name: Classify release
|
||||
id: classify
|
||||
run: |
|
||||
TAG="${{ steps.ver.outputs.tag }}"
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
prerelease: ${{ steps.classify.outputs.prerelease }}
|
||||
draft: false
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: false # Android job writes release notes (last-writer-wins race)
|
||||
|
||||
- name: Dry-run summary
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "### Dry-run: geode ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
ls -la dist >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# geode Docker image → GitHub Container Registry (GHCR). A relay is most often
|
||||
# deployed as a container, so this is geode's primary distribution channel.
|
||||
# Builds geode/Dockerfile (multi-stage: gradle installDist → temurin JRE) and
|
||||
# pushes ghcr.io/<owner>/geode:<version> (+ :latest on a stable release).
|
||||
# Tag-push only — skipped on the workflow_dispatch dry-run.
|
||||
# ---------------------------------------------------------------------------
|
||||
docker-geode:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve version + tags
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VER="${TAG#v}"
|
||||
# Lowercase owner — GHCR repository paths must be lowercase.
|
||||
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||
IMAGE="ghcr.io/${OWNER}/geode"
|
||||
TAGS="${IMAGE}:${VER}"
|
||||
# Only move :latest for a stable vX.Y.Z tag, never a prerelease.
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TAGS="${TAGS},${IMAGE}:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: geode/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Android build + sign + direct-upload. Logic preserved from previous workflow;
|
||||
# uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Multi-stage image for the geode Nostr relay.
|
||||
#
|
||||
# Build from the REPO ROOT (the build needs the whole Gradle project), pointing
|
||||
# at this file:
|
||||
#
|
||||
# docker build -f geode/Dockerfile -t geode:local .
|
||||
# docker run --rm -p 7447:7447 geode:local
|
||||
#
|
||||
# That runs the relay on 0.0.0.0:7447 with the built-in in-memory store — events
|
||||
# vanish on restart. For a persistent, configured deployment, mount a config and
|
||||
# a data volume:
|
||||
#
|
||||
# docker run -d --name geode -p 7447:7447 \
|
||||
# -v $PWD/geode.toml:/etc/geode/geode.toml:ro \
|
||||
# -v geode-data:/var/lib/geode \
|
||||
# geode:local --config /etc/geode/geode.toml
|
||||
#
|
||||
# with `file = "/var/lib/geode/events.db"` and `in_memory = false` in geode.toml.
|
||||
#
|
||||
# Published to GHCR on every release by .github/workflows/create-release.yml
|
||||
# (docker-geode job) as ghcr.io/vitorpamplona/geode:<version> and :latest.
|
||||
|
||||
# --- build stage ------------------------------------------------------------
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /src
|
||||
# Copy the whole repo (geode depends only on :quartz, but the Gradle build needs
|
||||
# settings.gradle.kts, the version catalog, and the wrapper). .dockerignore keeps
|
||||
# build outputs and VCS metadata out of the context.
|
||||
COPY . .
|
||||
# installDist emits bin/geode + lib/*.jar under geode/build/install/geode.
|
||||
RUN ./gradlew --no-daemon :geode:installDist
|
||||
|
||||
# --- runtime stage ----------------------------------------------------------
|
||||
FROM eclipse-temurin:21-jre
|
||||
LABEL org.opencontainers.image.source="https://github.com/vitorpamplona/amethyst"
|
||||
LABEL org.opencontainers.image.description="geode — a standalone Nostr relay"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Run as a non-root user that owns the data dir.
|
||||
RUN groupadd --system geode \
|
||||
&& useradd --system --gid geode --home-dir /var/lib/geode --shell /usr/sbin/nologin geode \
|
||||
&& mkdir -p /var/lib/geode \
|
||||
&& chown geode:geode /var/lib/geode
|
||||
|
||||
COPY --from=build /src/geode/build/install/geode /opt/geode
|
||||
# Ship the example config for reference inside the image.
|
||||
COPY --from=build /src/geode/config.example.toml /opt/geode/share/geode/config.example.toml
|
||||
ENV PATH="/opt/geode/bin:${PATH}"
|
||||
|
||||
VOLUME /var/lib/geode
|
||||
EXPOSE 7447
|
||||
USER geode
|
||||
|
||||
# Liveness: the default WebSocket port accepts a TCP connection. Uses bash's
|
||||
# /dev/tcp (the temurin image is Ubuntu-based) so no curl/nc is needed. Override
|
||||
# the port with --port and this HEALTHCHECK together if you remap it.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/7447' || exit 1
|
||||
|
||||
ENTRYPOINT ["/opt/geode/bin/geode"]
|
||||
CMD ["--host", "0.0.0.0", "--port", "7447"]
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# geode
|
||||
|
||||
A standalone [Nostr](https://github.com/nostr-protocol/nips) relay for the JVM,
|
||||
built on Quartz's relay-server code (Ktor CIO). It speaks the core relay
|
||||
protocol plus NIP-11 (info doc), NIP-42 (AUTH), NIP-45 (COUNT), NIP-50
|
||||
(full-text search), NIP-77 (Negentropy sync), and NIP-86 (relay management),
|
||||
stores events in SQLite (or a filesystem backend), and can mirror upstream
|
||||
relays strfry-router style.
|
||||
|
||||
geode depends only on `:quartz` — no Android, no Compose. `amy serve` (the
|
||||
Amethyst CLI) embeds the same engine in-process.
|
||||
|
||||
## Install
|
||||
|
||||
Pick the channel that matches how you deploy. For a real relay, the **Docker
|
||||
image** or the **`.deb`/`.rpm` + systemd** are the two production paths;
|
||||
Homebrew and the portable tarball are handy for local testing.
|
||||
|
||||
### Docker (recommended for servers)
|
||||
|
||||
Images are published to GHCR on every release:
|
||||
|
||||
```bash
|
||||
# Ephemeral — in-memory store, events vanish on restart:
|
||||
docker run --rm -p 7447:7447 ghcr.io/vitorpamplona/geode:latest
|
||||
|
||||
# Persistent + configured:
|
||||
docker run -d --name geode -p 7447:7447 \
|
||||
-v $PWD/geode.toml:/etc/geode/geode.toml:ro \
|
||||
-v geode-data:/var/lib/geode \
|
||||
ghcr.io/vitorpamplona/geode:latest --config /etc/geode/geode.toml
|
||||
```
|
||||
|
||||
with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your
|
||||
`geode.toml`. Pin a version (`:1.12.6`) instead of `:latest` for reproducible
|
||||
deploys. To build the image yourself, from the repo root:
|
||||
|
||||
```bash
|
||||
docker build -f geode/Dockerfile -t geode:local .
|
||||
```
|
||||
|
||||
### Debian/Ubuntu (`.deb`) and Fedora/RHEL (`.rpm`)
|
||||
|
||||
Download `geode-<version>-linux-x64.deb` (or `.rpm`) from the
|
||||
[release page](https://github.com/vitorpamplona/amethyst/releases) and install
|
||||
it — a minimal JRE is bundled, so no system Java is required. It installs to
|
||||
`/opt/geode/` with the launcher at `/opt/geode/bin/geode`.
|
||||
|
||||
```bash
|
||||
sudo dpkg -i geode-1.12.6-linux-x64.deb # or: sudo rpm -i geode-1.12.6-linux-x64.rpm
|
||||
```
|
||||
|
||||
To run it as a managed service, wire up the shipped systemd unit
|
||||
(`/opt/geode/share/geode/geode.service`) — the unit file's header comment has
|
||||
the copy-paste steps (create the `geode` user, drop your config in
|
||||
`/etc/geode/geode.toml`, `systemctl enable --now geode`).
|
||||
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew install vitorpamplona/tap/geode # from the tap, once published
|
||||
```
|
||||
|
||||
The formula depends on `openjdk` and installs the no-JRE jar bundle. Reference
|
||||
formula: [`packaging/homebrew/geode.rb`](packaging/homebrew/geode.rb).
|
||||
|
||||
### Portable tarball (any Linux/macOS, no system Java)
|
||||
|
||||
Download `geode-<version>-<os>-<arch>.tar.gz`, unpack, and run:
|
||||
|
||||
```bash
|
||||
tar xzf geode-1.12.6-linux-x64.tar.gz
|
||||
./geode/bin/geode --config geode/share/geode/config.example.toml
|
||||
```
|
||||
|
||||
The tarball bundles a jlink'd JRE plus `share/geode/config.example.toml` and
|
||||
`share/geode/geode.service`.
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
./gradlew :geode:run --args="--config /etc/geode.toml"
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
geode runs with zero config (binds `0.0.0.0:7447`, in-memory SQLite). For
|
||||
anything real, copy [`config.example.toml`](config.example.toml) and edit it —
|
||||
the sections mirror nostr-rs-relay's `config.toml` so existing configs port
|
||||
almost verbatim. Precedence is **CLI flags > TOML > built-in defaults**.
|
||||
|
||||
```bash
|
||||
geode --config /etc/geode/geode.toml
|
||||
geode --help # full flag list
|
||||
geode --version
|
||||
```
|
||||
|
||||
Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools),
|
||||
`[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search),
|
||||
`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and
|
||||
`[admin]` (NIP-86 management). See the example file for every knob.
|
||||
|
||||
## Verbs
|
||||
|
||||
```
|
||||
geode [relay] [flags] serve the relay (default)
|
||||
geode import [flags] [FILE…] bulk-load NDJSON events into the store
|
||||
geode export [flags] dump the store as NDJSON to stdout
|
||||
```
|
||||
|
||||
`import`/`export` are geode's `strfry import` / `strfry export` — one JSON event
|
||||
per line, for seeding, migrating, or backing up a relay. Both stream, so a
|
||||
multi-million-event corpus round-trips in roughly constant memory.
|
||||
|
||||
## Release process
|
||||
|
||||
geode ships on the same tag-driven pipeline as the rest of Amethyst: pushing a
|
||||
`v*` tag runs `.github/workflows/create-release.yml`, whose `build-geode` job
|
||||
produces the tarball + `.deb`/`.rpm` + Homebrew jvm bundle, `docker-geode`
|
||||
builds and pushes the GHCR image, and `bump-homebrew-geode-formula.yml` syncs
|
||||
the reference formula. Design notes:
|
||||
[`plans/2026-07-24-geode-release.md`](plans/2026-07-24-geode-release.md).
|
||||
@@ -137,3 +137,208 @@ dependencies {
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
testImplementation(libs.okhttp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native distribution (jlink + jpackage)
|
||||
//
|
||||
// geode is a long-running server daemon, so unlike a desktop app the useful
|
||||
// artifacts are a bundled-runtime tarball and native Linux packages an operator
|
||||
// can drop onto a box. This mirrors the amy CLI's pipeline (cli/build.gradle.kts)
|
||||
// — geode is the same kind of `application`-plugin JVM module — but geode drags
|
||||
// in no Compose UI (it depends only on :quartz), so there's no render-stack to
|
||||
// exclude and no "no Compose" CI assertion to guard.
|
||||
//
|
||||
// Outputs land under geode/build/:
|
||||
// - geode-image/geode/ portable, flat directory (bin/ + lib/ + runtime/ +
|
||||
// share/ with config.example.toml + geode.service).
|
||||
// tar this on every OS → geode-<ver>-<fam>-<arch>.tar.gz
|
||||
// - jpackage/geode_*.deb Debian/Ubuntu package (Linux runners only)
|
||||
// - jpackage/geode-*.rpm Fedora/RHEL package (Linux runners only)
|
||||
//
|
||||
// We build our own flat app-image instead of `jpackage --type app-image` because
|
||||
// on macOS jpackage buries the binary inside an `.app` bundle — wrong for a CLI-
|
||||
// launched daemon. The flat tree matches the Linux jpackage layout on every OS.
|
||||
//
|
||||
// Release wiring lives in .github/workflows/create-release.yml (build-geode job)
|
||||
// and geode/Dockerfile (the GHCR image). See the plan at
|
||||
// geode/plans/2026-07-24-geode-release.md.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
val appVersion: String = project.version.toString()
|
||||
|
||||
// RPM rejects dashes in version strings — replace with tilde (~), which RPM
|
||||
// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0.
|
||||
val rpmVersion: String = appVersion.replace("-", "~")
|
||||
|
||||
val mainJarName: String = "geode-$appVersion.jar"
|
||||
val mainClassName: String = "com.vitorpamplona.geode.MainKt"
|
||||
|
||||
// Minimal JDK 21 module set for geode. Keep this tight — every module adds
|
||||
// megabytes. geode needs more than the CLI: java.management (Ktor CIO / JVM
|
||||
// metrics), java.sql (the bundled SQLite driver), java.net.http +
|
||||
// java.security.jgss (okhttp mirror WebSockets over TLS), jdk.crypto.ec (EC
|
||||
// used by the TLS handshake), jdk.unsupported (sun.misc.Unsafe, touched by
|
||||
// coroutines/SQLite/CIO). If a transitive dep needs more, `jlink` succeeds at
|
||||
// link time but the server fails at runtime with NoClassDefFound — the CI
|
||||
// dry-run (workflow_dispatch) boots the image to catch that early.
|
||||
val jlinkModules: String = listOf(
|
||||
"java.base",
|
||||
"java.logging",
|
||||
"java.management",
|
||||
"java.naming",
|
||||
"java.net.http",
|
||||
"java.security.jgss",
|
||||
"java.sql",
|
||||
"java.xml",
|
||||
"jdk.crypto.ec",
|
||||
"jdk.unsupported",
|
||||
).joinToString(",")
|
||||
|
||||
fun javaToolBin(name: String): Provider<String> =
|
||||
javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}.map {
|
||||
val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name
|
||||
it.metadata.installationPath.file("bin/$exe").asFile.absolutePath
|
||||
}
|
||||
|
||||
val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime")
|
||||
val geodeImageDir = layout.buildDirectory.dir("geode-image/geode")
|
||||
val installLibDir = layout.buildDirectory.dir("install/geode/lib")
|
||||
val jpackageOutDir = layout.buildDirectory.dir("jpackage")
|
||||
|
||||
val jlinkRuntime =
|
||||
tasks.register<Exec>("jlinkRuntime") {
|
||||
group = "distribution"
|
||||
description = "Build a minimal JRE for geode via jlink."
|
||||
|
||||
outputs.dir(jlinkRuntimeDir)
|
||||
|
||||
val jlinkBin = javaToolBin("jlink")
|
||||
val outDir = jlinkRuntimeDir
|
||||
val modules = jlinkModules
|
||||
|
||||
doFirst {
|
||||
// jlink refuses to write into an existing directory.
|
||||
outDir.get().asFile.deleteRecursively()
|
||||
executable = jlinkBin.get()
|
||||
args(
|
||||
"--add-modules", modules,
|
||||
"--no-header-files",
|
||||
"--no-man-pages",
|
||||
"--strip-debug",
|
||||
// JDK 21+: --compress <int> is deprecated; use zip-<level>.
|
||||
"--compress", "zip-6",
|
||||
"--output", outDir.get().asFile.absolutePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Flat app-image: bin/geode launcher + lib/*.jar + runtime/ (the jlink'd JRE) +
|
||||
// share/geode/ (config.example.toml + the systemd unit). Cross-platform — the
|
||||
// release workflow tars this up on every OS.
|
||||
val geodeImage =
|
||||
tasks.register<Sync>("geodeImage") {
|
||||
group = "distribution"
|
||||
description = "Assemble a portable geode app-image (bin/ + lib/ + runtime/ + share/)."
|
||||
|
||||
dependsOn(tasks.named("installDist"), jlinkRuntime)
|
||||
|
||||
into(geodeImageDir)
|
||||
|
||||
// jars from installDist
|
||||
from(installLibDir) {
|
||||
into("lib")
|
||||
}
|
||||
// jlink'd JRE
|
||||
from(jlinkRuntimeDir) {
|
||||
into("runtime")
|
||||
}
|
||||
// Operator seed files: an example config and the systemd unit.
|
||||
from(layout.projectDirectory.file("config.example.toml")) {
|
||||
into("share/geode")
|
||||
}
|
||||
from(layout.projectDirectory.file("packaging/systemd/geode.service")) {
|
||||
into("share/geode")
|
||||
}
|
||||
|
||||
val mainJar = mainJarName
|
||||
val mainClass = mainClassName
|
||||
val unixLauncher =
|
||||
"""
|
||||
#!/bin/sh
|
||||
# geode launcher — uses the bundled jlink'd JRE so no system Java is required.
|
||||
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
|
||||
exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
|
||||
""".trimIndent() + "\n"
|
||||
|
||||
doLast {
|
||||
val binDir = geodeImageDir.get().asFile.resolve("bin")
|
||||
binDir.mkdirs()
|
||||
val launcher = binDir.resolve("geode")
|
||||
launcher.writeText(unixLauncher)
|
||||
launcher.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun registerJpackage(
|
||||
taskName: String,
|
||||
type: String,
|
||||
extraArgs: List<String> = emptyList(),
|
||||
) = tasks.register<Exec>(taskName) {
|
||||
group = "distribution"
|
||||
description = "Run jpackage --type $type for geode."
|
||||
|
||||
dependsOn(tasks.named("installDist"), jlinkRuntime)
|
||||
|
||||
inputs.dir(installLibDir)
|
||||
inputs.dir(jlinkRuntimeDir)
|
||||
outputs.dir(jpackageOutDir)
|
||||
|
||||
val jpackageBin = javaToolBin("jpackage")
|
||||
val inDir = installLibDir
|
||||
val runtimeDir = jlinkRuntimeDir
|
||||
val outDir = jpackageOutDir
|
||||
val versionArg = if (type == "rpm") rpmVersion else appVersion
|
||||
val extra = extraArgs
|
||||
|
||||
doFirst {
|
||||
outDir.get().asFile.mkdirs()
|
||||
executable = jpackageBin.get()
|
||||
args(
|
||||
"--type", type,
|
||||
"--name", "geode",
|
||||
"--app-version", versionArg,
|
||||
"--vendor", "Amethyst Contributors",
|
||||
"--description", "geode — a standalone Nostr relay.",
|
||||
"--input", inDir.get().asFile.absolutePath,
|
||||
"--runtime-image", runtimeDir.get().asFile.absolutePath,
|
||||
"--main-jar", mainJarName,
|
||||
"--main-class", mainClassName,
|
||||
"--dest", outDir.get().asFile.absolutePath,
|
||||
)
|
||||
args(extra)
|
||||
}
|
||||
}
|
||||
|
||||
// .deb for Debian/Ubuntu. Installs under /opt/geode/ with /opt/geode/bin/geode
|
||||
// as the launcher. No .desktop entry — geode is a headless daemon. The systemd
|
||||
// unit + example config are shipped for the operator to wire up (see README).
|
||||
registerJpackage(
|
||||
"jpackageDeb",
|
||||
"deb",
|
||||
extraArgs = listOf(
|
||||
"--linux-package-name", "geode",
|
||||
"--linux-deb-maintainer", "vitor@vitorpamplona.com",
|
||||
),
|
||||
)
|
||||
|
||||
// .rpm for Fedora/RHEL/openSUSE.
|
||||
registerJpackage(
|
||||
"jpackageRpm",
|
||||
"rpm",
|
||||
extraArgs = listOf(
|
||||
"--linux-package-name", "geode",
|
||||
"--linux-rpm-license-type", "MIT",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Reference Homebrew formula for `geode`, the standalone Nostr relay.
|
||||
#
|
||||
# Submit this to Homebrew/homebrew-core (new-formula PR) or drop it into a
|
||||
# personal tap (`Formula/geode.rb`) for an instant `brew install <tap>/geode`.
|
||||
#
|
||||
# The url + sha256 below are kept in sync automatically on every stable release
|
||||
# by .github/workflows/bump-homebrew-geode-formula.yml (it downloads the
|
||||
# published `geode-<version>-jvm.tar.gz`, recomputes the sha256, and opens a
|
||||
# PR). To refresh by hand instead:
|
||||
# curl -fsSL -o geode-jvm.tar.gz \
|
||||
# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/geode-X.Y.Z-jvm.tar.gz
|
||||
# shasum -a 256 geode-jvm.tar.gz
|
||||
#
|
||||
# Why a pre-built jar bundle instead of building from source:
|
||||
# homebrew-core builds inside a network sandbox, so a Gradle build cannot
|
||||
# resolve its Maven dependencies there. The accepted pattern for JVM tools is
|
||||
# to download a pre-built, no-JRE jar bundle and depend on the system openjdk.
|
||||
# We publish exactly that as `geode-<version>-jvm.tar.gz` (bin/geode +
|
||||
# lib/*.jar, no bundled runtime) from .github/workflows/create-release.yml.
|
||||
#
|
||||
# Note: geode is a long-running relay daemon. Homebrew is a convenient way to
|
||||
# INSTALL it on a workstation for local testing; for a production deployment
|
||||
# prefer the Docker image or the .deb/.rpm + systemd unit (see geode/README.md).
|
||||
class Geode < Formula
|
||||
desc "Standalone Nostr relay from the Amethyst project"
|
||||
homepage "https://github.com/vitorpamplona/amethyst"
|
||||
url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/geode-1.12.6-jvm.tar.gz"
|
||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
license "MIT"
|
||||
|
||||
# Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new
|
||||
# stable GitHub release appears.
|
||||
livecheck do
|
||||
url :stable
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
depends_on "openjdk"
|
||||
|
||||
def install
|
||||
# Tarball top level is bin/ and lib/ (the Gradle installDist layout).
|
||||
libexec.install Dir["*"]
|
||||
# Wrapper on PATH that pins JAVA_HOME to Homebrew's openjdk so geode runs
|
||||
# regardless of the user's own Java setup.
|
||||
(bin/"geode").write_env_script libexec/"bin/geode", JAVA_HOME: Formula["openjdk"].opt_prefix
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "geode", shell_output("#{bin}/geode --version 2>&1")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
Hardened-runtime entitlements for geode's bundled JVM (runtime/bin/java).
|
||||
Required when codesigning + notarizing the macOS jlink image:
|
||||
- allow-jit / allow-unsigned-executable-memory: the JIT compiler writes
|
||||
and executes generated machine code.
|
||||
- disable-library-validation: geode loads the secp256k1 native .dylib that
|
||||
secp256k1-kmp-jni-jvm extracts from a jar at runtime (Schnorr signature
|
||||
verification), plus the bundled SQLite native; those dylibs are not
|
||||
signed by our Team ID, so library validation would otherwise block them.
|
||||
- allow-dyld-environment-variables: the launcher sets JVM env.
|
||||
Applied only to Mach-O *executables* in the image; plain dylibs are signed
|
||||
without entitlements. See .github/workflows/create-release.yml (build-geode).
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,50 @@
|
||||
# systemd unit for the geode Nostr relay.
|
||||
#
|
||||
# Install (paths assume the .deb/.rpm or the portable tarball unpacked to
|
||||
# /opt/geode — adjust ExecStart if you put it elsewhere):
|
||||
#
|
||||
# sudo useradd --system --home /var/lib/geode --shell /usr/sbin/nologin geode
|
||||
# sudo mkdir -p /var/lib/geode /etc/geode
|
||||
# sudo cp /opt/geode/share/geode/config.example.toml /etc/geode/geode.toml # then edit
|
||||
# sudo cp /opt/geode/share/geode/geode.service /etc/systemd/system/geode.service
|
||||
# sudo chown -R geode:geode /var/lib/geode
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now geode
|
||||
#
|
||||
# Logs: journalctl -u geode -f
|
||||
|
||||
[Unit]
|
||||
Description=geode Nostr relay
|
||||
Documentation=https://github.com/vitorpamplona/amethyst/blob/main/geode/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=geode
|
||||
Group=geode
|
||||
# Point --config at your edited copy. Drop the flag to run on built-in
|
||||
# defaults (0.0.0.0:7447, in-memory store — events vanish on restart).
|
||||
ExecStart=/opt/geode/bin/geode --config /etc/geode/geode.toml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# A relay holds one file descriptor per WebSocket; the distro default of 1024
|
||||
# caps it well below 1k connections. Raise to match [network] tuning.
|
||||
LimitNOFILE=65536
|
||||
|
||||
# systemd creates + owns /var/lib/geode (0750, User:Group above). Point
|
||||
# [database].file there, e.g. file = "/var/lib/geode/events.db".
|
||||
StateDirectory=geode
|
||||
WorkingDirectory=/opt/geode
|
||||
|
||||
# Hardening — geode needs no elevated access. Loosen only if a custom
|
||||
# IEventStore writes outside its StateDirectory.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/geode
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,76 @@
|
||||
# geode release & distribution
|
||||
|
||||
**Status:** shipped (2026-07-24)
|
||||
|
||||
## Goal
|
||||
|
||||
Give geode a real release process so people can run it without cloning the repo
|
||||
and invoking Gradle. Reuse the amy CLI's proven pipeline wherever possible —
|
||||
geode is the same kind of module (a Gradle `application`-plugin JVM app) — and
|
||||
add the pieces a long-running **server daemon** needs that a one-shot CLI does
|
||||
not (a container image, a systemd unit).
|
||||
|
||||
## Why the CLI pipeline maps onto geode
|
||||
|
||||
`cli/build.gradle.kts` already models everything geode needs for the
|
||||
tarball/deb/rpm channels: `jlinkRuntime` (minimal JRE) → an image task
|
||||
(portable flat `bin/`+`lib/`+`runtime/`) → `jpackageDeb`/`jpackageRpm`, driven
|
||||
by the `build-cli` matrix in `create-release.yml` and named through
|
||||
`scripts/asset-name.sh`. geode is `application`-plugin + JVM 21 too, so the same
|
||||
task shapes drop in.
|
||||
|
||||
Two simplifications vs. the CLI:
|
||||
|
||||
- **No Compose to exclude.** amy drags the Compose UI render stack in
|
||||
transitively via `:commons` and has to exclude skiko/compose.ui from the
|
||||
runtime classpath (plus a CI assertion). geode depends only on `:quartz`, so
|
||||
there's nothing to strip and no assertion to add.
|
||||
- **A wider jlink module set.** amy is compute-only; geode is a network server
|
||||
with a SQLite store, so its module list adds `java.management`, `java.sql`,
|
||||
`java.net.http`, and `java.security.jgss`. A too-tight list links fine but
|
||||
fails at runtime, so the `build-geode` job **boots the image** (`--version`
|
||||
plus a serve + NIP-11 curl) as a smoke test before shipping.
|
||||
|
||||
## What shipped
|
||||
|
||||
| Piece | File |
|
||||
| --- | --- |
|
||||
| `--version` / `--help` (testable exit-0 command) | `geode/src/main/kotlin/.../Main.kt` |
|
||||
| jlink + jpackage + `geodeImage` tasks | `geode/build.gradle.kts` |
|
||||
| Portable app-image seeds (config + unit under `share/geode/`) | `geode/build.gradle.kts` (geodeImage) |
|
||||
| Multi-stage Docker image | `geode/Dockerfile`, `.dockerignore` |
|
||||
| systemd unit | `geode/packaging/systemd/geode.service` |
|
||||
| macOS hardened-runtime entitlements | `geode/packaging/macos/geode.entitlements` |
|
||||
| Reference Homebrew formula | `geode/packaging/homebrew/geode.rb` |
|
||||
| Asset naming (`geode-<ver>-<fam>-<arch>`) | `scripts/asset-name.sh` |
|
||||
| Release matrix + GHCR push | `.github/workflows/create-release.yml` (`build-geode`, `docker-geode`) |
|
||||
| Homebrew formula auto-sync | `.github/workflows/bump-homebrew-geode-formula.yml` |
|
||||
| geode tests in CI | `.github/workflows/build.yml` (`:geode:test`) |
|
||||
| Operator docs | `geode/README.md` |
|
||||
|
||||
## Distribution channels
|
||||
|
||||
1. **Docker → GHCR** (`ghcr.io/vitorpamplona/geode:<version>` + `:latest`) —
|
||||
the primary channel for relay operators. Built from `geode/Dockerfile`.
|
||||
2. **`.deb` / `.rpm`** with a bundled JRE, install to `/opt/geode`, plus the
|
||||
shipped systemd unit for `systemctl enable --now geode`.
|
||||
3. **Portable tarball** with a jlink'd JRE — no system Java needed.
|
||||
4. **Homebrew** (no-JRE jar bundle + `depends_on openjdk`) for local testing.
|
||||
|
||||
All four (except the container) are named by `scripts/asset-name.sh` and
|
||||
uploaded to the GitHub Release on `v*` tags.
|
||||
|
||||
## Follow-ups / not done
|
||||
|
||||
- **Multi-arch Docker.** `docker-geode` builds `linux/amd64` only. arm64 under
|
||||
QEMU emulation would run a full Gradle build per arch (slow / timeout-prone);
|
||||
add a native arm64 runner leg when one is available.
|
||||
- **jpackage doesn't auto-install the systemd unit or create the `geode`
|
||||
user.** The `.deb`/`.rpm` install the binary; service wiring is a documented
|
||||
manual step (unit ships in the package under `share/geode/`). A post-install
|
||||
scriptlet via jpackage `--resource-dir` could automate it later.
|
||||
- **homebrew-core bootstrap.** The first `geode` submission to homebrew-core is
|
||||
a manual new-formula PR; the auto-bump workflow only keeps the in-repo
|
||||
reference formula synced until then (same state as `amy`).
|
||||
- **CI size budget** is set to 200 MB per asset (matches amy). geode should be
|
||||
well under that — tighten once the first release confirms the real size.
|
||||
@@ -111,6 +111,18 @@ import java.io.File
|
||||
* per-event tokenization cost on ingest.
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
// Terminal flags handled before verb dispatch so a packaged binary has a
|
||||
// fast, exit-0 command that never opens a store or binds a port — the smoke
|
||||
// test the Homebrew formula, the .deb/.rpm post-install check, and the
|
||||
// Docker image healthcheck all invoke.
|
||||
if (args.any { it == "--version" || it == "-V" }) {
|
||||
println("geode ${BuildConfig.VERSION}")
|
||||
return
|
||||
}
|
||||
if (args.any { it == "--help" || it == "-h" }) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
when (args.firstOrNull()?.takeUnless { it.startsWith("--") }) {
|
||||
"import" -> runImport(args.copyOfRange(1, args.size))
|
||||
"export" -> runExport(args.copyOfRange(1, args.size))
|
||||
@@ -121,6 +133,36 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun printHelp() {
|
||||
println(
|
||||
"""
|
||||
geode ${BuildConfig.VERSION} - a standalone Nostr relay.
|
||||
|
||||
Usage:
|
||||
geode [relay] [flags] serve the relay (default when no verb is given)
|
||||
geode import [flags] [FILE...] bulk-load NDJSON events into the store
|
||||
geode export [flags] dump the store as NDJSON to stdout
|
||||
|
||||
Common flags:
|
||||
--config <file> TOML config (see config.example.toml)
|
||||
--host <addr> bind address (default 0.0.0.0)
|
||||
--port <n> tcp port (default 7447, 0 to autobind)
|
||||
--path <p> websocket path (default /)
|
||||
--db <file> sqlite db path (persistent storage)
|
||||
--store <backend> event-store backend: sqlite (default), fs, or a
|
||||
fully-qualified IEventStore class name
|
||||
--auth require NIP-42 AUTH
|
||||
--optional-auth advertise NIP-42 AUTH but don't require it
|
||||
--no-verify do NOT verify event signatures (trusted input only)
|
||||
--no-search disable NIP-50 full-text search
|
||||
--version, -V print the version and exit
|
||||
--help, -h print this help and exit
|
||||
|
||||
Docs: https://github.com/vitorpamplona/amethyst/blob/main/geode/README.md
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the store the `import`/`export` verbs operate on, honoring the same
|
||||
* `--db`/`--config`/`--no-search` selection the relay uses so a verb targets the
|
||||
|
||||
+63
-5
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Single source of truth for Amethyst release asset naming.
|
||||
#
|
||||
# Two product lines share this contract:
|
||||
# Three product lines share this contract:
|
||||
# amethyst-desktop-<version>-<family>-<arch>.<ext> (Compose Desktop app)
|
||||
# amy-<version>-<family>-<arch>.<ext> (Amy CLI — cli/ module)
|
||||
# geode-<version>-<family>-<arch>.<ext> (geode relay — geode/ module)
|
||||
#
|
||||
# <version> = tag stripped of leading 'v' (e.g. "1.08.0")
|
||||
# <family> = macos | windows | linux
|
||||
@@ -33,13 +34,18 @@
|
||||
# amy-1.08.0-linux-x64.tar.gz
|
||||
# amy-1.08.0-linux-x64.deb
|
||||
# amy-1.08.0-linux-x64.rpm
|
||||
# geode-1.08.0-macos-arm64.tar.gz
|
||||
# geode-1.08.0-linux-x64.tar.gz
|
||||
# geode-1.08.0-linux-x64.deb
|
||||
# geode-1.08.0-linux-x64.rpm
|
||||
#
|
||||
# One CLI asset breaks the family/arch shape on purpose: the no-JRE jar bundle
|
||||
# for Homebrew-core is pure JVM bytecode (no bundled runtime), so a single
|
||||
# Two assets break the family/arch shape on purpose: the no-JRE jar bundles for
|
||||
# Homebrew-core are pure JVM bytecode (no bundled runtime), so a single
|
||||
# platform-independent artifact serves every OS:
|
||||
# amy-1.08.0-jvm.tar.gz
|
||||
# It is packaged inline in create-release.yml (linux leg), not via the helpers
|
||||
# below, since it has no <family>/<arch> dimension.
|
||||
# geode-1.08.0-jvm.tar.gz
|
||||
# They are packaged inline in create-release.yml (linux leg), not via the helpers
|
||||
# below, since they have no <family>/<arch> dimension.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -57,6 +63,13 @@ cli_asset_name() {
|
||||
printf 'amy-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext"
|
||||
}
|
||||
|
||||
# geode relay variant of asset_name — same shape, different product prefix.
|
||||
# Usage: geode_asset_name <family> <arch> <version> <ext>
|
||||
geode_asset_name() {
|
||||
local family="$1" arch="$2" version="$3" ext="$4"
|
||||
printf 'geode-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext"
|
||||
}
|
||||
|
||||
# Copy + rename build outputs into <dest_dir> using the canonical naming scheme.
|
||||
# Usage: collect_assets <family> <arch> <version> <dest_dir>
|
||||
# Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives).
|
||||
@@ -136,3 +149,48 @@ collect_cli_assets() {
|
||||
done
|
||||
shopt -u nullglob
|
||||
}
|
||||
|
||||
# Copy + rename geode relay build outputs into <dest_dir> using the canonical
|
||||
# geode-<version>-<family>-<arch>.<ext> scheme.
|
||||
#
|
||||
# Expected inputs:
|
||||
# geode/build/geode-image/geode/ flat app-image built by :geode:geodeImage
|
||||
# (bin/geode + lib/*.jar + runtime/ + share/)
|
||||
# geode/build/jpackage/*.deb from :geode:jpackageDeb (Linux only)
|
||||
# geode/build/jpackage/*.rpm from :geode:jpackageRpm (Linux only)
|
||||
#
|
||||
# Usage: collect_geode_assets <family> <arch> <version> <dest_dir>
|
||||
collect_geode_assets() {
|
||||
local family="$1" arch="$2" version="$3" dest="$4"
|
||||
mkdir -p "$dest"
|
||||
local abs_dest
|
||||
abs_dest="$(cd "$dest" && pwd)"
|
||||
shopt -s nullglob
|
||||
|
||||
# 1. Tar the flat app-image into geode-<version>-<family>-<arch>.tar.gz.
|
||||
# This is the portable-across-OS asset — macOS runners produce only
|
||||
# this one.
|
||||
local app_image="geode/build/geode-image/geode"
|
||||
if [ -d "$app_image" ]; then
|
||||
local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)"
|
||||
( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" )
|
||||
echo "Collected: $tarball"
|
||||
fi
|
||||
|
||||
# 2. Linux native installers (.deb, .rpm). jpackage writes them directly
|
||||
# to geode/build/jpackage/ with its own naming, so we re-copy under the
|
||||
# canonical scheme.
|
||||
local src base ext dst
|
||||
for src in \
|
||||
geode/build/jpackage/*.deb \
|
||||
geode/build/jpackage/*.rpm \
|
||||
; do
|
||||
[ -f "$src" ] || continue
|
||||
base="$(basename "$src")"
|
||||
ext="${base##*.}"
|
||||
dst="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" "$ext")"
|
||||
cp "$src" "$dst"
|
||||
echo "Collected: $dst"
|
||||
done
|
||||
shopt -u nullglob
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user