diff --git a/.sqlx/query-09f4f824ed960a6bfbf6b8c3c28cd3c88cb041e76f1af34b79751cca2266174f.json b/.sqlx/query-09f4f824ed960a6bfbf6b8c3c28cd3c88cb041e76f1af34b79751cca2266174f.json
new file mode 100644
index 0000000..7e92adb
--- /dev/null
+++ b/.sqlx/query-09f4f824ed960a6bfbf6b8c3c28cd3c88cb041e76f1af34b79751cca2266174f.json
@@ -0,0 +1,64 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT app_id, url, checked_at, last_error FROM app_sources WHERE app_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "app_id",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "app_id"
+ }
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "url",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "url"
+ }
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "checked_at",
+ "type_info": "Timestamptz",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "checked_at"
+ }
+ }
+ },
+ {
+ "ordinal": 3,
+ "name": "last_error",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "last_error"
+ }
+ }
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "09f4f824ed960a6bfbf6b8c3c28cd3c88cb041e76f1af34b79751cca2266174f"
+}
diff --git a/.sqlx/query-33fe8be23d0952bcd9e3e3e312aecfbb2922625384ec931684f05b3aaf7adcdb.json b/.sqlx/query-33fe8be23d0952bcd9e3e3e312aecfbb2922625384ec931684f05b3aaf7adcdb.json
new file mode 100644
index 0000000..e99cfa3
--- /dev/null
+++ b/.sqlx/query-33fe8be23d0952bcd9e3e3e312aecfbb2922625384ec931684f05b3aaf7adcdb.json
@@ -0,0 +1,65 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO app_sources (app_id, url) VALUES ($1, $2)\n ON CONFLICT (app_id) DO UPDATE\n SET url = EXCLUDED.url,\n checked_at = CASE WHEN app_sources.url = EXCLUDED.url\n THEN app_sources.checked_at END,\n last_error = CASE WHEN app_sources.url = EXCLUDED.url\n THEN app_sources.last_error END\n RETURNING app_id, url, checked_at, last_error",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "app_id",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "app_id"
+ }
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "url",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "url"
+ }
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "checked_at",
+ "type_info": "Timestamptz",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "checked_at"
+ }
+ }
+ },
+ {
+ "ordinal": 3,
+ "name": "last_error",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "last_error"
+ }
+ }
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "33fe8be23d0952bcd9e3e3e312aecfbb2922625384ec931684f05b3aaf7adcdb"
+}
diff --git a/.sqlx/query-6099560f8e8294d1455186bc5f5fd91b7b516018bbb6d29da278131a56a55b4b.json b/.sqlx/query-6099560f8e8294d1455186bc5f5fd91b7b516018bbb6d29da278131a56a55b4b.json
new file mode 100644
index 0000000..e52836f
--- /dev/null
+++ b/.sqlx/query-6099560f8e8294d1455186bc5f5fd91b7b516018bbb6d29da278131a56a55b4b.json
@@ -0,0 +1,62 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT app_id, url, checked_at, last_error FROM app_sources ORDER BY app_id",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "app_id",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "app_id"
+ }
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "url",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "url"
+ }
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "checked_at",
+ "type_info": "Timestamptz",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "checked_at"
+ }
+ }
+ },
+ {
+ "ordinal": 3,
+ "name": "last_error",
+ "type_info": "Text",
+ "origin": {
+ "Table": {
+ "table": "app_sources",
+ "name": "last_error"
+ }
+ }
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "6099560f8e8294d1455186bc5f5fd91b7b516018bbb6d29da278131a56a55b4b"
+}
diff --git a/.sqlx/query-9fed993e8dfbc0e7eeca1b6c01683e8341aba5e59f72e1a58025e7b13583a69a.json b/.sqlx/query-9fed993e8dfbc0e7eeca1b6c01683e8341aba5e59f72e1a58025e7b13583a69a.json
new file mode 100644
index 0000000..cb90e95
--- /dev/null
+++ b/.sqlx/query-9fed993e8dfbc0e7eeca1b6c01683e8341aba5e59f72e1a58025e7b13583a69a.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE app_sources SET checked_at = $2, last_error = $3 WHERE app_id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "9fed993e8dfbc0e7eeca1b6c01683e8341aba5e59f72e1a58025e7b13583a69a"
+}
diff --git a/.sqlx/query-bc2e79109d949ae99fb1df140bf37550105763beacd61bdc17551bbaeae3aacc.json b/.sqlx/query-bc2e79109d949ae99fb1df140bf37550105763beacd61bdc17551bbaeae3aacc.json
new file mode 100644
index 0000000..e62c3c2
--- /dev/null
+++ b/.sqlx/query-bc2e79109d949ae99fb1df140bf37550105763beacd61bdc17551bbaeae3aacc.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM app_sources WHERE app_id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "bc2e79109d949ae99fb1df140bf37550105763beacd61bdc17551bbaeae3aacc"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c35868c..3455e33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,36 @@
All notable changes to Setu. Release notes are cut from the section
matching the tag.
+## 0.1.5 — 2026-10-21
+
+Spec: `docs/specs/2026-10-21-setu-0.1.5-design.md`.
+
+### Added
+
+- **Apps follow their manifest URL.** An app published from a URL is
+ checked again every 6 hours, and any new version is published, so it
+ appears in each site's Updates tab without publishing it again by hand.
+ Sites are never updated on their own, and a check never replaces a
+ version that is already published. The Apps page shows the URL and the
+ last check, with **Check now** and **Stop following**. The API is
+ `POST /api/v1/apps/{id}/check` and `DELETE /api/v1/apps/{id}/source`,
+ and `AppOut` gains `source`.
+- **`setu upgrade [--version ]`.** Run as root, it upgrades this
+ server to a release (the latest by default). It verifies the checksum,
+ backs up the control plane, installs the binary, restarts `setu` and
+ `setu-agent`, and waits for `/healthz`. Older releases are refused
+ unless `--allow-downgrade`.
+
+### Changed
+
+- `setu` reads `/etc/setu/setu.toml` when the current directory has no
+ `setu.toml`, so `setu backup` and `setu upgrade` work from any
+ directory.
+
+### Migrations
+
+- `0004_app_sources.sql` (forward-only): the `app_sources` table.
+
## 0.1.4 — 2026-10-20
Spec: `docs/specs/2026-10-20-setu-0.1.4-design.md`.
diff --git a/Cargo.lock b/Cargo.lock
index a10fc9e..3cfb77a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -977,7 +977,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hello"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"axum",
"reqwest",
@@ -2170,7 +2170,7 @@ dependencies = [
[[package]]
name = "setu-agent"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"anyhow",
"async-trait",
@@ -2204,7 +2204,7 @@ dependencies = [
[[package]]
name = "setu-api"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"argon2",
"async-trait",
@@ -2212,6 +2212,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"clap",
+ "flate2",
"futures",
"hex",
"hostname",
@@ -2223,6 +2224,7 @@ dependencies = [
"rand 0.9.5",
"reqwest",
"rust-embed",
+ "semver",
"serde",
"serde_json",
"setu-agent",
@@ -2234,6 +2236,7 @@ dependencies = [
"setu-testkit",
"sha2 0.10.9",
"sqlx",
+ "tar",
"tempfile",
"tokio",
"tower",
@@ -2245,7 +2248,7 @@ dependencies = [
[[package]]
name = "setu-common"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"anyhow",
"axum",
@@ -2271,7 +2274,7 @@ dependencies = [
[[package]]
name = "setu-db"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -2293,7 +2296,7 @@ dependencies = [
[[package]]
name = "setu-jobs"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"async-trait",
"chrono",
@@ -2314,7 +2317,7 @@ dependencies = [
[[package]]
name = "setu-manifest"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"hex",
"rand 0.9.5",
@@ -2328,7 +2331,7 @@ dependencies = [
[[package]]
name = "setu-proto"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"chrono",
"serde",
@@ -2338,7 +2341,7 @@ dependencies = [
[[package]]
name = "setu-testkit"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"sqlx",
"tokio",
diff --git a/Cargo.toml b/Cargo.toml
index 9843b13..5305e19 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ members = [
]
[workspace.package]
-version = "0.1.4"
+version = "0.1.5"
edition = "2021"
license = "AGPL-3.0-only"
# Never published to crates.io (single binary + private repo); also
diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml
index c2e05ac..982aca9 100644
--- a/crates/api/Cargo.toml
+++ b/crates/api/Cargo.toml
@@ -47,6 +47,9 @@ base64.workspace = true
ipnet.workspace = true
nix.workspace = true
tempfile.workspace = true
+semver.workspace = true
+tar.workspace = true
+flate2.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
setu-common = { path = "../common" }
diff --git a/crates/api/src/follow.rs b/crates/api/src/follow.rs
new file mode 100644
index 0000000..e2bc85b
--- /dev/null
+++ b/crates/api/src/follow.rs
@@ -0,0 +1,126 @@
+//! Followed manifest URLs (0.1.5 §1): an app published from a URL is
+//! fetched again every [`CHECK_EVERY`] and any version not yet published
+//! is published. A check never replaces a published version: a manifest
+//! names binaries and their checksums, and an unattended re-publish must
+//! not swap what a running version installs.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_trait::async_trait;
+use setu_common::AppError;
+use setu_db::{AppSource, Db};
+use setu_jobs::Clock;
+
+use crate::auth::audit;
+
+/// How often every followed app is checked.
+pub const CHECK_EVERY: Duration = Duration::from_hours(6);
+
+/// Fetches a manifest by URL; the real one applies the publish guards.
+#[async_trait]
+pub trait ManifestFetcher: Send + Sync + std::fmt::Debug {
+ async fn fetch(&self, url: &str) -> Result;
+}
+
+/// The guarded HTTP fetch `POST /api/v1/apps` uses.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct HttpFetcher;
+
+#[async_trait]
+impl ManifestFetcher for HttpFetcher {
+ async fn fetch(&self, url: &str) -> Result {
+ crate::rest::apps::fetch_manifest_url(url).await
+ }
+}
+
+/// Fetch `source.url` once and publish its version when it is new.
+/// Returns the version published, or `None` when it was already
+/// published. The outcome is recorded on the source either way.
+pub async fn check(
+ db: &Db,
+ fetcher: &dyn ManifestFetcher,
+ clock: &dyn Clock,
+ source: &AppSource,
+) -> Result
{app.homepage ? (
) : null}
+
);
@@ -77,11 +157,7 @@ export function AppsCatalogue({ onAdd }: { onAdd?: () => void }) {
description="Publish an app manifest to make it available for new sites."
action={
onAdd !== undefined ? (
-