Show build info in header
Deploy live / test (push) Failing after 36s
Deploy live / deploy (push) Skipped

Deploy the CI-built wheel for the triggering SHA so the UI and\nbuild metadata stay in sync in production.
This commit is contained in:
2026-08-11 10:16:37 +02:00
parent 52701569c3
commit a93480e48a
5 changed files with 74 additions and 13 deletions
+20 -5
View File
@@ -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"
+16 -6
View File
@@ -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
+2 -1
View File
@@ -23,6 +23,7 @@ export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures-
function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; }
function Field({ label, ...props }) { return <label className="grid gap-2 text-sm font-medium">{label}<Input {...props}/></label>; }
export function BuildSummary({ build }) { return <p className="hidden max-w-md truncate text-xs text-muted-foreground sm:block">{build?`Build ${build.git_sha} · ${build.build_date}`:"Build information unavailable"}</p>; }
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 <div className="min-h-screen bg-background">
<header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{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])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header>
<header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p><BuildSummary build={status?.build}/></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{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])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header>
<main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["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])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p>{currentName&&<><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/><p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p></>}</CardContent></Card>
+6 -1
View File
@@ -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(<BuildSummary build={{ git_sha: "abc123def456", build_date: "2026-08-11T12:34:56+02:00" }} />);
expect(screen.getByText("Build abc123def456 · 2026-08-11T12:34:56+02:00")).toBeInTheDocument();
});
});
+30
View File
@@ -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():