From a7a8ab6f4e41f72b80692e4095e655ded10247f2 Mon Sep 17 00:00:00 2001 From: Franzz Date: Mon, 14 Sep 2026 23:48:51 +0200 Subject: [PATCH] Simplify deployment --- .gitea/workflows/deploy.yml | 98 +++++++++++++++++-------------------- README.md | 58 ++++++++++++++-------- config.py | 2 +- 3 files changed, 83 insertions(+), 75 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 83742dc..a9a07f5 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -3,17 +3,17 @@ # nobody offers sits in the queue rather than failing. # # The runner has to be in host mode on the machine that serves the panel: -# it writes into DEPLOY_PATH and restarts the service. See -# deploy/heos-panel.service for the unit and the one sudoers line the -# restart needs. +# it updates DEPLOY_PATH and restarts the service, and it must run as the +# user that owns that directory. # -# Every step here is plain shell, on purpose. actions/checkout is a -# JavaScript action, and a host-mode runner can only run those if node is -# on its PATH -- swapping the clone below back to `uses: actions/checkout` -# brings back "Cannot find: node in PATH" on a runner without it. +# DEPLOY_PATH is a git checkout, cloned there ONCE by hand -- see +# "Deploying from Gitea" in the README. This workflow only fast-forwards +# it, so the remote and whatever credentials reach it are set up a single +# time and stay put. # -# DEPLOY_PATH is also where you edit: an rsync --delete lands on top of -# whatever is sitting there uncommitted, so commit before you push. +# Every step is plain shell on purpose: actions/checkout is a JavaScript +# action, and a host-mode runner can only run those with node on its PATH, +# failing with "Cannot find: node in PATH" without one. name: Deploy HEOS panel @@ -31,72 +31,62 @@ jobs: DEPLOY_PATH: /var/www/html/heos SERVICE: heos-panel PANEL_URL: http://127.0.0.1:5005/ # WEB_PORT in config.py - SRC: src # the clone, inside the workspace steps: - name: Check runner tools run: | command -v git command -v python3 - command -v rsync command -v curl - - name: Check deploy path + - name: Check the deploy path is ready run: | - test -d "$DEPLOY_PATH" - test -w "$DEPLOY_PATH" + if [ ! -d "$DEPLOY_PATH" ] || [ ! -w "$DEPLOY_PATH" ]; then + echo "$DEPLOY_PATH is missing, or not writable by $(id -un)." + exit 1 + fi + if [ ! -d "$DEPLOY_PATH/.git" ]; then + echo "$DEPLOY_PATH is not a checkout yet. Once, on this machine," + echo "as $(id -un):" + echo + echo " git clone $DEPLOY_PATH" + echo " cd $DEPLOY_PATH" + echo " python3 -m venv .venv" + echo " .venv/bin/pip install -r requirements.txt" + echo " sudo cp deploy/heos-panel.service /etc/systemd/system/" + echo " sudo systemctl enable --now $SERVICE" + echo + echo "Then edit config.py for this house and push again." + exit 1 + fi - name: Check the restart is allowed without a password run: sudo -n systemctl is-active "$SERVICE" || true - # A shallow clone of the pushed branch, taking the remote from the - # live checkout so there is no URL or token written down here. It - # works because the runner runs as the user that owns that checkout, - # which is the same reason it can write to DEPLOY_PATH at all. - - name: Checkout + - name: Fast-forward the checkout run: | - rm -rf "$SRC" - git init --quiet "$SRC" - git -C "$SRC" remote add origin "$(git -C "$DEPLOY_PATH" remote get-url origin)" - git -C "$SRC" fetch --quiet --depth 1 origin "${GITHUB_REF_NAME:-main}" - git -C "$SRC" checkout --quiet FETCH_HEAD - git -C "$SRC" --no-pager log -1 --oneline + cd "$DEPLOY_PATH" + git fetch --prune origin + # --ff-only on purpose: if someone has edited a tracked file on the + # box without committing it, this stops rather than throwing their + # change away. + git merge --ff-only "origin/${GITHUB_REF_NAME:-main}" + git --no-pager log -1 --oneline - # A throwaway virtualenv in the workspace: the one under - # $DEPLOY_PATH/.venv is what the running panel imports from, and a - # test run has no business touching it. - name: Install dependencies - working-directory: src run: | - python3 -m venv .venv-ci - .venv-ci/bin/pip install --quiet --upgrade pip - .venv-ci/bin/pip install --quiet -r requirements.txt + cd "$DEPLOY_PATH" + test -d .venv || python3 -m venv .venv + .venv/bin/pip install --quiet -r requirements.txt # Runs against the fake HEOS and AVR servers in tests/fakes.py, so it - # needs no speakers and touches nothing on the network. + # needs no speakers and touches nothing on the network. The new code + # is on disk by this point, but the running process is still the old + # one: a failure here stops the job before the restart below. - name: Run tests - working-directory: src - run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose - - - name: Deploy to production - working-directory: src run: | - rsync -azc --no-times --delete \ - --exclude "/.git/" \ - --exclude "/.gitea/" \ - --exclude "/.venv/" \ - --exclude "/.venv-ci/" \ - --exclude "/members.json" \ - --exclude "__pycache__/" \ - ./ "$DEPLOY_PATH/" - - # members.json is the stereo pair's learned membership and .venv is - # the runtime -- both are excluded above, so --delete leaves them be. - - - name: Install runtime dependencies - run: | - test -d "$DEPLOY_PATH/.venv" || python3 -m venv "$DEPLOY_PATH/.venv" - "$DEPLOY_PATH/.venv/bin/pip" install --quiet -r "$DEPLOY_PATH/requirements.txt" + cd "$DEPLOY_PATH" + .venv/bin/python -m unittest discover -s tests -t . --verbose - name: Restart run: sudo systemctl restart "$SERVICE" diff --git a/README.md b/README.md index 0043c97..12c239d 100644 --- a/README.md +++ b/README.md @@ -97,34 +97,52 @@ Edit `User=` and the paths in it if you keep the panel somewhere else. ## Deploying from Gitea -`.gitea/workflows/deploy.yml` runs the tests on every push to `main`, then -rsyncs the tree into place, installs anything new from `requirements.txt`, -restarts the service and waits for the panel to answer again. +`.gitea/workflows/deploy.yml` fast-forwards the checkout on the server, +installs anything new from `requirements.txt`, runs the tests, restarts the +service and waits for the panel to answer again. -It needs a runner **in host mode on the machine that serves the panel**, -registered with the label `heos` (`runs-on:` must match, or the job queues -forever), running as the user that owns the directory. +### Once, by hand on the server -Every step is plain shell. `actions/checkout` is a JavaScript action, and a -host-mode runner can only run those with `node` on its PATH — swapping the -clone back for it brings back `Cannot find: node in PATH`. The clone takes -its URL from the live checkout's own remote, so no URL or token is written -down here. - -Restarting needs one sudoers line: +The workflow only ever *updates* a checkout. It never creates one, so the +remote and whatever credentials reach it are set up a single time and stay +put. As the user the runner runs as: ```bash -echo 'franzz ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel' \ +git clone /var/www/html/heos +cd /var/www/html/heos +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt + +sudo cp deploy/heos-panel.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now heos-panel + +echo "$(id -un) ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel" \ | sudo tee /etc/sudoers.d/heos-panel sudo chmod 440 /etc/sudoers.d/heos-panel ``` -The rsync excludes `.venv` and `members.json`, so the runtime and the -learned stereo-pair membership survive a deploy. It does *not* exclude -`config.py`: your device names live in git, so commit changes to them -rather than editing the deployed copy. And since the deploy path is also -where you edit, `--delete` lands on top of anything uncommitted sitting -there. +Edit `User=` and the paths in the unit if the panel lives somewhere else or +runs as someone else. Until that clone exists the workflow stops on its +first real step and prints these commands back at you. + +### The runner + +Host mode, on the machine that serves the panel, registered with the label +`heos` (`runs-on:` must match, or the job queues forever), running as the +user that owns the checkout — it writes there, and it fetches with that +checkout's own git credentials. + +Every step is plain shell. `actions/checkout` is a JavaScript action, and a +host-mode runner can only run those with `node` on its PATH; without one it +fails with `Cannot find: node in PATH`. + +Nothing untracked is disturbed, so `.venv` and `members.json` — the runtime +and the stereo pair's learned membership — survive a deploy untouched. The +fast-forward is `--ff-only`, so a tracked file edited on the box without +being committed stops the deploy rather than being silently overwritten. +`config.py` is tracked: your device names live in git, so change them there +and push, rather than editing the deployed copy. ## Behind a reverse proxy, at /heos diff --git a/config.py b/config.py index 682cec2..6b821b4 100644 --- a/config.py +++ b/config.py @@ -66,4 +66,4 @@ VOLUME_STEP = 5 AVR_INPUT_CODES = [] # Shown as the app's name on the iOS home screen. -APP_NAME = "HEOS" +APP_NAME = "Heos"