Merge pull request #7239 from haranishi/review/packaged-cli-docs-20261018

Linux packages: ship the CLI in source Flatpaks and offline automation guides
This commit is contained in:
Tanish Manku authored and GitHub committed 2026-11-10 23:58:52 -07:00
commit e5cffe2375
5 files changed
+175 -3

No files matched your search

+4 -2
View File
@@ -5,9 +5,9 @@ name: Packaging lint
on:
push:
branches: [main, release]
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**", "docs/mcp.md", "docs/control-protocol.md"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**", "docs/mcp.md", "docs/control-protocol.md"]
workflow_dispatch:
permissions:
@@ -21,6 +21,8 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/linux/package-docs-test.sh
verified-appimage-inputs:
timeout-minutes: 10
+42
View File
@@ -302,6 +302,48 @@ one is missing, prints the package to install and exits with status 1 instead of
Locally (on Linux): install [nfpm](https://nfpm.goreleaser.com/install/), then
`packaging/linux/package.sh` (or `--formats "deb tar"`).
#### Packaged automation and offline documentation
Both Flatpak manifests install `photocraft-cli`, alongside the GUI. To start the stdio MCP
server with a project in the sandbox's allowed Documents directory:
```sh
flatpak run --command=photocraft-cli ai.storyteller.photocraft mcp \
--automation-read-root "$HOME/Documents/project" \
--automation-write-root "$HOME/Documents/project"
```
The automation roots restrict PhotoCraft's file access; they do not grant Flatpak access to
other host directories. For a project elsewhere, also grant that directory with Flatpak's
`--filesystem=/absolute/project` run option. Stdio MCP needs no network permission.
Linux packages include the original `mcp.md` and `control-protocol.md` for offline use:
| Format | Documentation directory |
|---|---|
| `.deb`, `.rpm` | `/usr/share/doc/photocraft/` |
| Extracted `.tar.gz` | `share/doc/photocraft/` inside the extracted release directory |
| Flatpak (source or bundle) | `/app/share/doc/photocraft/` inside the sandbox |
| Extracted AppImage | `squashfs-root/usr/share/doc/photocraft/` |
For example, read the Flatpak's MCP guide without opening the GUI:
```sh
flatpak run --command=cat ai.storyteller.photocraft /app/share/doc/photocraft/mcp.md
```
To read an AppImage's guides, run `./PhotoCraft.AppImage --appimage-extract` in a directory
where you want its `squashfs-root` contents, using the downloaded file's actual name. Then
open either guide in `squashfs-root/usr/share/doc/photocraft/`. Extraction is an explicit user
action; launching the app does not extract documentation into the home directory. These two
Markdown files are included as written; links to other repository documents still need a
checkout or the online documentation.
`bash packaging/linux/package-docs-test.sh` checks the real Linux staging/tar commands,
AppDir retention and both Flatpak manifests' install commands with fixture binaries and
stubbed AppImage tooling. Packaging lint runs it through `extra-checks-script`; it does not
compile Rust, build a real AppImage, or run a Flatpak sandbox.
### FreeBSD
GitHub has no FreeBSD runners, so the `freebsd` job runs `packaging/freebsd/package.sh` in a
@@ -101,8 +101,11 @@ modules:
CRAFT_I18N_DIR: /run/build/photocraft/craft-i18n
CRAFT_I18N_REQUIRED: '1'
build-commands:
- cargo --offline build --release --locked -p photocraft --features heif
- cargo --offline build --release --locked -p photocraft -p photocraft-cli --features heif
- install -Dm755 target/release/photocraft /app/bin/photocraft
- install -Dm755 target/release/photocraft-cli /app/bin/photocraft-cli
- install -Dm644 docs/mcp.md /app/share/doc/photocraft/mcp.md
- install -Dm644 docs/control-protocol.md /app/share/doc/photocraft/control-protocol.md
- install -Dm644 NOTICE /app/share/doc/photocraft/NOTICE
- install -Dm644 ATTRIBUTION.md /app/share/doc/photocraft/ATTRIBUTION.md
- install -Dm644 packaging/linux/ai.storyteller.photocraft.desktop /app/share/applications/ai.storyteller.photocraft.desktop
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Exercise Linux staging and both Flatpak manifests without building Rust or an AppImage.
# Needs the normal Linux packaging tools, Python 3 and PyYAML (packaging-lint installs them).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
python3 - "$ROOT" <<'PY'
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import yaml
root = Path(sys.argv[1])
docs = ("mcp.md", "control-protocol.md")
def run(command, cwd, env):
result = subprocess.run(command, cwd=cwd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if result.returncode:
raise AssertionError(f"{command!r} failed ({result.returncode}):\n{result.stdout}")
def assert_docs(directory):
for name in docs:
path = directory / name
assert path.is_file(), f"missing packaged document: {path}"
assert path.read_bytes() == (root / "docs" / name).read_bytes(), f"changed packaged document: {path}"
with tempfile.TemporaryDirectory(prefix="photocraft-package-docs-") as temporary:
temp = Path(temporary)
fixture = temp / "source with spaces"
fixture.mkdir()
# Keep the real scripts, metadata, icons and document bytes. Only replace the binary
# build and AppImage download/tool boundary; no real tool or runtime is downloaded.
for name in ("packaging", "assets/app-icon/hicolor", "docs/mcp.md", "docs/control-protocol.md", "Cargo.toml", "README.md", "LICENSE", "LICENSE-MIT", "LICENSE-APACHE", "COPYRIGHT", "NOTICE", "ATTRIBUTION.md"):
source, destination = root / name, fixture / name
if not source.exists():
continue
destination.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, destination)
else:
shutil.copy2(source, destination)
binary = "#!/usr/bin/env bash\nprintf 'PhotoCraft packaging fixture\\n'\n"
target = fixture / "target"
for name in ("photocraft", "photocraft-cli"):
path = target / "release" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(binary)
path.chmod(0o755)
(fixture / "packaging/linux/verified-download.sh").write_text('''
download_verified() {
if [ "${4:-false}" = true ]; then
cat >"$2" <<'TOOL'
#!/usr/bin/env bash
set -euo pipefail
# Stand in for appimagetool's output, leaving the real AppDir for assertions below.
touch "${@: -1}"
TOOL
chmod 755 "$2"
else
printf 'fixture runtime\\n' >"$2"
fi
}
verify_appimagetool_override() { return 1; }
''')
env = os.environ.copy()
for key in ("CROSS_TARGET", "CROSS_COMPILE", "EMULATOR", "APPIMAGETOOL", "CRAFT_FONTS_DIR"):
env.pop(key, None)
env.update(CARGO_TARGET_DIR=str(target), DIST=str(temp / "dist"), CROSS_ARCH="x86_64", PHOTOCRAFT_BUILD_DATE="2026-01-01")
run(["bash", "packaging/linux/package.sh", "--skip-build", "--formats", "tar appimage"], fixture, env)
assert_docs(target / "linux-package/root/usr/share/doc/photocraft")
assert_docs(target / "linux-package/PhotoCraft.AppDir/usr/share/doc/photocraft")
archives = list((temp / "dist").glob("*.tar.gz"))
assert len(archives) == 1, f"expected one tarball, found {archives}"
unpacked = temp / "unpacked"
unpacked.mkdir()
run(["tar", "-xzf", str(archives[0]), "-C", str(unpacked)], fixture, env)
bundle = next(unpacked.iterdir())
assert_docs(bundle / "share/doc/photocraft")
assert (bundle / "bin/photocraft-cli").read_text() == binary
print("PASS: FHS stage, tarball and AppDir retain both protocol documents")
# The source manifest must request both packages; a missing -p photocraft-cli leaves
# no CLI for its real install command to copy. Do not seed its build with staged bins.
shutil.rmtree(target)
tools = temp / "tools"
tools.mkdir()
cargo = tools / "cargo"
cargo.write_text('''#!/usr/bin/env python3
from pathlib import Path
import sys
args = sys.argv[1:]
for index, arg in enumerate(args[:-1]):
if arg == "-p":
path = Path("target/release") / args[index + 1]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/usr/bin/env bash\\nprintf 'PhotoCraft packaging fixture\\\\n'\\n")
path.chmod(0o755)
''')
cargo.chmod(0o755)
env["PATH"] = str(tools) + os.pathsep + env["PATH"]
for suffix, directory in ((".yml", fixture), (".bundle.yml", bundle)):
manifest = yaml.safe_load((root / f"packaging/linux/flatpak/ai.storyteller.photocraft{suffix}").read_text())
app = temp / ("flatpak-source" if suffix == ".yml" else "flatpak-bundle")
# Other modules, such as cups-client, build their own sources; only photocraft is checked here.
module = next(m for m in manifest["modules"] if isinstance(m, dict) and m.get("name") == "photocraft")
for command in module["build-commands"]:
# Redirect only the sandbox output root, preserving the manifest's commands.
command = command.replace("/app/", shlex.quote(str(app)) + "/")
run(["bash", "-euo", "pipefail", "-c", command], directory, env)
cli = app / "bin/photocraft-cli"
assert cli.is_file() and os.access(cli, os.X_OK), f"missing executable CLI in {suffix}"
assert cli.read_text() == binary, f"changed CLI bytes in {suffix}"
assert_docs(app / "share/doc/photocraft")
print(f"PASS: Flatpak {suffix} build commands install the CLI and both protocol documents")
PY
+2
View File
@@ -78,6 +78,8 @@ mkdir -p "$STAGE/usr/share/icons"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/"
mkdir -p "$STAGE/usr/share/doc/photocraft"
copy_docs "$STAGE/usr/share/doc/photocraft"
install -Dm644 "$ROOT/docs/mcp.md" "$STAGE/usr/share/doc/photocraft/mcp.md"
install -Dm644 "$ROOT/docs/control-protocol.md" "$STAGE/usr/share/doc/photocraft/control-protocol.md"
# photocraft-cli's man pages and shell completions, from its clap definition, written by the
# example built above. A cross build runs it through $EMULATOR, like the --version check at the
# end, and leaves them out without one, as a --skip-build run whose prebuilt binaries came without