chore: bootstrap furtka-apps catalog repo
Initial layout: apps/fileshare/ (seeded from daniel/furtka apps/), CI (JSON + manifest validator + shellcheck), release pipeline (tag-driven, mirrors core repo), vendored manifest schema for offline validation. The core repo (daniel/furtka) at 26.6-alpha keeps apps/fileshare as a seed so offline first-boot still has an installable app; this catalog becomes authoritative once a box has synced at least once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
692a637a50
15 changed files with 758 additions and 0 deletions
34
.forgejo/workflows/ci.yml
Normal file
34
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate JSON files
|
||||
run: |
|
||||
set -e
|
||||
for f in $(find . -name '*.json' -not -path './node_modules/*'); do
|
||||
echo "Validating $f"
|
||||
python3 -m json.tool "$f" > /dev/null
|
||||
done
|
||||
|
||||
- name: Validate apps/ (manifest schema + compose shape)
|
||||
run: python3 scripts/validate-catalog.py
|
||||
|
||||
shellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install shellcheck
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck
|
||||
- name: Run shellcheck
|
||||
run: shellcheck scripts/*.sh
|
||||
28
.forgejo/workflows/release.yml
Normal file
28
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: Release
|
||||
|
||||
# Tag-triggered: when `git push origin <version>` lands, this builds the
|
||||
# catalog tarball and publishes it + the sha256 + release.json to the
|
||||
# Forgejo releases page for that tag. Running Furtka boxes pull from here
|
||||
# on their daily furtka-catalog-sync.timer.
|
||||
#
|
||||
# Version tags only (CalVer like 26.0, 26.1-alpha, 27.0-beta). Random tags
|
||||
# are ignored by the [0-9]* prefix.
|
||||
on:
|
||||
push:
|
||||
tags: ['[0-9]*']
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # changelog section extraction needs history
|
||||
|
||||
- name: Build catalog tarball
|
||||
run: ./scripts/build-catalog-tarball.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Publish to Forgejo releases
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||
run: ./scripts/publish-catalog-release.sh "${GITHUB_REF_NAME}"
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to the Furtka apps catalog will be documented here.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Versioning: CalVer (`YY.N`) — same scheme as the core Furtka repo.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.6-alpha] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- Initial catalog release. Carries one app: **fileshare** (v0.1.1, SMB
|
||||
share over `dperson/samba`). Copied from the `daniel/furtka` seed so
|
||||
boxes on 26.6 see the same fileshare bits whether they pull the
|
||||
catalog or fall back to the bundled seed.
|
||||
- Release pipeline + CI parity with the core repo:
|
||||
`scripts/build-catalog-tarball.sh`, `scripts/publish-catalog-release.sh`,
|
||||
`scripts/validate-catalog.py` (CI guardrail — loads every manifest via
|
||||
the vendored `furtka.manifest.load_manifest` + cross-checks compose
|
||||
volume references).
|
||||
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka-apps/compare/26.6-alpha...HEAD
|
||||
[26.6-alpha]: https://forgejo.sourcegate.online/daniel/furtka-apps/releases/tag/26.6-alpha
|
||||
70
README.md
Normal file
70
README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# furtka-apps
|
||||
|
||||
Apps catalog for [Furtka](https://furtka.org). Each release here ships a
|
||||
tarball that running Furtka boxes pull via the daily catalog-sync timer
|
||||
(see `furtka.catalog` in the core repo). Apps update on their own cadence,
|
||||
independent of Furtka's core OS release schedule.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
apps/ # one folder per app
|
||||
<name>/
|
||||
manifest.json # JSON schema — see ./apps/README.md
|
||||
docker-compose.yaml
|
||||
.env.example # if the app has user-facing settings
|
||||
icon.svg
|
||||
scripts/
|
||||
build-catalog-tarball.sh # bundle apps/ + VERSION into furtka-apps-<ver>.tar.gz
|
||||
publish-catalog-release.sh # upload tarball + sha256 + release.json to Forgejo
|
||||
validate-catalog.py # CI guardrail — manifest + compose syntax
|
||||
vendor/
|
||||
furtka_manifest.py # copy of daniel/furtka furtka/manifest.py
|
||||
.forgejo/workflows/
|
||||
ci.yml # JSON + manifest + shellcheck on every push/PR
|
||||
release.yml # tag push → build tarball → publish to Forgejo releases
|
||||
```
|
||||
|
||||
## Adding an app
|
||||
|
||||
See [apps/README.md](./apps/README.md) for the manifest schema, volume
|
||||
namespacing rules, `.env.example` guardrails, and the SVG sanitiser
|
||||
limits the Furtka UI enforces on `icon.svg`.
|
||||
|
||||
Shape of a new app PR:
|
||||
|
||||
1. Add `apps/<name>/` with all four files.
|
||||
2. `python3 scripts/validate-catalog.py` locally — must go green.
|
||||
3. Open a PR. CI runs the same validator + shellcheck.
|
||||
4. Merged to `main` — next catalog release will include it.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Follows the same CalVer + tag-driven pattern as daniel/furtka:
|
||||
|
||||
```bash
|
||||
# Bump CHANGELOG.md [Unreleased] → [<version>] section, commit:
|
||||
git commit -m "chore: release <version>"
|
||||
git tag -a <version> -m "Release <version>"
|
||||
git push origin main
|
||||
git push origin <version>
|
||||
```
|
||||
|
||||
Tag push fires `.forgejo/workflows/release.yml`. Needs
|
||||
`FORGEJO_RELEASE_TOKEN` secret (PAT with `write:repository` on this repo).
|
||||
|
||||
## Trust model
|
||||
|
||||
Releases are unsigned (SHA256 sidecar only). Boxes verify the sidecar
|
||||
against the downloaded tarball before extracting anything. Same posture
|
||||
as daniel/furtka core releases — PGP is a deferred cross-repo migration.
|
||||
|
||||
## Relationship to daniel/furtka
|
||||
|
||||
- The core repo still ships `apps/fileshare/` as a seed inside the
|
||||
Furtka release tarball. That's the offline/first-boot fallback.
|
||||
- When a box has synced the catalog at least once, the resolver
|
||||
(`furtka.sources.resolve_app_name`) prefers the catalog copy.
|
||||
- Already-installed apps don't auto-reinstall on catalog updates — user
|
||||
hits "Reinstall" in `/apps` when they want the new version. Settings
|
||||
are merge-preserved.
|
||||
113
apps/README.md
Normal file
113
apps/README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Building a Furtka app from a Docker image
|
||||
|
||||
A Furtka app is a folder with four files. The reconciler walks `/var/lib/furtka/apps/*` at boot, validates each manifest, ensures the declared volumes exist, and runs `docker compose up -d` per app. Filesystem is the only source of truth — no database.
|
||||
|
||||
Use `apps/fileshare/` as the reference implementation.
|
||||
|
||||
## Folder layout
|
||||
|
||||
```
|
||||
apps/<name>/
|
||||
manifest.json # required — app metadata and user-facing settings
|
||||
docker-compose.yaml # required — filename is .yaml, not .yml
|
||||
.env.example # required — keys consumed by docker-compose, with safe defaults
|
||||
icon.svg # required — referenced by manifest.icon
|
||||
```
|
||||
|
||||
The folder name must equal `manifest.name`. The scanner rejects mismatches.
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
All top-level fields except `description_long` and `settings` are required.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"display_name": "My App",
|
||||
"version": "0.1.0",
|
||||
"description": "One-line summary shown in the app list.",
|
||||
"description_long": "Longer German prose shown on the app page. Optional.",
|
||||
"volumes": ["data"],
|
||||
"ports": [8080],
|
||||
"icon": "icon.svg",
|
||||
"settings": [
|
||||
{
|
||||
"name": "ADMIN_PASSWORD",
|
||||
"label": "Passwort",
|
||||
"description": "Wird beim ersten Start gesetzt.",
|
||||
"type": "password",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules enforced by `furtka/manifest.py`:
|
||||
|
||||
- `volumes` — short names, strings. Namespaced to `furtka_<app>_<short>` at runtime.
|
||||
- `ports` — integers. Informational only; compose owns the actual port binding.
|
||||
- `settings[].name` — must match `^[A-Z_][A-Z0-9_]*$`. This name becomes both the env-var key and the form-field ID.
|
||||
- `settings[].type` — one of `text`, `password`, `number`.
|
||||
- `settings[].required` — if true, the install refuses when the value is empty.
|
||||
- `settings[].default` — optional string. Used to pre-fill the form and the bootstrapped `.env`.
|
||||
|
||||
## `docker-compose.yaml`
|
||||
|
||||
- File extension is `.yaml`. The compose runner hardcodes this — `.yml` will not be found.
|
||||
- Reference manifest volumes as `furtka_<app>_<short>` with `external: true`. The reconciler creates the volume *before* `compose up`, so compose must not try to manage its lifecycle.
|
||||
- Values from `.env` are substituted by compose in the usual `${VAR}` form.
|
||||
- If the upstream image ships a HEALTHCHECK that misbehaves on Furtka's setup, disable it — a permanently-unhealthy container scares users reading `docker ps`.
|
||||
- Pin images to a digest or stable tag when you can. `:latest` is acceptable for an MVP but noisy.
|
||||
|
||||
Minimal example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/example/myapp:1.2.3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- furtka_myapp_data:/var/lib/myapp
|
||||
|
||||
volumes:
|
||||
furtka_myapp_data:
|
||||
external: true
|
||||
```
|
||||
|
||||
## `.env.example`
|
||||
|
||||
One `KEY=VALUE` per line. Every key declared in `manifest.settings` should have a line here so the compose file resolves cleanly on first install even before the user opens the form.
|
||||
|
||||
Do not use `changeme` (or any value listed in `furtka.installer.PLACEHOLDER_SECRETS`) as the default for a required secret. The install step scans the final `.env` and refuses to finish if a placeholder survives — this is the guardrail that stops us shipping an app with a known password.
|
||||
|
||||
For non-secret values (usernames, paths), sensible defaults are fine and go straight into `.env` on first install.
|
||||
|
||||
## `icon.svg`
|
||||
|
||||
- 64×64 viewBox, no width/height attributes so the UI can scale it.
|
||||
- Use `fill="currentColor"` (and `stroke="currentColor"`) so the icon picks up the current theme instead of baking in a color.
|
||||
- Keep it single-path-ish. These render small in the app grid.
|
||||
- The icon is inlined into the `/apps` page by the defensive SVG sanitiser, which strips `<script>`, `on*` attributes, and `javascript:` refs and enforces a 16 KB cap. Anything fancier than static paths and shapes will be rejected.
|
||||
|
||||
## Install and test
|
||||
|
||||
From the repo root on a dev box with Furtka installed:
|
||||
|
||||
```
|
||||
sudo furtka app install ./apps/myapp
|
||||
```
|
||||
|
||||
`furtka app install` runs a reconcile as its last step, so the container is up once the command returns. Open the Web UI (`http://furtka.local/`), fill in the settings form, and confirm the app starts. `docker ps` should show one container per compose service; `docker volume ls` should show `furtka_myapp_*`.
|
||||
|
||||
To bundle the app into the ISO, drop the folder into `apps/` before `iso/build.sh` runs — the build tarballs the whole `apps/` tree into the image.
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- Sharing volumes between apps. v1 keeps them isolated.
|
||||
- Auth on the Web UI. The UI itself has a banner about this.
|
||||
- Automatic updates. User-triggered per-app update is `POST /api/apps/<name>/update`.
|
||||
- A network catalog. `furtka app install <name>` only resolves bundled apps in `/opt/furtka/apps/`.
|
||||
2
apps/fileshare/.env.example
Normal file
2
apps/fileshare/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SMB_USER=furtka
|
||||
SMB_PASSWORD=changeme
|
||||
39
apps/fileshare/docker-compose.yaml
Normal file
39
apps/fileshare/docker-compose.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Furtka fileshare — SMB share via dperson/samba.
|
||||
#
|
||||
# The volume `furtka_fileshare_files` is created by the Furtka reconciler
|
||||
# from the manifest's "volumes" list before this compose file is brought up;
|
||||
# it's referenced as `external: true` here so docker compose doesn't try
|
||||
# to manage its lifecycle.
|
||||
#
|
||||
# TODO(image-pin): `:latest` is shaky for production — pin to a digest
|
||||
# (`dperson/samba@sha256:...`) or a stable tag once we've verified one
|
||||
# against the upstream registry. For the MVP run we accept the drift
|
||||
# risk to keep the install reproducible against whatever the upstream
|
||||
# image happens to be on test day; revisit before any non-developer
|
||||
# touches this.
|
||||
|
||||
services:
|
||||
smbd:
|
||||
image: dperson/samba:latest
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# The upstream image's HEALTHCHECK times out under normal operation on
|
||||
# our setup (2026-04-15 VM test — all 6 probes failed while the share
|
||||
# was reachable from clients). Disable to avoid a permanently-"unhealthy"
|
||||
# container that scares users reading `docker ps`.
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- USERID=1000
|
||||
- GROUPID=1000
|
||||
- TZ=Europe/Berlin
|
||||
command: >
|
||||
-u "${SMB_USER};${SMB_PASSWORD}"
|
||||
-s "files;/mount;yes;no;no;${SMB_USER}"
|
||||
-p
|
||||
volumes:
|
||||
- furtka_fileshare_files:/mount
|
||||
|
||||
volumes:
|
||||
furtka_fileshare_files:
|
||||
external: true
|
||||
9
apps/fileshare/icon.svg
Normal file
9
apps/fileshare/icon.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<path fill="currentColor" opacity="0.28" d="M6 18 Q6 14 10 14 H22 L28 20 H54 Q58 20 58 24 V46 Q58 50 54 50 H10 Q6 50 6 46 Z"/>
|
||||
<path fill="currentColor" d="M6 28 Q6 24 10 24 H54 Q58 24 58 28 V46 Q58 50 54 50 H10 Q6 50 6 46 Z"/>
|
||||
<g fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M42 12 A10 10 0 0 1 58 22"/>
|
||||
<path d="M46 14 A6 6 0 0 1 56 22" opacity="0.75"/>
|
||||
</g>
|
||||
<circle cx="51" cy="19" r="1.8" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
27
apps/fileshare/manifest.json
Normal file
27
apps/fileshare/manifest.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "fileshare",
|
||||
"display_name": "Network Files",
|
||||
"version": "0.1.1",
|
||||
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
|
||||
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
|
||||
"volumes": ["files"],
|
||||
"ports": [445, 139],
|
||||
"icon": "icon.svg",
|
||||
"settings": [
|
||||
{
|
||||
"name": "SMB_USER",
|
||||
"label": "Benutzername",
|
||||
"description": "Der Name, mit dem sich Geräte am Share anmelden.",
|
||||
"type": "text",
|
||||
"default": "furtka",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "SMB_PASSWORD",
|
||||
"label": "Passwort",
|
||||
"description": "Mindestens 8 Zeichen. Wird nie angezeigt — auch dir nicht.",
|
||||
"type": "password",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
47
scripts/build-catalog-tarball.sh
Executable file
47
scripts/build-catalog-tarball.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build a Furtka apps-catalog release tarball + sha256 sidecar + release.json.
|
||||
#
|
||||
# Usage: ./scripts/build-catalog-tarball.sh <version>
|
||||
#
|
||||
# Produces (in ./dist/):
|
||||
# furtka-apps-<version>.tar.gz contents extract to /var/lib/furtka/catalog/
|
||||
# furtka-apps-<version>.tar.gz.sha256 single-line sha256 (<hash> <name>)
|
||||
# release.json {"version","sha256","size","created_at"}
|
||||
#
|
||||
# Tarball shape: VERSION at root + apps/<name>/ trees underneath. The on-box
|
||||
# `furtka catalog sync` extracts into a staging dir, validates every
|
||||
# manifest, then atomically renames into /var/lib/furtka/catalog/.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: $0 <version>}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DIST_DIR="$REPO_ROOT/dist"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
cp -a "$REPO_ROOT/apps" "$STAGE/"
|
||||
find "$STAGE" -type d -name __pycache__ -exec rm -rf {} +
|
||||
echo "$VERSION" > "$STAGE/VERSION"
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
TARBALL="$DIST_DIR/furtka-apps-$VERSION.tar.gz"
|
||||
tar -czf "$TARBALL" -C "$STAGE" .
|
||||
|
||||
SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
SIZE=$(stat -c%s "$TARBALL")
|
||||
|
||||
printf '%s %s\n' "$SHA" "$(basename "$TARBALL")" > "$TARBALL.sha256"
|
||||
|
||||
cat > "$DIST_DIR/release.json" <<EOF
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"sha256": "$SHA",
|
||||
"size": $SIZE,
|
||||
"created_at": "$(date -Iseconds)"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Built $TARBALL"
|
||||
echo " sha256: $SHA"
|
||||
echo " size: $SIZE bytes"
|
||||
96
scripts/publish-catalog-release.sh
Executable file
96
scripts/publish-catalog-release.sh
Executable file
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env bash
|
||||
# Publish a Furtka apps-catalog release to the Forgejo releases page.
|
||||
#
|
||||
# Usage: ./scripts/publish-catalog-release.sh <version>
|
||||
#
|
||||
# Preconditions:
|
||||
# - $FORGEJO_TOKEN set (PAT with write:repository on daniel/furtka-apps)
|
||||
# - dist/furtka-apps-<version>.tar.gz + .sha256 + release.json already built
|
||||
#
|
||||
# Behaviour: mirrors daniel/furtka's scripts/publish-release.sh — create a
|
||||
# release via the Forgejo API, then upload all three assets sequentially.
|
||||
# Python for JSON assembly so we don't drag jq onto the runner.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: $0 <version>}"
|
||||
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN must be set}"
|
||||
|
||||
HOST="${FORGEJO_HOST:-forgejo.sourcegate.online}"
|
||||
REPO="${FORGEJO_REPO:-daniel/furtka-apps}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DIST_DIR="$REPO_ROOT/dist"
|
||||
TARBALL="$DIST_DIR/furtka-apps-$VERSION.tar.gz"
|
||||
SHA_FILE="$TARBALL.sha256"
|
||||
RELEASE_JSON="$DIST_DIR/release.json"
|
||||
|
||||
for f in "$TARBALL" "$SHA_FILE" "$RELEASE_JSON"; do
|
||||
[ -f "$f" ] || { echo "missing: $f"; exit 1; }
|
||||
done
|
||||
|
||||
# Extract the changelog section for this version from CHANGELOG.md — matches
|
||||
# `## [<version>]` up to (but not including) the next `## [` line.
|
||||
BODY="$(awk -v v="$VERSION" '
|
||||
BEGIN { inside=0 }
|
||||
/^## \[/ {
|
||||
if (inside) exit
|
||||
if (index($0, "[" v "]") > 0) { inside=1; next }
|
||||
}
|
||||
inside { print }
|
||||
' "$REPO_ROOT/CHANGELOG.md")"
|
||||
if [ -z "$BODY" ]; then
|
||||
BODY="Catalog release $VERSION"
|
||||
fi
|
||||
|
||||
PRERELEASE=false
|
||||
if [[ "$VERSION" == *-alpha* || "$VERSION" == *-beta* || "$VERSION" == *-rc* ]]; then
|
||||
PRERELEASE=true
|
||||
fi
|
||||
|
||||
api() {
|
||||
curl --silent --show-error --fail-with-body \
|
||||
--header "Authorization: token $FORGEJO_TOKEN" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
base="https://$HOST/api/v1/repos/$REPO"
|
||||
|
||||
release_body_json="$(
|
||||
VERSION="$VERSION" BODY="$BODY" PRE="$PRERELEASE" python3 -c '
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
"tag_name": os.environ["VERSION"],
|
||||
"name": os.environ["VERSION"],
|
||||
"body": os.environ["BODY"],
|
||||
"prerelease": os.environ["PRE"] == "true",
|
||||
}))
|
||||
'
|
||||
)"
|
||||
|
||||
echo "==> Creating catalog release $VERSION"
|
||||
release_response="$(api --request POST "$base/releases" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "$release_body_json")"
|
||||
|
||||
release_id="$(echo "$release_response" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("id", ""))')"
|
||||
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
|
||||
echo "error: couldn't parse release id from response:"
|
||||
echo "$release_response"
|
||||
exit 1
|
||||
fi
|
||||
echo " release id: $release_id"
|
||||
|
||||
upload_asset() {
|
||||
local path="$1"
|
||||
local name
|
||||
name="$(basename "$path")"
|
||||
echo "==> Uploading $name"
|
||||
api --request POST "$base/releases/$release_id/assets?name=$name" \
|
||||
--form "attachment=@$path" > /dev/null
|
||||
}
|
||||
|
||||
upload_asset "$TARBALL"
|
||||
upload_asset "$SHA_FILE"
|
||||
upload_asset "$RELEASE_JSON"
|
||||
|
||||
echo "Catalog release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"
|
||||
124
scripts/validate-catalog.py
Executable file
124
scripts/validate-catalog.py
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""CI guardrail: validate every app in ./apps/ before cutting a release.
|
||||
|
||||
For each `apps/<name>/`:
|
||||
- manifest.json must parse via furtka.manifest.load_manifest (same schema
|
||||
the on-box reconciler expects — fields, settings rules, icon name).
|
||||
- docker-compose.yaml must exist and reference every manifest-declared
|
||||
volume as `external: true` (the reconciler creates the volumes; compose
|
||||
must not try to manage their lifecycle).
|
||||
- `docker compose config` must parse the compose file (catches YAML typos
|
||||
+ missing env refs).
|
||||
|
||||
The `furtka` package is vendored in `scripts/vendor/` — lifted verbatim from
|
||||
daniel/furtka's `furtka/manifest.py`. Keeping the copy local avoids dragging
|
||||
the core repo's whole pyproject into this repo's CI. If the core manifest
|
||||
schema evolves we bump the vendored copy with a conventional commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "vendor"))
|
||||
|
||||
from furtka_manifest import ManifestError, load_manifest # noqa: E402
|
||||
|
||||
APPS_ROOT = HERE.parent / "apps"
|
||||
|
||||
|
||||
def check_app(app_dir: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
manifest_path = app_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return [f"{app_dir.name}: manifest.json missing"]
|
||||
|
||||
try:
|
||||
m = load_manifest(manifest_path, expected_name=app_dir.name)
|
||||
except ManifestError as e:
|
||||
return [f"{app_dir.name}: invalid manifest: {e}"]
|
||||
|
||||
compose_path = app_dir / "docker-compose.yaml"
|
||||
if not compose_path.is_file():
|
||||
errors.append(f"{app_dir.name}: docker-compose.yaml missing")
|
||||
return errors
|
||||
|
||||
compose_text = compose_path.read_text()
|
||||
# Cheap text search — pyyaml would be cleaner but we stay stdlib-only.
|
||||
# The reconciler also works on a text basis: volume must be referenced
|
||||
# at all, and must carry `external: true` in its definition block.
|
||||
for volume in m.volumes:
|
||||
namespaced = f"furtka_{m.name}_{volume}"
|
||||
if namespaced not in compose_text:
|
||||
errors.append(
|
||||
f"{app_dir.name}: docker-compose.yaml doesn't reference namespaced "
|
||||
f"volume {namespaced!r}"
|
||||
)
|
||||
# very forgiving pattern — accept `external: true` anywhere after the
|
||||
# namespaced name appears. A more rigorous YAML walk can come later.
|
||||
tail = compose_text.split(namespaced, 1)[-1]
|
||||
if "external: true" not in tail and "external:\n true" not in tail:
|
||||
errors.append(
|
||||
f"{app_dir.name}: volume {namespaced!r} must be declared with "
|
||||
f"external: true (reconciler creates the volume before compose up)"
|
||||
)
|
||||
|
||||
# `docker compose config` parses the file and resolves env substitutions.
|
||||
# Skip the check if docker isn't installed in the environment — locally
|
||||
# that's fine, and CI images should have it.
|
||||
docker = subprocess.run(
|
||||
["which", "docker"], capture_output=True, text=True, check=False
|
||||
)
|
||||
if docker.returncode == 0:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-f", str(compose_path), "config"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=app_dir,
|
||||
# Mask any env-var substitution errors — we don't have the real
|
||||
# .env on a catalog validation pass; `docker compose config`
|
||||
# returns the stderr line "WARN[0000] The X variable is not set"
|
||||
# which is fine for syntax check.
|
||||
)
|
||||
if result.returncode != 0:
|
||||
errors.append(
|
||||
f"{app_dir.name}: `docker compose config` failed:\n"
|
||||
+ (result.stderr or result.stdout).strip()
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not APPS_ROOT.is_dir():
|
||||
print(f"no apps/ directory at {APPS_ROOT}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
all_errors: list[str] = []
|
||||
apps = sorted(p for p in APPS_ROOT.iterdir() if p.is_dir())
|
||||
if not apps:
|
||||
print("no apps to validate", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
for app_dir in apps:
|
||||
errors = check_app(app_dir)
|
||||
if errors:
|
||||
all_errors.extend(errors)
|
||||
else:
|
||||
print(f"OK {app_dir.name}")
|
||||
|
||||
if all_errors:
|
||||
print("\nFAIL:")
|
||||
for e in all_errors:
|
||||
print(f" - {e}")
|
||||
return 1
|
||||
print(f"\nValidated {len(apps)} app(s).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1
scripts/vendor/README.md
vendored
Normal file
1
scripts/vendor/README.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
# vendored from daniel/furtka furtka/manifest.py — bump on schema changes
|
||||
140
scripts/vendor/furtka_manifest.py
vendored
Normal file
140
scripts/vendor/furtka_manifest.py
vendored
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"name",
|
||||
"display_name",
|
||||
"version",
|
||||
"description",
|
||||
"volumes",
|
||||
"ports",
|
||||
"icon",
|
||||
)
|
||||
|
||||
VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
|
||||
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
||||
|
||||
|
||||
class ManifestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Setting:
|
||||
name: str # env-var name, e.g. SMB_PASSWORD
|
||||
label: str # human label shown in the UI
|
||||
description: str # one-sentence help text under the input
|
||||
type: str # "text" | "password" | "number"
|
||||
required: bool
|
||||
default: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
name: str
|
||||
display_name: str
|
||||
version: str
|
||||
description: str
|
||||
volumes: tuple[str, ...]
|
||||
ports: tuple[int, ...]
|
||||
icon: str
|
||||
description_long: str = ""
|
||||
settings: tuple[Setting, ...] = field(default_factory=tuple)
|
||||
|
||||
def volume_name(self, short: str) -> str:
|
||||
# Namespace volume names so two apps can each declare e.g. "data"
|
||||
# without colliding in `docker volume ls`.
|
||||
if short not in self.volumes:
|
||||
raise ManifestError(f"{self.name}: volume {short!r} not declared in manifest")
|
||||
return f"furtka_{self.name}_{short}"
|
||||
|
||||
|
||||
def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
|
||||
if raw is None:
|
||||
return ()
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(f"{manifest_path}: settings must be a list")
|
||||
out: list[Setting] = []
|
||||
seen: set[str] = set()
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ManifestError(f"{manifest_path}: settings[{i}] must be an object")
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not SETTING_NAME_RE.match(name):
|
||||
raise ManifestError(
|
||||
f"{manifest_path}: settings[{i}].name must be an UPPER_SNAKE_CASE env-var name"
|
||||
)
|
||||
if name in seen:
|
||||
raise ManifestError(f"{manifest_path}: settings has duplicate name {name!r}")
|
||||
seen.add(name)
|
||||
label = item.get("label", name)
|
||||
description = item.get("description", "")
|
||||
type_ = item.get("type", "text")
|
||||
if type_ not in VALID_SETTING_TYPES:
|
||||
valid = sorted(VALID_SETTING_TYPES)
|
||||
raise ManifestError(f"{manifest_path}: settings[{name}].type must be one of {valid}")
|
||||
required = bool(item.get("required", False))
|
||||
default = item.get("default")
|
||||
if default is not None and not isinstance(default, str):
|
||||
default = str(default)
|
||||
out.append(
|
||||
Setting(
|
||||
name=name,
|
||||
label=str(label),
|
||||
description=str(description),
|
||||
type=type_,
|
||||
required=required,
|
||||
default=default,
|
||||
)
|
||||
)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
||||
"""Parse and validate a manifest.json.
|
||||
|
||||
`expected_name` is used by the scanner (where the install location's folder
|
||||
name IS the source of truth and must match the manifest). For loading from
|
||||
arbitrary source folders during install, leave it None — the manifest's own
|
||||
`name` field decides the install target.
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
raise ManifestError(f"{path}: invalid JSON: {e}") from e
|
||||
if not isinstance(raw, dict):
|
||||
raise ManifestError(f"{path}: top-level must be an object")
|
||||
|
||||
missing = [f for f in REQUIRED_FIELDS if f not in raw]
|
||||
if missing:
|
||||
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
|
||||
|
||||
name = raw["name"]
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ManifestError(f"{path}: name must be a non-empty string")
|
||||
if expected_name is not None and name != expected_name:
|
||||
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")
|
||||
|
||||
volumes = raw["volumes"]
|
||||
if not isinstance(volumes, list) or not all(isinstance(v, str) and v for v in volumes):
|
||||
raise ManifestError(f"{path}: volumes must be a list of non-empty strings")
|
||||
|
||||
ports = raw["ports"]
|
||||
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
|
||||
raise ManifestError(f"{path}: ports must be a list of integers")
|
||||
|
||||
settings = _parse_settings(raw.get("settings"), path)
|
||||
|
||||
return Manifest(
|
||||
name=name,
|
||||
display_name=str(raw["display_name"]),
|
||||
version=str(raw["version"]),
|
||||
description=str(raw["description"]),
|
||||
volumes=tuple(volumes),
|
||||
ports=tuple(ports),
|
||||
icon=str(raw["icon"]),
|
||||
description_long=str(raw.get("description_long", "")),
|
||||
settings=settings,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue