What a package is
A FlatPark package is one directory under registry/,
named exactly for the app’s Flatpak id. It holds no copy of the app. Its manifest
names the vendor’s own download by URL, checksum and size, and Flatpak fetches
that download on the user’s machine when the app is installed. What FlatPark
builds, signs and hosts is only what goes around it: a launcher, a desktop entry,
an icon, the AppStream data and a script that unpacks the download.
The files
| File | What it is for |
|---|---|
flatpark.yml |
The descriptor: name, summary, category, which manifest to build, how to check for updates, what the package changes. CI and the site read it. |
<id>.yml |
The Flatpak manifest: runtime, sandbox permissions, and the pinned download. |
apply_extra.sh |
Unpacks the download on the user’s machine, at install time. |
<name>-wrapper |
The launcher. Sets what the app needs and starts the vendor’s binary. |
<id>.desktop, <id>.metainfo.xml, <id>.png or <id>.svg |
What the desktop and software centres show. The metainfo also records the current version. |
resolve-update.sh |
Finds upstream’s latest release and its download URL. |
Some packages carry more: a fonts.conf, a second launcher for a CLI, a small
shim. Each one is listed in the manifest and explained in a comment there.
What runs when
- Build (CI, on merge).
flatpak-builderinstalls the launcher, the unpack script and the desktop files into the app. The vendor’s download is not fetched; the manifest only records where it is. - Install (the user’s machine). Flatpak downloads the vendor’s file, checks
its size and SHA-256, and runs
apply_extra.shoffline to unpack it into/app/extra. - Launch. The desktop entry runs the wrapper, which starts the vendor’s
binary from
/app/extra. - Update. Twice a day, and within minutes for apps whose upstream sends us
a release notice,
resolve-update.shis asked for the latest version. When it changed, CI re-pins the download, adds a release to the metainfo and publishes. The user’s nextflatpak updatedownloads the new file and runsapply_extra.shagain.
The one rule: extra-data only
The app always comes from the vendor’s own URL, fetched at install time (Flatpak calls this extra-data). FlatPark doesn’t build apps from source and doesn’t host their files. Three reasons:
- You run what the vendor shipped. A reviewer checks a pin and an unpack script, not a build. Users can compare the checksum with the vendor’s.
- Closed-source apps can be listed. Most licences allow downloading from the vendor but not redistributing; we never redistribute.
- It stays cheap and fast. A package on FlatPark’s storage is a few megabytes of scaffolding instead of the whole app, and an update is a re-pin, not a rebuild, so it ships minutes after upstream’s release. CI downloads the new file once to record its checksum and size; users download it from the vendor, never from FlatPark.
The one thing a package may build in is a shared support library from
flatpark/prebuilt. Everything else
the app needs and the runtime lacks is a second extra-data download.
For agents
flatpark.yml
id: com.example.App # required: the directory name
name: Example App # required
summary: One-line description # required
website: https://example.com/
source_url: https://github.com/example/app # upstream source, if open
build:
manifest: com.example.App.yml # required, relative to this directory
branch: stable # default: stable
mode: extra-data
arches: [x86_64, aarch64] # default: [x86_64]
catalog:
category: Productivity
tags: [Example, Demo]
upstream_approved: true # only with a row in upstream-approvals.md
update:
command: ./resolve-update.sh
policy:
proprietary: true # honest; drives the licence shown on the site
dangerous_permissions: [] # high-risk finish-args, each argued in the PR
packaging: # anything beyond a plain launch
- kind: seeded-config
detail:
en: "One sentence a user can read."
zh-Hans: "同一句话的中文。"
build.arches: listaarch64only when upstream publishes an arm build and the manifest pins it. Each extra-data source then carries its ownonly-arches. The simplest shape gives both payloads the samefilename, soapply_extra.shunpacks one name on either arch.catalog.upstream_approvedis the blue shield. CI fails it without a matching row in upstream approvals.packagingdeclares what the package changes around the vendor’s build; the kinds and levels are in permissions.
The manifest
A typical manifest, trimmed (the full file is
dev.tabularis.Tabularis,
a Tauri app whose upstream approved the listing and pushes its releases to us):
id: dev.tabularis.Tabularis
runtime: org.gnome.Platform
runtime-version: "51"
sdk: org.gnome.Sdk
command: tabularis
separate-locales: false
build-options:
no-debuginfo: true
finish-args:
# why each grant is needed, one comment per group
- --share=ipc
- --share=network
- --socket=wayland
- --socket=fallback-x11
- --device=dri
- --talk-name=org.freedesktop.secrets
modules:
- name: tabularis
buildsystem: simple
build-commands:
- install -Dm755 apply_extra.sh /app/bin/apply_extra
- install -Dm755 tabularis-wrapper /app/bin/tabularis
- install -Dm644 dev.tabularis.Tabularis.desktop /app/share/applications/dev.tabularis.Tabularis.desktop
- install -Dm644 dev.tabularis.Tabularis.metainfo.xml /app/share/metainfo/dev.tabularis.Tabularis.metainfo.xml
- install -Dm644 dev.tabularis.Tabularis.png /app/share/icons/hicolor/512x512/apps/dev.tabularis.Tabularis.png
sources:
# BEGIN MANAGED EXTRA-DATA
- type: extra-data
filename: tabularis.deb
only-arches: [x86_64]
url: https://github.com/TabularisDB/tabularis/releases/download/v0.28.0/tabularis_0.28.0_amd64.deb
sha256: 237649b0…
size: 20561470
# END MANAGED EXTRA-DATA
- type: file
path: apply_extra.sh
- type: file
path: tabularis-wrapper
# … one `type: file` per packaging file
- Runtime. Every app is on the current major of its runtime:
org.freedesktop.Platform26.08,org.gnome.Platform51, ororg.kde.Platform6.11. Never an older major for one app. Which one to pick depends on what the payload brings with it; see choosing a format. apply_extramust be installed to/app/bin/apply_extra. That path is how Flatpak finds it.- The managed block (
# BEGIN MANAGED EXTRA-DATA…# END) is rewritten whole by the updater. Anything else inside it is lost on the next update, so a fixed second download (a prebuilt tool, a fonts archive) goes outside the block. Debian packages pinned as extra-data have their own block,# BEGIN MANAGED DEBIAN <suite> <components>, re-pinned by a separate job. sizemust be right. Flatpak checks it before the checksum. When you change a URL by hand, changesha256andsizetogether; a wrong size builds fine and fails only at install (“Wrong size for extra data”).no-debuginfo: truealone does not strip. It drops the debug split; if/appcontains real binaries (not just scripts), addstrip: trueas well, or the ref grows instead of shrinking.finish-argsare the smallest set that lets the core feature work, with a comment on each group. Optional extras are documented asflatpak overridecommands in the metainfo, not granted; see permissions.
apply_extra.sh
This is the part that differs most from an ordinary Flatpak, and the part that breaks installs when it is wrong. It runs:
- at install and on every update, on the user’s machine, in
/app/extra, where Flatpak has put each download under itsfilename; - offline — no network at all;
- inside the runtime, with only the runtime’s
/usrand/app/extravisible on a system-wide install./app/binis not there, so a tool the script needs (unsquashfs for an AppImage, for example) has to arrive as extra-data too, not be built into the app; - as root with every capability dropped on a system-wide install. A
tarorbsdtarthat tries to restore the archive’s owner fails and aborts the unpack. Always pass--no-same-owner. A--userinstall runs as the user and never shows this, which is why the check below exists. Archives made withoutfakerootrecord a real uid (1000, 1001…);bsdtar --numeric-owner -tvf <file> | awk '{print $3":"$4}' | sort -ushows it.
What it must do:
- Check each expected download exists, unpack it, and move the app to a fixed
path (
/app/extra/<name>), not a versioned one, so the wrapper works across updates. - Don’t hardcode a name upstream can change between releases: a versioned
directory, or the launcher binary itself. Updates are merged without a
human, so read the name out of the archive (a
.deb’s own desktop entryExec=is the authority for Electron builds) and fail if it is ambiguous. tldraw renamed its launcher in 1.12 and the refreshed pin shipped an app nobody could install (#130). - Check the binary it is about to hand to the wrapper exists, and fail with a clear message if not. An upstream that changes its archive layout then fails the update check instead of shipping a broken app.
- Delete the downloaded archives and staging directories. What is left in
/app/extrastays on the user’s disk. - Nothing that must be exported — the desktop entry, icon, metainfo — can come
from here. Flatpak exports files at build time, before the download exists.
Ship them as
type: filesources even if the vendor’s archive has its own.
The runtime has bsdtar (libarchive: tar in every compression, zip, .deb,
.rpm, 7z), GNU tar and the usual coreutils. It has no ar, dpkg, rpm2cpio
or unsquashfs.
Test it the way a system install runs it before opening a PR:
./scripts/check-apply-extra.sh com.example.App
It runs the real pinned download through your apply_extra.sh as root without
capabilities, offline. The update job runs the same check on every new upstream
release and holds back a pin that fails it.
Icon
Name it <id>.png or <id>.svg in the package directory, even if the manifest
installs it under another name: the site only picks up that name, and
audit-descriptor.mjs fails without it. A PNG is at most 512×512 (Flatpak’s
validator rejects larger); resize with ffmpeg or rsvg-convert from the SDK.
The wrapper
A short sh script installed as /app/bin/<command>. It sets environment
variables and flags the app needs in the sandbox, then execs the vendor’s
binary from /app/extra. Keep it to that: anything that writes files, patches
the payload or preloads code changes the packaging level and must be declared.
Say why each line is there in a comment; the next packager reads the wrapper
first.
The metainfo and the version
Flatpak has no version field for extra-data. The version lives in the
metainfo, as the newest <release version="…" date="…"> entry, and that is
what the updater compares against. A new package starts with one <release>
for the version it pins. The description is shown on the app’s page, so it is
also where opt-in flatpak override commands and any disclosed change go.
The rest of the metainfo:
- Say it is a community package: it repackages the official upstream build unmodified. A closed-source package that injects code says what it injects instead of “unmodified”.
project_licenseis the app’s real licence; for closed source,LicenseRef-proprietary=<URL of the vendor's terms>.- Screenshots are
<image>URLs pointing at upstream, never files in the package. The site downloads them at build time and serves them as WebP, falling back to the upstream URL if the download fails.
resolve-update.sh
Prints the latest release as JSON on stdout (logs go to stderr), and nothing else. It does no hashing and touches no file:
{
"version": "1.2.3",
"releaseDate": "2026-06-19",
"sources": [
{ "filename": "app.tar.gz", "url": "https://example.com/app-1.2.3-linux-x86_64.tar.gz" }
]
}
filenamemust match the one in the manifest.- For several arches, emit one source per arch with
"arch": "x86_64"or"arch": "aarch64". - If upstream re-uploads a fixed build under a new name (
1.2.3-2), put the revision inversion; otherwise the version compares equal and the new file is never pinned. - Optional:
sizeper source (re-pin when upstream silently replaces the file behind the same URL),releaseUrlandreleaseNotesfor the metainfo. - Match the asset name tightly (anchor it, include the arch and extension). A loose pattern picks whichever variant sorts first when upstream adds one.
- Use
${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"}on GitHub API calls; unauthenticated runs are rate-limited and fail.
Templates:
#!/usr/bin/env bash
# GitHub releases
set -euo pipefail
repo="owner/name"
rel="$(curl -fsSL ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \
"https://api.github.com/repos/$repo/releases/latest")"
version="$(jq -r '.tag_name | ltrimstr("v")' <<<"$rel")"
url="$(jq -r '.assets[] | select(.name | test("linux-x86_64\\.tar\\.gz$")) | .browser_download_url' <<<"$rel" | head -n1)"
date="$(jq -r '.published_at' <<<"$rel" | cut -c1-10)"
[ -n "$version" ] && [ -n "$url" ] || { echo "failed to resolve release" >&2; exit 1; }
jq -n --arg v "$version" --arg d "$date" --arg u "$url" \
'{version:$v, releaseDate:$d, sources:[{filename:"app.tar.gz", url:$u}]}'
#!/usr/bin/env bash
# A vendor endpoint that reports the version
set -euo pipefail
version="$(curl -fsSL https://vendor.example/latest.txt)"
jq -n --arg v "$version" \
'{version:$v, sources:[{filename:"app.deb", url:("https://vendor.example/app-"+$v+"_amd64.deb")}]}'
Run it locally with cd registry/<id> && ./resolve-update.sh and check that
the URL it prints downloads.
What an update changes
Two triggers run the same steps:
- Push: upstream’s release workflow calls FlatPark’s hook, and
release-dispatch.ymlhandles that one app within minutes. - Pull:
update-check.ymlruns every resolver twice a day (06:17 and 18:17 UTC) and catches everything else.
The steps:
- Run
resolve-update.sh. If itsversionequals the newest<release>in the metainfo, stop. scripts/update-pins.mjsdownloads every source, computes its SHA-256 and size, rewrites the managed block and prepends a<release>to the metainfo. These two files are the only ones an update touches.check-apply-extra.shunpacks the new download with the app’sapply_extra.sh. If that fails, the app keeps its old pin and an alert issue is opened.- A PR is opened and merged by CI without review, as long as the diff touches
only those two files (a resolver that writes anywhere else stops the merge).
publish.ymlthen rebuilds only the changed apps, signs the repo and uploads it. - Users get the update on their next
flatpak update, which downloads the new file and runsapply_extra.shagain.
debian-pins.yml re-pins the managed Debian blocks on its own schedule, because
Debian deletes a superseded package from its pool and new installs would fail
on the download while the app itself hasn’t changed.
Limits
What follows from extra-data, and what to do instead:
- No source builds. An official release makes them unnecessary, and they
would cost CI time and storage on every update. When an app people want has
no official Linux release at all, it can get a public
flatpark/<app>-releaserepository that builds it on GitHub Actions exactly the way upstream does, unmodified and within upstream’s licence, and publishes a release; the package pins that release as extra-data like any vendor download (com.ayugram.desktoppinsflatpark/ayugram-release). That needs a maintainer’s agreement first. - A library or tool the runtime lacks: a second extra-data download (an
upstream binary, a Debian package) or a prebuilt stack.
Don’t compile it into
/app. - The download must be a stable public URL with fixed bytes. A URL that always serves “the latest” changes under the pin and fails installs until it is re-pinned; resolve it to a versioned URL. A CDN that serves different bytes per region breaks installs in some regions.
- Nothing exported comes from the payload. Desktop entry, icon, metainfo and D-Bus service files are ours, written at build time.
- The payload runs unmodified. Open-source apps get nothing injected and no file changed. Closed-source apps may, when nothing else works, and the metainfo says what and why. See the packaging levels.
- One runtime major for the whole catalog. An app that only works on an older runtime is not listed until it works on the current one.