diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..311a967
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,63 @@
+name: Deploy live
+
+on:
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: importarr-live
+ cancel-in-progress: false
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+ - name: Install dependencies
+ run: |
+ python -m pip install '.[test]'
+ npm --prefix frontend ci
+ - name: Test and build
+ run: |
+ python -m pytest -q
+ npm --prefix frontend test -- --run
+ npm --prefix frontend run build
+ sh -n deploy/systemd-install.sh
+ sh -n deploy/repo-upgrade.sh
+
+ deploy:
+ needs: test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Configure deployment SSH
+ shell: bash
+ env:
+ DEPLOY_KEY: ${{ secrets.IMPORTARR_DEPLOY_SSH_KEY }}
+ KNOWN_HOSTS: ${{ secrets.IMPORTARR_DEPLOY_KNOWN_HOSTS }}
+ run: |
+ install -d -m 0700 ~/.ssh
+ install -m 0600 /dev/null ~/.ssh/id_ed25519
+ printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
+ printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
+
+ - name: Deploy origin/main
+ env:
+ DEPLOY_HOST: ${{ secrets.IMPORTARR_DEPLOY_HOST }}
+ DEPLOY_PORT: ${{ secrets.IMPORTARR_DEPLOY_PORT }}
+ DEPLOY_USER: ${{ secrets.IMPORTARR_DEPLOY_USER }}
+ run: >-
+ ssh -p "$DEPLOY_PORT" -o BatchMode=yes "$DEPLOY_USER@$DEPLOY_HOST"
+ sudo -n /bin/sh -c 'set -eu; cd /opt/importarr/repo;
+ test -z "$(git status --porcelain)";
+ git fetch --prune origin main;
+ git checkout -B main origin/main;
+ exec /bin/sh deploy/repo-upgrade.sh'
diff --git a/.gitignore b/.gitignore
index 3551159..5ce76fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,4 @@ __pycache__/
*.db
*.partial
AGENTS.local.md
-.review-data/
-deploy/importarr.review.env
frontend/node_modules/
diff --git a/AGENTS.md b/AGENTS.md
index f239836..1e1775b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,11 +1,7 @@
# Repository Instructions
-The Importarr review site at must always reflect the local working-tree code.
+## Deployment workflow
-- After making local code changes, run `deploy/review/review.sh update` from the repository root.
-- Before finishing, verify that `http://172.20.30.35:18765/` responds successfully.
-- If deployment or URL verification fails, report the failure clearly; do not claim the review site is current.
-
-## Release workflow
-
-Generic requests such as "release app", "release to live", or "release importarr" mean the complete release-to-live flow: verify a clean release candidate, run tests, commit the intended changes, create and push a SemVer tag and repository release, call the live bearer's protected `POST /api/control/update?expected_tag=` with the required exact release tag, then verify health and that the reported installed version matches the release. Follow any host-local operational runbook for credentials and live URLs; never put tokens or other secrets in this repository, commands shown in logs, release notes, or commits.
+Pushes to `main` deploy through the bounded Gitea Actions workflow. Follow the
+host-local operational runbook for credentials, live URLs, and verification;
+never put tokens, SSH keys, or other secrets in this repository or logs.
diff --git a/Makefile b/Makefile
index 564e83c..2e13fcf 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,6 @@ SERVICE ?= importarr.service
IMPORTARR_PREFIX ?= /opt/importarr
IMPORTARR_REPO_DIR ?= $(CURDIR)
IMPORTARR_URL ?= http://127.0.0.1:8765
-RELEASE ?= vMAJOR.MINOR.PATCH
.PHONY: test install-systemd install-from-repo repo-upgrade verify
@@ -18,7 +17,7 @@ install-from-repo:
sudo -n $(IMPORTARR_PREFIX)/venv/bin/pip install --upgrade $(IMPORTARR_REPO_DIR)
repo-upgrade:
- sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh $(RELEASE)
+ sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh
verify:
curl -fsS $(IMPORTARR_URL)/health
diff --git a/README.md b/README.md
index 26c6814..2b67a13 100644
--- a/README.md
+++ b/README.md
@@ -38,14 +38,26 @@ The installer creates the `importarr` system user when needed, installs a virtua
For a machine that should stay current with the repository, use the installed repo-upgrade helper:
```sh
-sudo -n sh /opt/importarr/repo-upgrade.sh v1.2.3
+sudo -n sh /opt/importarr/repo-upgrade.sh
```
-The helper requires the intended release tag and a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches tags, checks out that exact tag in detached-HEAD state, reinstalls the package, records tag/SHA provenance, and restarts `importarr.service`; it never installs an arbitrary branch head.
+The helper requires a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches and checks out exactly `origin/main`, reinstalls the package, records commit provenance, and restarts `importarr.service`.
-Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the installed release recorded in `/opt/importarr/build.env`. `POST /api/control/update?expected_tag=v1.2.3` verifies that the latest release is the caller's expected tag, appends that exact tag to the configured command, and schedules it in a detached process after returning. A mismatch returns `409` without scheduling an upgrade. The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent requests cannot overlap. Poll `/health` and `update-check` until the new process is healthy and reports the expected tag. The systemd default is the working-directory-independent `/bin/sh /opt/importarr/repo-upgrade.sh`; configure `IMPORTARR_UPDATE_COMMAND` only when the helper is installed elsewhere. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need a wrapper such as sudo.
+When bootstrapping an existing tagged-release installation, invoke the new helper directly from the checked-out repository once instead of invoking the old installed copy:
-Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then installed from the tagged checkout or artifact.
+```sh
+sudo -n sh /opt/importarr/repo/deploy/repo-upgrade.sh
+```
+
+That first run installs the repository-based helper at `/opt/importarr/repo-upgrade.sh` and removes the obsolete `IMPORTARR_UPDATE_COMMAND` entry from the existing env file. Subsequent deployments can use the installed helper normally.
+
+Pushes to `main` run `.gitea/workflows/deploy.yml`. Python tests, frontend tests/build, and deploy-script syntax checks must pass before the deploy job can start. The workflow then connects only to the configured live SSH host, refuses a dirty checkout, updates `/opt/importarr/repo` to `origin/main`, and invokes that checkout's upgrade helper through passwordless sudo.
+
+Configure repository Actions secrets `IMPORTARR_DEPLOY_HOST`, `IMPORTARR_DEPLOY_PORT`, `IMPORTARR_DEPLOY_USER`, `IMPORTARR_DEPLOY_SSH_KEY` (a dedicated private key accepted for the deployment account), and `IMPORTARR_DEPLOY_KNOWN_HOSTS` (a pinned `known_hosts` line for the live host). The deployment account must allow the workflow's fixed `/bin/sh -c` deployment command through `sudo -n`. The live checkout must be able to fetch `origin/main`; do not put a Git credential in the workflow. Protect `main`: require pull requests and the workflow test job, restrict direct pushes and force pushes, and limit workflow/secret administration to trusted maintainers. Gitea Actions executes repository code before deployment, so deployment secrets must not be exposed to pull-request workflows and untrusted contributors must not be permitted to push to `main`. These controls are repository-administration prerequisites; this workflow cannot enforce them itself.
+
+The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent deployments cannot overlap. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need a wrapper such as sudo.
+
+Release-worthy changes should be committed and pushed to `main`; tags remain optional release metadata and are not the deployment trigger.
### Required setup
@@ -78,8 +90,6 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
- `POST /api/control/stop`
- `POST /api/control/cancel-current`
- `POST /api/control/restart`
-- `GET /api/control/update-check`
-- `POST /api/control/update?expected_tag=v1.2.3` (`expected_tag` is required)
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
- `POST /api/import/run-now`
@@ -103,42 +113,6 @@ lucide-react for icons. Run `npm --prefix frontend run dev` for Vite's developme
server (it proxies API calls to port 8765), or build before running FastAPI so the
production assets are written to `importarr/static`.
-### Persistent local review environment
-
-The review Compose stack builds the current working tree, including uncommitted
-UI/API changes, and stays running for pre-commit or pre-push inspection. It is
-separate from production: the web port is bound to localhost by default, state and
-sample media live under the ignored `.review-data/` directory, external Arr/SAB
-services are not required, and service-control/update commands are safe no-ops.
-
-```sh
-deploy/review/review.sh up # build current files and start in background
-deploy/review/review.sh update # rebuild changed files and recreate as needed
-deploy/review/review.sh status
-deploy/review/review.sh logs # follow logs; Ctrl-C leaves the stack running
-deploy/review/review.sh stop # stop containers, preserving them and data
-deploy/review/review.sh down # remove containers/network, preserving data
-deploy/review/review.sh reset # remove stack and all local review data
-```
-
-On first use the wrapper copies `deploy/importarr.review.env.example` to the
-ignored `deploy/importarr.review.env`. Adjust `REVIEW_PORT` there if port 18765
-is occupied, then review `http://127.0.0.1:18765/`. For review from a trusted
-internal network, set `REVIEW_BIND_ADDRESS` to the host's LAN address and use
-that address in the URL. Do not use `0.0.0.0` or expose this review stack to an
-untrusted network; the safe default is `127.0.0.1`. To exercise imports without
-real integrations, place disposable folders in `.review-data/downloads/`; movie
-and TV destinations are `.review-data/movies/` and `.review-data/tv/`.
-Each checkout gets its own Compose project and image name, so worktrees do not
-replace each other's containers or images. Read-only and cleanup commands do not
-create the local env file when it is absent.
-
-Agent workflow: run tests, run `update`, confirm `status` reports healthy, and
-leave the stack running for the reviewer. Reviewer workflow: inspect the UI and
-API, use `logs` when needed, and use `down` after review (or `reset` when the
-saved review state is no longer useful). Re-run `update` after every working-tree
-change that should be reviewed.
-
## Operations
Importarr intentionally does not document private deployment topology, hostnames, reverse proxies, monitoring, backups, or operator workflows in this repository. Keep those details in your own ops runbooks.
diff --git a/deploy/docker-compose.review.yml b/deploy/docker-compose.review.yml
deleted file mode 100644
index de1c424..0000000
--- a/deploy/docker-compose.review.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-services:
- importarr:
- build:
- context: ..
- dockerfile: Dockerfile
- image: ${REVIEW_IMAGE:-importarr-review:local}
- env_file:
- - ${REVIEW_SERVICE_ENV_FILE:-importarr.review.env.example}
- ports:
- - "${REVIEW_BIND_ADDRESS:-127.0.0.1}:${REVIEW_PORT:-18765}:8765"
- volumes:
- - ../.review-data/config:/config
- - ../.review-data/downloads:/data/downloads/manual
- - ../.review-data/movies:/data/movies
- - ../.review-data/tv:/data/tv
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=2)"]
- interval: 10s
- timeout: 3s
- retries: 6
- start_period: 5s
diff --git a/deploy/importarr.env.example b/deploy/importarr.env.example
index 34ebddf..ac49cfd 100644
--- a/deploy/importarr.env.example
+++ b/deploy/importarr.env.example
@@ -16,6 +16,3 @@ IMPORTARR_LOG_LEVEL=info
IMPORTARR_BIND_HOST=0.0.0.0
IMPORTARR_BIND_PORT=8765
IMPORTARR_POLL_SECONDS=60
-# IMPORTARR_REPO_DIR=/path/to/importarr
-# Override only when the installed helper is not at the standard systemd path.
-# IMPORTARR_UPDATE_COMMAND=/bin/sh /opt/importarr/repo-upgrade.sh
diff --git a/deploy/importarr.review.env.example b/deploy/importarr.review.env.example
deleted file mode 100644
index b0c23f3..0000000
--- a/deploy/importarr.review.env.example
+++ /dev/null
@@ -1,23 +0,0 @@
-# Copied to importarr.review.env by deploy/review/review.sh. No secrets or
-# external services are required for review.
-REVIEW_PORT=18765
-# Keep this on loopback unless review access from a trusted network is needed.
-REVIEW_BIND_ADDRESS=127.0.0.1
-IMPORTARR_BIND_HOST=0.0.0.0
-IMPORTARR_BIND_PORT=8765
-IMPORTARR_STATE_PATH=/config/importarr.db
-IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual
-IMPORTARR_MOVIES_ROOT=/data/movies
-IMPORTARR_TV_ROOT=/data/tv
-IMPORTARR_SAB_URL=http://127.0.0.1:9
-IMPORTARR_SAB_CATEGORY=review
-IMPORTARR_POLL_SECONDS=3600
-IMPORTARR_LOG_LEVEL=info
-
-# UI control operations must not control host services or update this checkout.
-IMPORTARR_START_COMMAND=/bin/true
-IMPORTARR_STOP_COMMAND=/bin/true
-IMPORTARR_RESTART_COMMAND=/bin/true
-IMPORTARR_UPDATE_COMMAND=/bin/true
-IMPORTARR_UPDATE_RELEASE_URL=http://127.0.0.1:9/releases/latest
-IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS=1
diff --git a/deploy/repo-upgrade.sh b/deploy/repo-upgrade.sh
index 4f71220..e7ece6f 100644
--- a/deploy/repo-upgrade.sh
+++ b/deploy/repo-upgrade.sh
@@ -2,7 +2,7 @@
set -eu
if [ "$(id -u)" -ne 0 ]; then
- echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH" >&2
+ echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh" >&2
exit 1
fi
@@ -16,10 +16,8 @@ PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo}
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
VENV=${IMPORTARR_VENV:-$PREFIX/venv}
-RELEASE_TAG=${1:-}
-
-if [ -z "$RELEASE_TAG" ]; then
- echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
+if [ "$#" -ne 0 ]; then
+ echo "Usage: $0" >&2
exit 2
fi
@@ -42,20 +40,19 @@ if [ -n "$(git status --porcelain)" ]; then
exit 1
fi
-git fetch --prune --tags origin
-if ! git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then
- echo "Release tag not found: $RELEASE_TAG" >&2
- exit 1
-fi
-git checkout --detach "$RELEASE_TAG"
-test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG"
+git fetch --prune origin main
+git checkout -B main origin/main
+test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
"$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "$REPO_DIR"
+if [ -f "$ENV_FILE" ]; then
+ sed -i '/^[[:space:]]*IMPORTARR_UPDATE_COMMAND=/d' "$ENV_FILE"
+fi
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" "$PREFIX/repo-upgrade.sh"
systemctl daemon-reload
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
-VERSION="$RELEASE_TAG"
+VERSION="$(git rev-parse --short=12 HEAD)"
BUILD_ENV_TMP="$PREFIX/build.env.tmp.$$"
trap 'rm -f "$BUILD_ENV_TMP"' EXIT HUP INT TERM
cat > "$BUILD_ENV_TMP" <&2
- exit 2
-}
-
-command -v docker >/dev/null 2>&1 || {
- echo "docker is required" >&2
- exit 1
-}
-
-if docker info >/dev/null 2>&1; then
- docker_with_sudo=false
-elif command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then
- docker_with_sudo=true
-else
- echo "cannot access the Docker daemon with docker or sudo -n docker" >&2
- exit 1
-fi
-
-compose() {
- selected_env_file=$env_example
- if [ -f "$env_file" ]; then
- selected_env_file=$env_file
- fi
-
- if [ "$docker_with_sudo" = true ]; then
- sudo -n env REVIEW_IMAGE="$review_image" REVIEW_SERVICE_ENV_FILE="$selected_env_file" \
- docker compose --project-name "$project_name" --project-directory "$repo_dir/deploy" \
- --env-file "$selected_env_file" -f "$compose_file" "$@"
- else
- REVIEW_IMAGE="$review_image" REVIEW_SERVICE_ENV_FILE="$selected_env_file" \
- docker compose --project-name "$project_name" --project-directory "$repo_dir/deploy" \
- --env-file "$selected_env_file" -f "$compose_file" "$@"
- fi
-}
-
-ensure_env_file() {
- if [ ! -f "$env_file" ]; then
- cp "$env_example" "$env_file"
- echo "Created $env_file from the safe review defaults."
- fi
-}
-
-remove_data() {
- if rm -rf "$data_dir" 2>/dev/null && [ ! -e "$data_dir" ]; then
- return
- fi
- if [ "$docker_with_sudo" = true ]; then
- sudo -n rm -rf "$data_dir"
- else
- echo "cannot remove root-owned review data without passwordless sudo: $data_dir" >&2
- exit 1
- fi
-}
-
-case "${1:-}" in
- up|update)
- ensure_env_file
- mkdir -p "$data_dir/config" "$data_dir/downloads" "$data_dir/movies" "$data_dir/tv"
- compose up --detach --build --wait
- ;;
- status)
- compose ps
- container_id=$(compose ps --quiet importarr)
- [ -n "$container_id" ] || {
- echo "review service is not running" >&2
- exit 1
- }
- health=$(if [ "$docker_with_sudo" = true ]; then
- sudo -n docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id"
- else
- docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id"
- fi)
- [ "$health" = healthy ] || {
- echo "review service is not healthy (status: $health)" >&2
- exit 1
- }
- ;;
- logs)
- compose logs --follow --tail=200
- ;;
- stop)
- compose stop
- ;;
- down)
- compose down --remove-orphans
- ;;
- reset)
- compose down --remove-orphans
- remove_data
- echo "Removed review data: $data_dir"
- ;;
- *) usage ;;
-esac
diff --git a/deploy/systemd-install.sh b/deploy/systemd-install.sh
index 088a299..42bd783 100644
--- a/deploy/systemd-install.sh
+++ b/deploy/systemd-install.sh
@@ -46,4 +46,4 @@ systemctl enable importarr.service
systemctl enable manual-media-import.timer
systemctl restart importarr.service
systemctl start manual-media-import.timer
-echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH"
+echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh"
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index a3ed86b..81ad20c 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
-import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
+import { Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
import "./globals.css";
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui";
@@ -36,18 +36,17 @@ export function SettingsDialog({ status, refresh }) {
function ManualBatches({ refresh }) { const [batches,setBatches]=useState([]); const load=()=>request("/api/manual-batches").then(setBatches).catch(()=>{}); return Manual batches}>