diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 6848088d37..86619a3ad6 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index 91d9652a3b..b1c030a02b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index 08ae4bd3f0..90d07dca77 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -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 diff --git a/packaging/linux/package-docs-test.sh b/packaging/linux/package-docs-test.sh new file mode 100755 index 0000000000..52c79285ff --- /dev/null +++ b/packaging/linux/package-docs-test.sh @@ -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 diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 519bc850c6..8ddc9b8e02 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -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