diff --git a/.github/workflows/build&release.yml b/.github/workflows/build&release.yml index ab066168..ead3e013 100644 --- a/.github/workflows/build&release.yml +++ b/.github/workflows/build&release.yml @@ -122,19 +122,113 @@ jobs: cp release/supernode release/${{ steps.vars.outputs.binary_name }} - - name: Publish the Release + - name: Check if release already exists + id: rel_check + uses: actions/github-script@v7 + env: + TAG_NAME: ${{ steps.tag_info.outputs.tag_name }} + with: + script: | + const { owner, repo } = context.repo; + const tag_name = process.env.TAG_NAME; + try { + await github.request('GET /repos/{owner}/{repo}/releases/tags/{tag}', { + owner, + repo, + tag: tag_name, + }); + core.setOutput('exists', 'true'); + } catch (e) { + // 404 means not found => will be created + core.setOutput('exists', 'false'); + } + + - name: Generate auto release notes + id: auto_notes + uses: actions/github-script@v7 + if: steps.rel_check.outputs.exists != 'true' + env: + TAG_NAME: ${{ steps.tag_info.outputs.tag_name }} + with: + script: | + const { owner, repo } = context.repo; + const tag_name = process.env.TAG_NAME; + const res = await github.request('POST /repos/{owner}/{repo}/releases/generate-notes', { + owner, + repo, + tag_name + }); + core.setOutput('body', res.data.body || ''); + + - name: Prepare Release Body + id: rel_body + if: steps.rel_check.outputs.exists != 'true' + run: | + cat > /tmp/REL_BODY <<'EOT' + IMPORTANT: sn-manager setup and auto-update guide: + https://github.com/LumeraProtocol/supernode/blob/master/sn-manager/README.md + + ${{ steps.tag_info.outputs.tag_message }} + + --- + Auto-generated release notes: + ${{ steps.auto_notes.outputs.body }} + EOT + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Create release with composed body (no existing release) uses: softprops/action-gh-release@v0.1.15 - if: success() + if: success() && steps.rel_check.outputs.exists != 'true' with: tag_name: ${{ steps.tag_info.outputs.tag_name }} files: | release/${{ steps.vars.outputs.binary_name }}.tar.gz release/${{ steps.vars.outputs.binary_name }} - generate_release_notes: true - body: | - ${{ steps.tag_info.outputs.tag_message }} - - Version: ${{ steps.vars.outputs.version }} - Git Commit: ${{ steps.vars.outputs.git_commit }} - Build Time: ${{ steps.vars.outputs.build_time }} + body: ${{ steps.rel_body.outputs.body }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload assets to existing release (preserve manual notes) + uses: softprops/action-gh-release@v0.1.15 + if: success() && steps.rel_check.outputs.exists == 'true' + with: + tag_name: ${{ steps.tag_info.outputs.tag_name }} + files: | + release/${{ steps.vars.outputs.binary_name }}.tar.gz + release/${{ steps.vars.outputs.binary_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Ensure guide link present in existing release body + if: success() && steps.rel_check.outputs.exists == 'true' + uses: actions/github-script@v7 + env: + TAG_NAME: ${{ steps.tag_info.outputs.tag_name }} + GUIDE_URL: https://github.com/LumeraProtocol/supernode/blob/master/sn-manager/README.md + with: + script: | + const { owner, repo } = context.repo; + const tag_name = process.env.TAG_NAME; + const guide = `IMPORTANT: sn-manager setup and auto-update guide:\n${process.env.GUIDE_URL}`; + // Fetch release + const rel = await github.request('GET /repos/{owner}/{repo}/releases/tags/{tag}', { + owner, + repo, + tag: tag_name, + }); + const release = rel.data; + const body = release.body || ''; + if (body.includes(process.env.GUIDE_URL)) { + core.info('Guide link already present; not modifying release body.'); + } else { + const newBody = `${guide}\n\n${body}`; + await github.request('PATCH /repos/{owner}/{repo}/releases/{release_id}', { + owner, + repo, + release_id: release.id, + body: newBody, + }); + core.info('Prepended guide link to existing release body.'); + } diff --git a/sn-manager/README.md b/sn-manager/README.md index d569b18f..a6723a53 100644 --- a/sn-manager/README.md +++ b/sn-manager/README.md @@ -2,6 +2,21 @@ SuperNode Process Manager with Automatic Updates +## Table of Contents + +- [Installation](#installation) +- [Systemd Service Setup](#systemd-service-setup) +- [Ensure PATH points to user install](#ensure-path-points-to-user-install-required-for-self-update) +- [Initialization](#initialization) +- [Commands](#commands) +- [Version Update Scenarios](#version-update-scenarios) +- [Start/Stop Behavior](#startstop-behavior) +- [Migration for Existing sn-manager Users](#migration-for-existing-sn-manager-users) +- [Troubleshooting](#troubleshooting) + - [Fix non-writable install](#fix-non-writable-install) +- [Configuration](#configuration) +- [Notes](#notes) + ## Installation Download and install sn-manager: @@ -86,6 +101,14 @@ readlink -f "$(command -v sn-manager)" The systemd unit uses an absolute `ExecStart` pointing to your home directory, so the service will always run the intended binary regardless of PATH. +Note: Auto-upgrade requires the sn-manager binary directory to be writable by the service user. If you encounter an error like: + +``` +auto-upgrade is enabled but sn-manager binary directory is not writable (...) +``` + +follow the steps in [Fix non-writable install](#fix-non-writable-install). + ## Initialization ### Interactive Mode @@ -270,6 +293,49 @@ sudo systemctl restart sn-manager - Check updates: `sn-manager check` +## Troubleshooting + +### Fix non-writable install + +Symptom: + +``` +auto-upgrade is enabled but sn-manager binary directory is not writable (...) +``` + +Cause: `sn-manager` is installed in a root-owned directory such as `/usr/local/bin`, so the auto-updater cannot write `sn-manager.new` during self-update. + +Fix: + +1. Reinstall `sn-manager` to a user-writable path: + ```bash + curl -L https://github.com/LumeraProtocol/supernode/releases/latest/download/supernode-linux-amd64.tar.gz | tar -xz + install -D -m 0755 sn-manager "$HOME/.sn-manager/bin/sn-manager" + echo 'export PATH="$HOME/.sn-manager/bin:$PATH"' >> ~/.bashrc + source ~/.bashrc && hash -r + sn-manager version + ``` +2. Update the systemd unit to point to the user install and set HOME/workdir: + ```ini + [Service] + User= + ExecStart=/home//.sn-manager/bin/sn-manager start + Environment="HOME=/home/" + WorkingDirectory=/home/ + Restart=on-failure + RestartSec=10 + ``` +3. Remove the global copy so PATH doesn’t pick it: + ```bash + sudo rm -f /usr/local/bin/sn-manager && hash -r + ``` +4. Restart the service: + ```bash + sudo systemctl daemon-reload + sudo systemctl restart sn-manager + ``` + + ## Configuration ### SN-Manager (`~/.sn-manager/config.yml`) diff --git a/sn-manager/cmd/start.go b/sn-manager/cmd/start.go index bae8e657..0166bc10 100644 --- a/sn-manager/cmd/start.go +++ b/sn-manager/cmd/start.go @@ -102,6 +102,25 @@ func runStart(cmd *cobra.Command, args []string) error { } } + // Sanity check: if auto-upgrade is enabled and sn-manager binary dir is not writable, error and exit with guidance + if exePath, err := os.Executable(); err == nil { + if exeReal, err := filepath.EvalSymlinks(exePath); err == nil { + exeDir := filepath.Dir(exeReal) + if ok, _ := utils.IsDirWritable(exeDir); !ok { + if cfg.Updates.AutoUpgrade { + return fmt.Errorf( + "auto-upgrade is enabled but sn-manager binary directory is not writable (%s).\nInstall sn-manager to a user-writable path and update your systemd unit as per the README: %s\nRecommended path: %s", + exeDir, + "https://github.com/LumeraProtocol/supernode/blob/master/sn-manager/README.md#fix-non-writable-install", + filepath.Join(home, "bin", "sn-manager"), + ) + } + // If auto-upgrade is disabled, warn but continue + log.Printf("Warning: sn-manager binary directory is not writable (%s). Self-update is disabled.", exeDir) + } + } + } + // Start auto-updater if enabled var autoUpdater *updater.AutoUpdater if cfg.Updates.AutoUpgrade { diff --git a/sn-manager/internal/utils/fs.go b/sn-manager/internal/utils/fs.go new file mode 100644 index 00000000..b0ec2126 --- /dev/null +++ b/sn-manager/internal/utils/fs.go @@ -0,0 +1,34 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" +) + +// IsDirWritable returns true if the directory is writable by the current process. +// It attempts to create and remove a temporary file inside the directory. +func IsDirWritable(dir string) (bool, error) { + if dir == "" { + return false, fmt.Errorf("empty directory path") + } + // Ensure directory exists + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + if err != nil { + return false, err + } + return false, fmt.Errorf("not a directory: %s", dir) + } + // Try to create a temp file + tmpPath := filepath.Join(dir, ".wtest.tmp") + f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return false, nil + } + _ = f.Close() + if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) { + // Consider directory writable even if cleanup failed, but report error + return true, err + } + return true, nil +}