# runs-on must match the label the runner was registered with -- `heos` # here, the way Livetrail uses `livetrail`. A job asking for a label # 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. It also needs node # 20+ on its PATH, since actions/checkout is a JavaScript action and a # host-mode runner has nothing else to run one with. # # DEPLOY_PATH does not have to be a git checkout -- an empty directory the # runner can write to is enough. See "Deploying from Gitea" in the README # for the service and the one sudoers line the restart needs. name: Deploy HEOS panel on: push: branches: - main workflow_dispatch: jobs: deploy: runs-on: heos env: DEPLOY_PATH: /var/www/html/heos SERVICE: heos-panel PANEL_URL: http://127.0.0.1:5443/ # WEB_PORT in config.py steps: - name: Checkout uses: actions/checkout@v4 - name: Check runner tools run: | echo "running as $(id -un) on $(hostname)" command -v python3 command -v rsync command -v curl - name: Check deploy path run: | test -d "$DEPLOY_PATH" test -w "$DEPLOY_PATH" # `sudo -n -l ` asks "may I run this?" without prompting, so the # job stops here with the line to add rather than deploying and then # falling over on the restart at the very end. - name: Check the restart is allowed without a password run: | SYSTEMCTL="$(command -v systemctl)" if sudo -n -l "$SYSTEMCTL" restart "$SERVICE" >/dev/null 2>&1; then echo "$(id -un) may restart $SERVICE" exit 0 fi echo "$(id -un) cannot restart $SERVICE without a password. Once, here:" echo echo " echo '$(id -un) ALL=(ALL) NOPASSWD: $SYSTEMCTL restart $SERVICE' \\" echo " | sudo tee /etc/sudoers.d/$SERVICE" echo " sudo chmod 440 /etc/sudoers.d/$SERVICE" echo echo "The path matters: sudo matches what it resolves from PATH" echo "against the sudoers line, without following symlinks." exit 1 # A throwaway virtualenv in the workspace -- this is the npm ci of a # Python project. 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 run: | python3 -m venv .venv-ci .venv-ci/bin/pip install --quiet --upgrade pip .venv-ci/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. Nothing has # been deployed yet at this point, so a failure here leaves the server # exactly as it was. - name: Run tests run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose # .venv, members.json and .env are excluded, so the runtime, the # stereo pair's learned membership, and Spotify's credentials survive # --delete untouched. - name: Deploy to production run: | rsync -azc --no-times --delete \ --exclude "/.git/" \ --exclude "/.gitea/" \ --exclude "/.venv/" \ --exclude "/.venv-ci/" \ --exclude "/members.json" \ --exclude "/.env" \ --exclude "__pycache__/" \ ./ "$DEPLOY_PATH/" - 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" # The same absolute path the check above validated, so PATH order # cannot leave sudo matching a different one (/bin vs /usr/bin). - name: Restart run: sudo -n "$(command -v systemctl)" restart "$SERVICE" - name: Wait for the panel to answer run: | # The home page renders from config alone, so this proves the app # came back up without waiting on the speakers to reply. for attempt in $(seq 1 20); do if curl -fsS -o /dev/null "$PANEL_URL"; then echo "panel is up after ${attempt}s" exit 0 fi sleep 1 done echo "panel did not come back -- last of its log:" systemctl status "$SERVICE" --no-pager --lines 30 || true exit 1