diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 00cd1c0..1f4f306 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -31,13 +31,22 @@ jobs: python -m pytest -q npm --prefix frontend test -- --run npm --prefix frontend run build + python -m pip wheel --no-deps --wheel-dir dist . sh -n deploy/systemd-install.sh sh -n deploy/repo-upgrade.sh + - uses: actions/upload-artifact@v4 + with: + name: importarr-wheel + path: dist/*.whl deploy: needs: test runs-on: ubuntu-latest steps: + - uses: actions/download-artifact@v4 + with: + name: importarr-wheel + path: dist - name: Configure deployment SSH shell: bash env: @@ -54,10 +63,16 @@ jobs: 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; + DEPLOY_SHA: ${{ github.sha }} + run: | + WHEEL=$(find dist -maxdepth 1 -name 'importarr-*.whl' -print -quit) + test -n "$WHEEL" + scp -P "$DEPLOY_PORT" -o BatchMode=yes "$WHEEL" "$DEPLOY_USER@$DEPLOY_HOST:/tmp/importarr-deploy.whl" + ssh -p "$DEPLOY_PORT" -o BatchMode=yes "$DEPLOY_USER@$DEPLOY_HOST" \ + "deploy_status=0; 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'" + git checkout --detach $DEPLOY_SHA; + /bin/sh deploy/repo-upgrade.sh /tmp/importarr-deploy.whl $DEPLOY_SHA' || deploy_status=\$?; + rm -f /tmp/importarr-deploy.whl; + exit \$deploy_status" diff --git a/deploy/repo-upgrade.sh b/deploy/repo-upgrade.sh index e7ece6f..e68d790 100644 --- a/deploy/repo-upgrade.sh +++ b/deploy/repo-upgrade.sh @@ -16,10 +16,16 @@ PREFIX=${IMPORTARR_PREFIX:-/opt/importarr} REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo} SERVICE=${IMPORTARR_SERVICE:-importarr.service} VENV=${IMPORTARR_VENV:-$PREFIX/venv} -if [ "$#" -ne 0 ]; then - echo "Usage: $0" >&2 +if [ "$#" -gt 2 ]; then + echo "Usage: $0 [wheel [revision]]" >&2 exit 2 fi +WHEEL=${1:-} +REVISION=${2:-} +if [ -n "$WHEEL" ] && [ ! -f "$WHEEL" ]; then + echo "Built wheel not found: $WHEEL" >&2 + exit 1 +fi LOCK_FILE=${IMPORTARR_UPGRADE_LOCK_FILE:-$PREFIX/repo-upgrade.lock} exec 9>"$LOCK_FILE" @@ -40,10 +46,14 @@ if [ -n "$(git status --porcelain)" ]; then exit 1 fi -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 [ -n "$REVISION" ]; then + test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")" +else + git fetch --prune origin main + git checkout -B main origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +fi +"$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "${WHEEL:-$REPO_DIR}" if [ -f "$ENV_FILE" ]; then sed -i '/^[[:space:]]*IMPORTARR_UPDATE_COMMAND=/d' "$ENV_FILE" fi diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 81ad20c..1b1bd76 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -23,6 +23,7 @@ export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures- function Modal({ trigger, title, children }) { return {trigger}{title}{children}; } function Field({ label, ...props }) { return ; } +export function BuildSummary({ build }) { return

{build?`Build ${build.git_sha} · ${build.build_date}`:"Build information unavailable"}

; } export function SettingsDialog({ status, refresh }) { const [result,setResult]=useState({}); @@ -45,7 +46,7 @@ export function App(){ const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)}; const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current; return
-

Importarr

{status?.control?.queue_mode||"Connecting"}

{currentName||"No active import"}
{menu&& Service info}>
{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=>
{k}
{String(v)}
)}
}
+

Importarr

{status?.control?.queue_mode||"Connecting"}

{currentName||"No active import"}
{menu&& Service info}>
{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=>
{k}
{String(v)}
)}
}
{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=>{value??"—"}{label})}
Current import

{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}

{currentName||"No active copy."}

{currentName&&<>

{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}

}
diff --git a/frontend/src/main.test.jsx b/frontend/src/main.test.jsx index 71f10ac..51e0a49 100644 --- a/frontend/src/main.test.jsx +++ b/frontend/src/main.test.jsx @@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { pollDelay, request, SettingsDialog } from "./main"; +import { BuildSummary, pollDelay, request, SettingsDialog } from "./main"; describe("Importarr UI behavior", () => { beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); }); @@ -38,4 +38,9 @@ describe("Importarr UI behavior", () => { it("backs polling off after failures and immediately recovers its fast interval", () => { expect([pollDelay(0),pollDelay(1),pollDelay(2),pollDelay(4)]).toEqual([300,1000,2000,5000]); }); + + it("shows the build commit and date in the header summary", () => { + render(); + expect(screen.getByText("Build abc123def456 · 2026-08-11T12:34:56+02:00")).toBeInTheDocument(); + }); }); diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py index 2d11562..aec2757 100644 --- a/tests/test_deploy_scripts.py +++ b/tests/test_deploy_scripts.py @@ -57,6 +57,36 @@ def test_repo_upgrade_forces_fresh_install_without_skipping_dependencies(): assert "pip\" install --upgrade --force-reinstall --no-cache-dir" in script assert "--no-deps" not in script + assert '"${WHEEL:-$REPO_DIR}"' in script + + +def test_deploy_workflow_pins_built_wheel_to_triggering_revision(): + workflow = (Path(__file__).parents[1] / ".gitea/workflows/deploy.yml").read_text() + + assert "actions/upload-artifact@v4" in workflow + assert "actions/download-artifact@v4" in workflow + assert "scp -P" in workflow + assert "DEPLOY_SHA: ${{ github.sha }}" in workflow + assert "git checkout --detach $DEPLOY_SHA" in workflow + assert "repo-upgrade.sh /tmp/importarr-deploy.whl $DEPLOY_SHA" in workflow + + +def test_deploy_workflow_cleans_up_without_masking_deployment_failure(): + workflow = (Path(__file__).parents[1] / ".gitea/workflows/deploy.yml").read_text() + + capture = "|| deploy_status=\\$?;" + cleanup = "rm -f /tmp/importarr-deploy.whl;" + propagate = "exit \\$deploy_status" + assert "deploy_status=0; sudo" in workflow + assert workflow.index(capture) < workflow.index(cleanup) < workflow.index(propagate) + + +def test_repo_upgrade_uses_pinned_revision_for_build_metadata(): + script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text() + + assert 'test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")"' in script + assert 'GIT_SHA="$(git rev-parse --short=12 HEAD' in script + assert 'VERSION="$(git rev-parse --short=12 HEAD)"' in script def test_repo_upgrade_deploys_only_origin_main():