-
Notifications
You must be signed in to change notification settings - Fork 46
App porting guide
This guide describes how to package a third-party application as a Syncloud app today. It reflects the current (2026) patterns — older apps in the repository may still follow earlier conventions; do not mirror them blindly.
Syncloud ships a modified Snapd on each device. Relative to upstream snapd:
- No confinement / apparmor / seccomp restrictions — apps run with full system access
- Syncloud store support built in
- Additional systemd parameters
Because there's no confinement, apps do not declare confinement:, plugs:, or slots: in snap.yaml. Older apps in the repo still carry these — they are no-ops on Syncloud snapd and should be dropped when porting anew.
Follow these, in this priority order, when porting a new app. Each one illustrates a specific pattern.
| App | What to crib |
|---|---|
owncast |
Canonical modern shape: snap.yaml at repo root, Go + Cobra CLI in cli/, Unix-socket nginx, statically-built vendored upstream |
owntracks |
Second reference for the owncast shape; useful nginx Unix-socket config |
paperless |
OIDC auth registration via platformClient.RegisterOIDCClient(...) — the only current reference for OIDC |
redirect |
Playwright UI test layout (web/e2e/, playwright.config.js, ci/ui.sh) and Vue 3 + Element Plus + Vite frontend stack |
bitwarden |
CI matrix covering both bookworm and buster
|
Older apps (openvpn, nextcloud, rocketchat, users) are fine for upstream-integration reference but their packaging patterns (Python hooks, meta/snap.yaml, HTML templates) are outdated.
Mirror owncast:
<app>/
├── snap.yaml # snap metadata — at repo ROOT, not meta/snap.yaml
├── package.sh # builds the .snap via mksquashfs
├── build.sh # top-level build orchestrator (optional)
├── .drone.jsonnet # CI pipeline
├── cli/ # Go + Cobra CLI: install, configure, storage-change, access-change, pre/post-refresh, backup/restore hooks
│ ├── cmd/cli/main.go
│ └── installer/ # Install, Configure, Upgrade, UpdateConfigs, OIDC register
├── backend/ # Go HTTP backend (if app has custom business logic; many apps just wrap upstream)
│ ├── main.go # listens on unix:/var/snap/<app>/current/backend.sock
│ └── build.sh # CGO_ENABLED=0 go build ...
├── web/ # Vue 3 + Element Plus + Vite + TypeScript SPA (if app has custom UI)
│ ├── package.json
│ ├── vite.config.js
│ ├── playwright.config.js
│ ├── src/
│ └── e2e/ # Playwright specs
├── bin/ # service.<app>.sh launcher scripts
├── config/ # templated at install time: nginx.conf, app.conf, etc.
├── <upstream>/ # upstream binary vendoring: build.sh, version pin
├── nginx/ # nginx build scripts (static build)
└── test/ or integration/ # pytest integration tests + Playwright UI tests
Everything the app needs to run, with zero dependency on the host OS beyond the kernel ABI. Bundle databases, web servers, runtimes, libraries. Guarantees:
- Apps keep working across distro upgrades
- Apps can be updated independently of the host
-
CGO_ENABLED=0by default — fully static, no libc dependency, trivial cross-compile. - For C dependencies in vendored upstreams: build with
-ldflags "-linkmode external -extldflags -static"(seeowncast/backend/build.sh). - SQLite driver:
modernc.org/sqlite(pure Go) —mattn/go-sqlite3requires cgo and breaks theCGO_ENABLED=0story.
- cobra — all sibling apps use it.
-
github.com/syncloud/golib/platformfor platform interaction (app URLs, OIDC registration, storage init). -
github.com/syncloud/golib/configfor config-template generation. -
github.com/syncloud/golib/linuxfor user creation, chown, etc.
- Prefer copying pre-built static binaries from official Docker images over rebuilding from source.
- If building from source, pin the upstream version in
build.sh(VERSION=...) and re-build deterministically.
Not every app is a static Go binary. For an interpreted / dynamically-linked
upstream (e.g. the Node server in actual-budget):
-
Bundle the whole runtime, not just the entry binary. Copy the runtime
plus its shared libraries and the dynamic loader out of the upstream image
(
cp -r /usr /lib), and launch through the bundled loader exactly likenginx/bin/nginx.shdoes (ld-linux.so --library-path <bundled-libs> <binary>). The snap then depends only on the kernel ABI and runs unchanged on bookworm and buster — and on arm/v7, where the platform image lacks libs the binary needs (e.g.libatomic). - Don't run the binary directly against the host loader/libc: the upstream image's
glibc is usually newer than the target's, so you get
GLIBC_… not found. Invoking the bundled loader sidesteps that. - Native addons (
*.node, etc.) ride along inside the copied tree, so there's no per-arch rebuild — build each arch from that arch's upstream image.
Default to zero comments in source — Go, Vue, TypeScript, shell, jsonnet. Well-named identifiers carry intent; rationale goes in commit messages and PR descriptions, which survive refactors and don't need synchronizing with the code they describe.
Exceptions worth keeping:
- Hidden invariants or upstream constraints (
// S2 must not equal S1+56 — AmneziaWG requirement). - Bug workarounds with a linkable root cause.
- Non-obvious "why this weird thing" — ideally one short line.
If removing a comment wouldn't confuse a future reader, remove it. No "// rewritten", "// added for ticket #…", or "// Mirror of ../other/…" breadcrumbs — those belong in git log.
name: <app>
summary: <Short Title>
description: <Longer description>
grade: stable
apps:
backend:
command: bin/service.backend.sh
daemon: simple
restart-condition: always
start-timeout: 600s
restart-delay: 10s
nginx:
command: bin/service.nginx.sh
daemon: simple
restart-condition: always
start-timeout: 600s
restart-delay: 10s
cli:
command: bin/cli
storage-change:
command: bin/cli storage-change
access-change:
command: bin/cli access-change
backup-pre-stop:
command: bin/cli backup-pre-stop
restore-pre-start:
command: bin/cli restore-pre-start
restore-post-start:
command: bin/cli restore-post-startNote the absence of confinement:, plugs:, slots:, meta: nesting. These are no-ops on Syncloud snapd.
Every long-running daemon must set restart-delay: 10s alongside restart-condition: always — without it, a crashing service will be restarted in a tight loop that spams logs and masks the underlying failure. 10 seconds is the repo convention.
App icon: ship the official upstream logo as meta/gui/icon.png, a 128×128 PNG (the repo convention — snapd auto-detects it, no snap.yaml reference needed). Make sure package.sh copies meta/gui into the snap.
Do not write to log files. Every daemon should log to stdout/stderr (preferred) or syslog, so systemd captures output into the journal. Users then get a single, uniform way to read logs across all apps:
snap logs <app>
journalctl -u snap.<app>.<daemon>
Platform-side tooling (the support-log bundle, the dashboard's log view) reads from the journal — app-private log files are invisible to it.
Rules of thumb when porting:
- Go backends: write to
os.Stdout/os.Stderr. Don't add a file-rotation library. - Upstream binaries that default to a logfile: pass a flag or config option to redirect to stdout (
--log-file=-,ErrorLog stderr,log_destination = stderr, etc.). If the upstream can only log to a file, point it at/dev/stderr. - nginx and php-fpm: use syslog, not
/dev/stdout|/dev/stderr. As a snap systemd service the process's stdout/stderr are journald pipes, not character devices, soaccess_log /dev/stdout;/ php-fpmerror_log = /dev/stderrfail to open the path withENXIO("No such device or address") and crash-loop the daemon (status=1/FAILURE, php-fpmstatus=78/CONFIG). Instead:- nginx:
error_log syslog:server=unix:/dev/log warn;andaccess_log syslog:server=unix:/dev/log;(mirrorowncast/config/nginx.conf). nginx's-e stderrlauncher flag is fine — nginx special-cases the literalstderr, unlike the/dev/stdoutpath. - php-fpm:
error_log = syslog+syslog.ident = <app>.php-fpmin the[global]block, andcatch_workers_output = yesso worker output reaches the journal too (mirror the wordpress app).
- nginx:
- If stdout is genuinely impossible, use syslog (
logger, Golog/syslog, upstream's syslog sink) — systemd-journald ingests syslog too. This is the norm for bundled nginx/php-fpm, not just a fallback. - No
logrotateconfigs, no/var/snap/<app>/common/log/*.log, no bespoke log directories.
Why: log files inside the snap fill up the data partition silently, bypass journalctl retention policy, and make support triage harder.
External network exposure goes through the platform's reverse proxy. Internally the app uses Unix sockets, not localhost TCP. Standard paths:
| Component | Path |
|---|---|
| platform → app (via nginx) | /var/snap/<app>/common/web.socket |
| nginx → backend | /var/snap/<app>/current/backend.sock |
| nginx → upstream (if any) | /var/snap/<app>/current/<upstream>.sock |
nginx config pattern (mirror owncast/config/nginx.conf):
upstream backend {
server unix:/var/snap/<app>/current/backend.sock;
}
server {
listen unix:/var/snap/<app>/common/web.socket;
location /api/ { proxy_pass http://backend; }
location / { root /snap/<app>/current/web/dist; try_files $uri /index.html; }
}No HTTPS on the app itself — the platform reverse proxy terminates TLS using Syncloud platform certs.
Some upstreams that look TCP-only still accept a socket path — e.g. Node's
server.listen(path) binds a Unix socket when the configured "port" is a
filesystem path (actual-budget sets "port": "…/actual.sock"). Check before
settling for a localhost TCP port.
If the app needs an external port (e.g. VPN UDP port, mail SMTP/IMAP), that's the only port the app binds on a real network interface. Prefer dynamic allocation at install time (random high port, persisted) rather than hard-coded defaults, because other apps may have already taken a specific port. Surface the chosen port to the user in the app's UI so they can forward it on their router — port forwarding is a user concern today; there is no platform-side auto-forwarding.
Mirror paperless/cli/ or owncast/cli/. Installer responsibilities:
-
Install hook — initialize DB, lay out directories under
/var/snap/<app>/{current,common}/. -
Configure hook — runs on every install/refresh:
- Create app user via
linux.CreateUser(App). -
platformClient.InitStorage(App, App). -
platformClient.GetAppDomainName(App),GetAppUrl(App). - Generate config files from templates via
config.Generate(...). - Register as OIDC client with the platform (see Auth section).
- Fix permissions.
- Create app user via
- Storage-change hook — re-create storage dirs, chown.
- Access-change hook — regenerate configs that depend on domain/URL.
- Pre-refresh / post-refresh — DB backup and restore.
- Backup-pre-stop / restore-pre-start / restore-post-start — data backup/restore hooks.
Current recommendation is OIDC. The platform runs Authelia and exposes a full OpenID Connect provider.
password, err := i.platformClient.RegisterOIDCClient(
App, // app name, e.g. "amneziawg"
"/auth/callback", // redirect path on the app's domain
true, // require PKCE
"client_secret_basic", // token endpoint auth method
)
// Persist `password` (the client_secret) where the backend can read it.Working reference: paperless/cli/installer/installer.go#L275.
- OIDC discovery against
${authUrl}/.well-known/openid-configuration(whereauthUrl = platformClient.GetAppUrl("auth")). - Standard auth-code + PKCE flow using
golang.org/x/oauth2+github.com/coreos/go-oidc/v3. - On callback: validate ID token → mint signed session cookie → redirect to SPA.
- Middleware verifies session cookie on
/api/*.
If the upstream has its own OpenID Connect support (paperless, actual-budget, …), don't reimplement the flow — register the client and hand the upstream its config:
-
configurestill callsRegisterOIDCClient(App, "<upstream-callback-path>", …), then writesissuer/client_id/client_secret/ redirect into the upstream's own config (templated viaconfig.Generate). -
Authelia implements RFC 9207 (
authorization_response_iss_parameter_supported), so strict OIDC clients require theissresponse parameter; an upstream that doesn't forward it fails the token exchange (iss missing from the response). If you can't patch the upstream, use its non-strict /oauth2token-exchange path (actual-budget setsauthMethod: oauth2).
The platform terminates TLS with its own CA. Runtimes that don't read the OS
trust store (Node, Python requests, …) won't trust it even after
update-ca-certificates — point them at the platform CA explicitly in the service
script:
- Node:
export NODE_EXTRA_CA_CERTS=/var/snap/platform/current/syncloud.ca.crt - Python
requests:export REQUESTS_CA_BUNDLE=/var/snap/platform/current/syncloud.ca.crt
That file exists on real devices too (the platform signs its certs with it), so it's the correct permanent setting, not a CI-only workaround.
The platform also exposes LDAP (port, DN per platformClient). Older apps that have native LDAP support upstream (nextcloud, gitea, etc.) use this instead of OIDC. Choose OIDC for new apps when you're implementing auth in your own Go backend; choose LDAP when the upstream app already has LDAP support.
The old openvpn app uses Authelia forward-auth with a shared session cookie. Do not use this pattern for new apps — OIDC is cleaner and gives the app its own session lifecycle.
The platform itself (platform/web/platform/), redirect (redirect/web/), and future apps all share this stack. Matching it gives you:
- Visual consistency with the Syncloud dashboard
- A
vite.config.jsto crib from (including Element Plus auto-import) - Pinia for state, Vue Router, auto-imported components
Scaffold:
npm create vite@latest web -- --template vue-ts
cd web
npm install element-plus pinia vue-router
npm install -D unplugin-auto-import unplugin-vue-componentsKeep SPA output in web/dist/ and have nginx serve it statically from /snap/<app>/current/web/dist/.
SQLite is the default for app-local state. Path: /var/snap/<app>/current/database.db or /var/snap/<app>/common/db/data.db. Use modernc.org/sqlite (pure Go) to keep CGO_ENABLED=0.
Apps that need more (Postgres, MySQL) vendor the database engine inside the snap. See paperless/cli/installer/database.go (Postgres).
License the packaging repo GPL-3.0, like the rest of the Syncloud app repos (and usually the upstream you're wrapping). Ship the full GNU GENERAL PUBLIC LICENSE Version 3 text as LICENSE at the repo root — copy it from a sibling app (e.g. files/LICENSE).
Don't use a permissive license like MIT: the snap bundles the upstream binary, which for most apps (Navidrome, Owncast, Paperless, …) is GPL, so the conveyed combined work is GPL anyway.
Three tiers:
Standard go test ./... in cli/ and backend/.
Install the snap on a fresh Syncloud image, exercise the API, assert behavior. Mirror owncast/test/ or owntracks/test/.
test/requirements.txt — use syncloud-lib>=366 and do NOT pin selenium. UI testing has fully moved to Playwright (tier 3), and syncloud-lib 366+ dropped the top-level from selenium import webdriver in syncloudlib/integration/conftest.py. Older apps still list selenium==4.21.0 alongside syncloud-lib==365 (games, owntracks, actual-budget) — that pin only exists to satisfy that stale import and should be dropped when you touch those files. With 365 or earlier, omitting selenium fails at collection time with ModuleNotFoundError: No module named 'selenium'; with 366+ no selenium is needed. Mirror bitwarden/test/requirements.txt:
pytest==8.4.1
syncloud-lib==367
pytest-retry==1.6.3
retrying==1.4.2
Mirror redirect/web/:
-
web/e2e/*.spec.js— Playwright specs -
web/playwright.config.js— desktop + mobile projects,baseURL = https://<app>.${PLAYWRIGHT_DOMAIN}, workers=1, retries=1 in CI, HTML report -
web/e2e/fixtures.js— common fixtures (afterEach screenshot on failure) -
ci/ui.sh— shell wrapper that CI invokes - CI image:
mcr.microsoft.com/playwright:v1.59.1-jammy
Reference: redirect/.drone.jsonnet for the CI integration.
redirect owns its Vue templates, so it selects by data-testid. When you're wrapping an
upstream web UI (FreshRSS, Paperless, Audiobookshelf, Actual) you can't add
data-testid — mirror audiobookshelf/web/e2e/ or actual-budget/web/e2e/ instead:
-
Log in through Syncloud SSO, not an app account. The specs drive the Authelia login
form (
#username-textfield/#password-textfield/#sign-in-button) withPLAYWRIGHT_USER/PLAYWRIGHT_PASSWORD, then assert a stable upstream element is visible. Keep ahelpers/auth.tsloginViaSyncloud()/loginViaAuthelia()that navigates tobaseURL, fills the Authelia form when the host isauth.*, and waits for a known app selector. Never provision a local app user with a password just to test it. -
Select against upstream HTML with resilient selectors —
getByRole,getByText, stable CSS ids/classes the upstream ships, and.or(...)fallback chains. Add adata-testidonly if the upstream already emits one. -
web/e2e/ci-ui.sh <project>exportsPLAYWRIGHT_*, adds thegetent … auth.$DOMAINhost alias,npm ci,npx playwright test --project=<project>.global-teardown.tsssh'es the device forjournalctland copies screenshots/videos intoartifact/. - Specs cover the key screens: SSO login landing on the main view, and one core navigation (settings/subscription/etc.).
Drone CI. Required coverage:
-
Architectures:
amd64,arm64at minimum.armhf(arm/v7) is optional and has been dropped by newer apps — attempt it, drop if it breaks. -
Distros:
bookwormANDbuster. Older devices still run buster, so coverage matters.
Buster apt repos are EOL, but rootfs refreshes the buster Docker test image on every release: it pulls a pinned syncloud/platform-buster-<arch>:25.02 base, re-runs the snap-based platform install on top (no apt needed), and republishes at the same tag as bookworm. App pipelines therefore use one platform variable for both distros — no buster-specific pin.
As of rootfs 26.07.01 the platform (and bootstrap) images are multi-arch manifest lists — the arch is gone from the reference. rootfs pushes each arch as a tag suffix inside a single repo (syncloud/platform-<distro>:<version>-<arch>) and fuses them with a plugins/manifest step into syncloud/platform-<distro>:<version>. Docker resolves the right arch per CI node, so pipelines drop the -<arch>:
local platform = '26.07.01';
local distros = ['bookworm', 'buster'];
// ...
image: 'syncloud/platform-' + distro + ':' + platform,Older app pipelines still carry a platform_buster = '25.02' constant and a ternary on distro. That pattern is obsolete — drop it when you next touch those files.
All CI steps must invoke a shell script committed in the repo, not inline commands: [...] blocks. This includes the Go CLI build, the web build, packaging, and tests. The only things allowed inline are trivial one-liners like echo $DRONE_BUILD_NUMBER > version.
Why:
- Every step is reproducible locally with the same command CI runs —
./cli/build.shworks on your laptop. - Linting and testing the shell is easier when it lives in
.shfiles, not inside JSON-escaped jsonnet strings. - Changes to build logic are reviewed as diffs against
*.sh, not buried in.drone.jsonnet.
Script placement:
- Build scripts: colocated with what they build —
./cli/build.sh,./backend/build.sh,./web/build.sh,./nginx/build.sh,./<upstream>/build.sh. - Test scripts:
./cli/test.sh,./web/test.sh,./nginx/test.shetc., plus top-level./ci/<step>.shfor CI-orchestration steps that don't belong to a single component (e.g../ci/ui.sh,./ci/integration.sh,./ci/upload.sh).
Drone jsonnet then looks like:
{
name: 'cli',
image: 'golang:' + go,
commands: ['./cli/build.sh'],
},
{
name: 'package',
image: 'syncloud/build-deps-' + arch,
commands: ['./package.sh ' + name + ' $DRONE_BUILD_NUMBER'],
},Reference: owncast/.drone.jsonnet follows this pattern (one-line commands: invoking ./nginx/build.sh, ./ffmpeg/build.sh, etc.). redirect/.drone.jsonnet still inlines — do not mirror that.
-
nginx/nginx test {distro}—./nginx/build.sh/./nginx/test.sh -
build web—./web/build.sh -
<upstream>/<upstream> test {distro}— build the bundled upstream, then smoke-test its binaries -
cli/cli test {distro}—./cli/build.sh/./cli/test.sh -
backend—./backend/build.sh -
package—./package.sh <app> $DRONE_BUILD_NUMBER -
test {distro}—./test/ci-test.sh {distro} {arch} -
test-ui-desktop/test-ui-mobile—./web/e2e/ci-ui.sh desktop/./web/e2e/ci-ui.sh mobile -
test-upgrade—./web/e2e/ci-upgrade.sh -
publish— store-publisher image,snap -c ${DRONE_BRANCH}(see below) -
artifact—appleboy/drone-scpto the artifact host
No version step. The old pattern had a version step doing echo $DRONE_BUILD_NUMBER > version that later steps cat. Drop it — pass $DRONE_BUILD_NUMBER straight to package.sh (./package.sh <app> $DRONE_BUILD_NUMBER) and have package.sh write the version file itself (echo ${VERSION} > ${DIR}/version), as pihole/package.sh does. One fewer step, one fewer inter-step file dependency.
Every step is a committed script — including the integration test. Don't inline the getent … auth.$DOMAIN host-aliasing + py.test in the jsonnet; put it in ./test/ci-test.sh <distro> <arch> (mirror actual-budget/test/ci-test.sh). Same for the upstream fetch — an apt install wget && wget … belongs inside ./<upstream>/build.sh, not as an inline commands: line.
The whole point of the ld-linux bundling is bookworm and buster compatibility, so run a per-distro smoke test for each bundled binary — nginx test {bookworm,buster}, php test {bookworm,buster}, cli test {bookworm,buster} — each on that distro's platform-<distro> image (a step per distro via a for distro in distros comprehension). nginx/test.sh runs nginx.sh -version; a php test.sh runs php.sh --version + php.sh -m and greps for the required extensions; cli/test.sh runs the built binaries with --help. Then run the pytest integration/device test on both distros too (test bookworm + test buster), with a platform-<distro> service per distro. A binary that silently fails to load a shared lib on buster is invisible until you exercise it there.
A snapd auto-refresh firing mid-test swaps the platform out from under your app and
flakes the run. Hold it on the platform service by overriding its entrypoint to
drop a snapd ExecStartPost that sets refresh.hold, then exec /sbin/init (the
photoprism pattern):
entrypoint: ['/bin/sh', '-c',
"mkdir -p /etc/systemd/system/snapd.service.d && printf '[Service]\\nExecStartPost=/bin/sh -c \"/usr/bin/snap set system refresh.hold=2099-01-01T00:00:00Z\"\\n' > /etc/systemd/system/snapd.service.d/disable-refresh.conf && exec /sbin/init"],This holds refresh before the app ever installs. An older approach runs
snap set system refresh.hold=… from the integration test instead (paperless) —
the service entrypoint is cleaner.
New apps publish to the store with a single publish step built on the
syncloud/store-publisher Docker image. This replaces the older pattern of
wget-ing a syncloud-release binary and running separate upload and
promote steps authenticated with AWS keys.
local publisher_image = 'syncloud/store-publisher:stable-291';
// ...
{
name: 'publish',
image: publisher_image,
environment: {
SYNCLOUD_TOKEN: { from_secret: 'SYNCLOUD_TOKEN' },
},
command: ['snap', '-c', '${DRONE_BRANCH}'],
when: {
branch: ['master', 'stable'],
event: ['push'],
},
},Notes:
- The image entrypoint takes
snap -c <channel>;${DRONE_BRANCH}selects the channel (masterandstablemap to the corresponding store channels), so the same step covers both whatuploadandpromoteused to do — no separate promote step. - It reads the built package straight out of the build workspace, so it must run
after
package(and after the tests you want to gate on). - Use Drone's
command:(the container entrypoint args), notcommands:(shell lines) — this step runs the image's own entrypoint, not a shell script. -
Only one secret is required:
SYNCLOUD_TOKEN.AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYare no longer needed — drop them when migrating an app to the publisher image.
Pin the publisher image to a specific tag (stable-<n>) rather than a floating
tag, and bump it deliberately.
Reference: games/.drone.jsonnet.
Older apps (e.g. bitwarden) download a release binary and run two steps:
// upload step
'wget ' + deployer + '-' + arch + ' -O release',
'chmod +x release',
'./release publish -f $PACKAGE -b $DRONE_BRANCH',
// promote step (stable only)
'./release promote -n ' + name + ' -a $(dpkg --print-architecture)',…authenticated with AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY plus
SYNCLOUD_TOKEN. Do not mirror this for new apps — use the store-publisher
image above. Migrate existing apps when you next touch their CI.
A new app's Drone repo needs these secrets (set via drone secret add or the
Drone UI):
| Secret | Value |
|---|---|
SYNCLOUD_TOKEN |
store publishing token (same across apps) |
artifact_host |
artifact server host/IP for the artifact scp step |
artifact_key |
SSH private key for the artifact user |
Apps still on the obsolete deployer additionally carry AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY; new apps on the publisher image do not.
drone jsonnet --stdout --stream > .drone.yml
drone exec --pipeline amd64 --trusted --include version --include build --include package .drone.yml
snap install --devmode /path/to/package.snap
"Upstream" = the third-party application the snap wraps (Vaultwarden inside bitwarden, Owncast inside owncast, Paperless-ngx inside paperless, etc.). The Syncloud package wraps a specific upstream release with installer hooks, nginx, OIDC glue, etc.
Most apps now pin the upstream version in .drone.jsonnet as a top-level local, then pass it to whichever step builds or vendors upstream. Older apps still pin it inside <upstream>/build.sh as VERSION=.... Either way it's a single line; just check both.
Examples (pinned to current master — open them and look for the obvious version constant near the top of the file):
| App | Where | Constant |
|---|---|---|
bitwarden/.drone.jsonnet |
jsonnet |
local version = "1.35.4"; (Vaultwarden) |
paperless/.drone.jsonnet |
jsonnet |
local version = '2.20.7'; (Paperless-ngx) |
owntracks/.drone.jsonnet |
jsonnet |
local version = '6.1.0'; (Recorder) |
amneziawg/.drone.jsonnet |
jsonnet |
local amneziawg_go_version = '0.2.17'; + local amneziawg_tools_version = '...';
|
owncast/owncast/build.sh |
shell | VERSION=... |
Some apps have multiple upstream components (e.g. amneziawg pins both amneziawg-go and amneziawg-tools). Bump them together if the upstream cuts paired releases, separately otherwise.
If you can't find the version constant in either place, ask first on the Syncloud forum before opening a PR — packaging conventions evolve and an app may have moved the pin somewhere unusual.
Upstream projects move faster than we do, so apps drift behind. Bumping the pin and reporting what breaks is high-leverage even if you don't fix the integration yourself — a clean repro on a test device is the bulk of the work, and the bisection of "what upstream change caused this" is the part outside contributors are best placed to do.
- Fork the app repo (e.g.
syncloud/bitwarden). - Bump the constant — in
.drone.jsonnetor<upstream>/build.sh, whichever the app uses — to the new upstream release tag. - Build locally. Top-level
./build.shor./package.sh <app> <build-number>is the usual entry point; the per-step Drone images (syncloud/platform-bookworm:<version>, etc.) reproduce the CI environment if you want closer parity. - Install the resulting
.snapon a test device:snap install --devmode /path/to/<app>_<ver>_<arch>.snap. A spare amd64 box, a Raspberry Pi, or a fresh VM with a Syncloud image all work. - Exercise the app — log in via the Syncloud dashboard, run through the main flows, check
snap logs <app>for errors. - Open a PR with the version bump. Even if integration breaks (config schema changed, new required env var, dropped flag), open the PR with a description of what fails — a maintainer can pick up the integration work from there.
No test device? Open an issue with the proposed version bump and what motivated it (CVE, new feature, end-of-life of current pin) — that's still useful signal.
Open an issue linking your repo. It'll be reviewed for functionality, compatibility, and open-source licensing.
Bright:
Dark: