Ludus game packaging and itch.io publishing¶
Status: architecture contract, originally written 2026-10-02 before implementation. The current native packaging guide and editor/browser/upload guide own usage instructions and implemented limits. Proposed extensions below retain their original scope. This document extends the project and SDK workflow and its implementation design. It does not change that milestone's acceptance scope or certify shipped tooling.
A game project should own its release configuration and optional CI workflow. Installed Ludus host tools should build and validate distributable packages, then upload those exact packages through butler. The Editor and CI use the same operations as the CLI. Packaging works without an itch.io account, network, Qt or an engine source checkout once required tools and SDKs are installed.
The first implementation is described in the native release guide and evidence ledger. Those records distinguish implemented CLI operations from pending production SDK, transport, CI, Editor and browser acceptance.
Scope and prerequisites¶
First deliver a Linux x64 Release package and explicit itch.io upload for an independent native game. Add generated GitHub Actions, then Editor integration. Browser publishing follows when independent web project creation and web SDK resolution are available; preserve the existing browser smoke workflow meanwhile. Windows, macOS, other CI providers and stores are later adapters with separate acceptance matrices.
Project/SDK milestones P2 and P3 provide installed host tools, project creation, locked SDK resolution and shared build supervision. Native packaging also needs P1's dependency and license inventory. Editor creation integration depends on P4. Do not require all Editor work before delivering a useful headless packager. General asset importing, store page creation, page visibility changes, pricing, devlogs, game self-updates and game-jam submissions are outside this milestone. Uploading a build does not submit a game to a jam.
Existing code to inspect and reuse:
scripts/python/web_package.pyandtest_web_package.py: deterministic smoke staging, archive extraction checks, browser limits and notices. The current fixed payload and Wasm memory budget belong to the smoke app.scripts/python/editor_project.py,editor_tool.py,editor_launch.pyandcmake_targets.py: descriptors, operation protocol, supervision and artifact discovery. Reuse their installed successors from the SDK workflow.apps/editor: asynchronous state ownership, cancellation, bounded output and stale-event handling.- Web packaging and hosted acceptance: current smoke behavior and remaining real-device gates. Their account-specific upload destination must never become a generated-project default.
Ownership and operation boundary¶
| Owner | Responsibility |
|---|---|
| Game repository | CMake build/install definitions, assets, release profiles, destination mappings, optional generated workflow |
| Runtime SDK | Game link inputs, redistributable dependency metadata and license notices |
| Installed host tools | Release planning, building, staging, validation, archive creation, package verification and upload supervision |
| Editor | Forms, package inspection, progress and explicit user actions through the shared backend |
| butler | itch.io authentication and transport to a selected project/channel |
| CI platform | Runner provisioning, secret storage, artifact retention and deployment scheduling |
Initial backend implementation remains Python, following the project/SDK design.
Suggested installed modules are release_model, release_plan, package_native,
package_web, package_verify, publish_itch and release_receipt. These names
are organizational guidance, not a second CLI or process supervisor. Only
publish_itch knows butler's command contract. A future provider must justify
extracting a generic publishing interface; do not build a plugin framework now.
C++ additions remain exception-free and obey AGENTS.md and steering. No network, butler or authoring dependency enters runtime exports or FoundationBase. The Editor does not load game libraries into its process. All external invocations use argument arrays and explicit working directories, never shell interpolation.
flowchart LR
E[Editor] --> O[Shared host operations]
C[CLI] --> O
G[Generated CI] --> O
O --> B[Locked SDK and CMake build]
B --> S[Private release staging]
S --> V[Package and extraction validation]
V --> A[Immutable archive and manifest]
A --> T[Tests of the exact package]
T --> U[Verify package and destination]
U --> I[butler push]
I --> R[Local upload receipt]
Files and configuration contracts¶
Use separate release configuration so packaging can evolve without changing the project descriptor merely to support a store. A version-2 project is initially required; version-1 projects retain their existing behavior and receive an explicit migration message when invoking the new operations.
MyGame/
ludus.project.json
ludus.lock.json
ludus.release.json
CMakeLists.txt
CMakePresets.json
cmake/GameRelease.cmake
assets/
.github/workflows/itch-upload.yml optional
.ludus/ ignored local settings and locks
out/packages/ ignored immutable package directories
out/publish/ ignored receipts and redacted logs
Example proposed ludus.release.json schema version 1:
{
"schemaVersion": 1,
"profiles": {
"linux-release": {
"targetPlatform": "linux-x64",
"buildProfile": "release",
"target": "MyGame",
"installComponent": "GameRelease",
"entryPoint": "bin/MyGame"
}
},
"itch": {
"target": "yourname/mygame",
"channels": {
"linux": { "packageProfile": "linux-release", "channel": "linux-stable" }
}
},
"automation": {
"provider": "github-actions",
"releaseTags": false
}
}
profiles is required; itch and automation are optional. The target is an
existing CMake executable target, resolved through the CMake File API. A profile
selects the supported target/flavor from the engine lock, not an arbitrary preset
command. CMake remains authoritative for which files install. An install
component is release payload selection, not permission to copy the whole build.
entryPoint is a package-relative path validated against the final payload.
The parser rejects unknown schema versions, duplicate JSON keys and unknown
fields. Version 1 limits: 256 KiB UTF-8 configuration, 32 profiles, 32 channel
mappings, 64 characters for local identifiers, 128 characters for the itch.io
target and 240 characters for payload-relative paths. Local identifiers and
channels use [a-z0-9][a-z0-9-]*; target uses two nonempty lower-case account/slug
segments, allowing digits, hyphens and underscores, separated by one slash.
These are deliberately conservative tool constraints, not a claim about every
identifier accepted by itch.io. Reject control characters, absolute paths,
.., drive prefixes and backslashes in portable payload paths. Publish shared
fixtures for C++/Python verdicts; diagnostics identify the failing field.
Keep engine versions and digests in ludus.lock.json. Release configuration does
not duplicate SDK identity. Host tools distribute an exact butler version and
per-host archive checksums in their tool manifest; generated CI pins a compatible
host-tool release and verifies downloaded distributions. Never download a
floating latest butler during upload. Tool installation/update is a separate
explicit operation. Local absolute tool paths and credential identity selection
belong in ignored settings. Generated files become game-owned after creation.
Native release packaging¶
Add a project CMake install component GameRelease. It installs the selected
executable, authored runtime assets, required redistributable dynamic libraries
and notices. A generated cmake/GameRelease.cmake documents this contract and
provides editable install rules. Initially require exactly one playable entry
point per package; supporting launchers or several executables is later work.
The packager performs these steps under the existing cooperative build lock:
- Validate configuration and resolve the exact Release SDK/toolchain. Configure and build the selected target successfully. Re-resolve its artifact and verify SDK inputs did not change. Never package a stale binary after a failed build.
- Create an empty private staging directory. Invoke CMake installation with the selected component/configuration and an explicit staging prefix. Game install code executes as part of this explicit operation, with the user's normal privileges. Opening or inspecting a project never executes it.
- Inspect every staged entry. Reject symlinks, special files, path collisions
including case-fold collisions, escaping paths and missing entry points.
Require an executable mode on the native entry point. Validate dynamic
dependency closure using static binary inspection, without executing
lddon project binaries. Resolve release libraries from declared SDK metadata and game install rules; never sweep a producer system's library directories. - Require notices for bundled dependencies and a declared system prerequisite list. Keep platform GPU drivers and the supported OS baseline external. Validate loader paths such as package-relative RPATHs. Reject producer paths, SDK headers, static link archives, editor/tool binaries and debug sidecars in the player payload. A separate debug archive can be added later.
- Write a payload manifest, create a ZIP, extract into a new empty directory and verify paths, bytes, modes, dependency rules and entry point again. Tests use this extracted copy. Publish the completed package directory atomically to an absent destination; failure leaves older packages intact.
Use explicit file installation instead of recursive copies of build/source roots. CMake install rules can run arbitrary project code; containment validation is validation of the resulting payload, not a sandbox for that code. Initial native limits are 10,000 entries, 30,000,000,000 expanded bytes and paths of at most 240 characters. Stream hashes and enforce bounds while extracting. Preserve executability in ZIP metadata and prove it survives installation with itch.
Browser release packaging¶
A future web-release adapter uses a separately locked Emscripten SDK and its
pinned WebGPU port. The native host tools and Editor stay native. Stage an
index.html at ZIP root, its JS/Wasm and every required data/asset file using an
explicit project install manifest. Preserve relative, case-matching references;
external runtime URLs are unsupported in the initial adapter.
Generalize the existing smoke validator instead of copying it. Wasm initial and maximum memory are profile policy validated against actual module sections; do not apply the smoke app's 32 MiB/256 MiB values to every game. Begin with unshared memory and no pthreads/SharedArrayBuffer requirement. Threaded browser profiles need separate hosting and compatibility acceptance.
The current HTML5 hosting limits are 1,000 files, path lengths of 240 characters, 500 MB expanded total and 200 MB per extracted file. Enforce conservative decimal byte values and version the hosting policy in the tool distribution. Recheck limits before enabling a new adapter version. Account upload allowances are a separate constraint. itch.io HTML5 guide
Clean extraction, local HTTP tests and software rendering tests validate the package. Real hosted rendering on supported browsers/devices remains a separate acceptance gate. Retain exact package hashes in both kinds of evidence.
Package identity and verification¶
Each completed package has this layout:
out/packages/<profile>/<archive-sha256>/
game.zip
package.json
validation.json
Inside game.zip, build-info.json records schema version, profile, entry point,
source revision, dirty status, engine lock digest, resolved SDK identity and
payload file paths/sizes/modes/SHA256 values. Its inventory excludes itself to
avoid recursive hashing. It contains no credentials, local absolute paths or
private draft URLs. The package sidecar includes the ZIP digest, manifest digest,
release configuration digest, host-tool identity and human version. The package
identity is the archive SHA256, not the editable version label.
validation.json records validator identity, checks/results and archive digest.
Test reports also bind to that digest. A report establishes what was tested,
not trust in an arbitrary downloaded report. CI trusts packages produced by its
own trusted job; manual upload accepts a user-selected local package after
verification. A checksum without a trusted source establishes consistency only.
ZIP output has sorted entries, fixed timestamps, normalized allowed modes and fixed compression settings. Determinism applies to identical payloads and compatible archiver versions; it is not a promise of reproducible compilation. Use same-filesystem staging and atomic no-replace publication. Reusing an identical package requires verifying existing content, never overwriting it.
Upload independently validates the ZIP, sidecars and extracted inventory against all limits. Copy to a private operation snapshot and rehash before invoking butler; never upload a writable build tree. Release configuration must match the recorded digest. Rebinding an old package to changed configuration requires a future explicit command; version 1 rejects that mismatch.
Local packaging may record dirty sources or a visible local SDK override. Local
upload permits these only with an explicit --allow-local-inputs option; the
Editor exposes the same choice with the resolved identity. Generated release CI
requires clean sources, a resolved committed lock and no local overrides. Test
scratch files belong in ignored output so tests do not make a release dirty.
Public commands and results¶
Proposed commands accept a project directory or descriptor, consistently with the project CLI. All commands use shared operations and optional versioned JSON results. No command below is currently promised to exist.
ludus project release init <project> --provider itch --ci github-actions
ludus project package <project> --profile linux-release --version 0.1.0
ludus project package verify <package-directory>
ludus project publish plan <project> --package <package-directory> --destination linux
ludus project publish upload <project> --package <package-directory> --destination linux
ludus project release ci <project> --enable-release-tags
release init generates configuration, packaging helper and optional workflow
only into absent paths, showing the intended additions before application.
For an existing project, apply under the project metadata lock with a recovery
journal for interrupted multi-file writes; refuse collisions and preserve
user-owned files. A repair can complete or remove only files created by that
operation. Template regeneration never silently replaces edited workflows.
Project creation can include these files in its existing atomic creation step.
publish plan is offline: show destination, channel, version, package digest,
payload size, prerequisites, local-input status and the redacted argument plan.
It never authenticates or uploads. Upload is a separate explicit operation.
There is no implicit upload in Build, Run, Package or Open. Version 1 does not
combine rebuilding and uploading: package first, test/inspect it, then upload.
Stable result categories include configuration/compatibility failure, tool missing, build failure, invalid package, lock busy, authentication failure, transport failure, upload accepted, cancelled before upload and remote outcome unknown. Assign numeric exit codes and protocol enums together during the first schema phase; keep tests against the published mapping. Cleanup uncertainty cannot be reported as success. Use bounded redacted logs and structured result fields; do not make the Editor parse human butler progress text.
itch.io adapter and credentials¶
Users create the itch.io project page once and choose its visibility themselves.
Run butler login explicitly for local use; CI supplies the secret through
BUTLER_API_KEY. The Editor may launch the supported login flow but never
reads/displays the key or writes it into project configuration. CI secrets are
configured through the CI provider, not generated into files. Pass the key only
to the butler subprocess environment; configure/build/package/test processes
must not receive it. Butler authentication
Invoke the verified pinned binary with the validated private ZIP snapshot:
butler push <snapshot/game.zip> <username/game:channel> --userversion <version>
Butler supports directories or ZIPs, updates an existing channel and transfers
changed data. Its --userversion labels the build; itch.io orders builds by
upload order rather than that label. Initial HTML setup requires selecting HTML
project type and marking the channel playable in the page editor after its
first upload. Record setup instructions in generated docs; a local “setup done”
flag is user acknowledgement, not remote verification.
Butler upload contract
The adapter never changes project visibility. A channel called beta or staging
does not make a public game's files private. An upload updates remote content and
may be immediately available to players according to the page's current settings.
Use a separate private/draft project for acceptance testing. Do not promise
atomic publication across platforms: each destination/channel is its own upload.
A multi-platform release can partially succeed; report each result separately.
Pin and probe the supported butler command/version during tool setup. Avoid promising machine-readable events or build IDs until tested against that version. Stream bounded redacted diagnostics and report indeterminate progress when butler offers no stable structured progress interface. Record a remote build ID only if the supported interface actually returns one.
Upload lifecycle and recovery¶
An upload owns one supervised process and follows:
Idle -> Validating -> Snapshotting -> Ready -> Uploading -> UploadAccepted
| |
| +-> RemoteOutcomeUnknown
+-> Failed / CancelledBeforeUpload
UploadAccepted means the pinned butler command successfully completed. It does
not certify playable runtime behavior or completion of every remote processing
step. Keep hosted acceptance evidence separate. An upload receipt records job ID,
destination/channel, source revision, archive digest, version, tool identity,
start/end times, result and optional observed remote ID. It is local evidence,
not a remotely verified hash. Never include secret values or full environment
maps in receipts, diagnostics or Copy Job Details.
Cancellation before starting butler is definite. Once upload starts, cancellation,
connection loss, timeout or process failure can leave an uncertain remote result.
Stop performs shared process-tree cleanup but cannot roll back an accepted build.
Record RemoteOutcomeUnknown whenever remote mutation cannot be excluded. Do
not automatically retry these cases. Offer inspection of the itch.io build list
and an explicit retry of the same immutable package; retry may create another
remote build. Prevent duplicate clicks for an active job.
Use a cooperative local lock keyed by itch.io target and channel in the host-tool user state directory, so two projects on one host share it. CI takes a matching repository concurrency group. Neither lock coordinates unrelated machines, repositories or the itch.io desktop uploader. Initial support assumes one CI repository owns a destination and local operators do not upload concurrently with CI. Strong cross-machine exclusion would require a deployment coordinator and is outside version 1. State this limit in generated documentation.
Generated GitHub Actions¶
Generate a user-owned workflow when requested. Default trigger is
workflow_dispatch; release-tag uploads require explicit enablement. PR checks
may build/package/test without credentials, but never upload. The separate
build and test workflow
provides those credential-free checks. Do not use
pull_request_target or run untrusted PR code with deployment secrets.
Use two separate jobs:
- Build and validate: check out the selected revision, provision exact host tools/toolchain and SDKs through explicit setup, build/package, extract and run relevant release tests, then retain the package and digest-bound reports. This job has no itch.io secret.
- Upload: depend on successful validation, download that exact workflow-run
artifact, verify its bytes and metadata, and invoke the shared upload command.
No rebuild occurs here. Supply
BUTLER_API_KEYonly to the upload step. Use a selected GitHub environment if the project wants environment protection.
Pin external Actions by full commit SHA and tooling by version/digest. Default
permissions are contents: read, with additions justified by actual API needs.
Use literal generated profile/destination selections; workflow inputs are bounded
identifiers/version labels validated by the backend, never shell code. Initial
manual dispatch uses the selected workflow revision and its generated policy;
repository maintainers control which revisions may deploy. Document that trust
boundary rather than claiming a secret can safely deploy arbitrary game code.
Group upload jobs by normalized target/channel with cancel-in-progress: false.
GitHub concurrency provides exclusion but does not guarantee queue order; newer
pending work can replace pending runs. Do not use cancellation of an active upload
as a newest-build policy. GitHub concurrency documentation
Optional automatic tags initially use stable vMAJOR.MINOR.PATCH only, reject
leading-zero/noncanonical versions, and require the tagged commit to be reachable
from the configured release branch. After acquiring concurrency, re-query the
repository's tags and require the current tag to be the highest eligible version
before upload; skip superseded jobs. Version comparison is numeric, not lexical.
The generated workflow never overwrites an already selected tag. A new tag during
an active upload waits; its job follows that upload. Tag deletion/movement and
uploads from other machines remain outside this ordering guarantee.
Manual reruns of older releases require an explicit restore choice in workflow inputs and the same destination lock. Display version and digest prominently. This is a new upload of retained bytes, not a promise of a remote rollback API. CI artifacts have finite retention; document archiving release packages elsewhere if restoration after expiration matters. A failed upload job retains its package so a retry uploads the same bytes.
Editor and project creation experience¶
New Project offers an optional “Add itch.io publishing” checkbox. Creation needs no network or account access; a destination can be entered then or configured later. Packaging-only projects are supported. GitHub CI generation is a separate choice; never assume every project uses GitHub. Include a short setup document with the generated files explaining page creation, login/secrets, triggers, channel visibility and initial browser settings.
A Release panel shows profiles, resolved Release SDK, destination/channel, package digest, version, validation results and latest local receipt. Actions are Package, Inspect Package, Upload to itch.io, Stop and Open itch.io Page. Upload uses the inspected immutable package, and disabled states explain missing tools, configuration or validation. Destination edits require saving and a new plan; a dirty release configuration cannot upload. Unsaved editor documents block packaging. Dirty source/SDK overrides follow the explicit local-input policy.
Extend the existing versioned Editor/backend protocol with package and upload operations, stage events and result categories. One workspace operation owns cancellation/output, and the existing controller rejects stale job events. Keep UI responsive while hashing large files, building, installing or uploading. Open only reads local metadata; credential discovery and remote actions are explicit. A successful upload leaves the package/receipt available for inspection.
Implementation phases and acceptance gates¶
Record evidence in docs/development/game-packaging-publishing-evidence.md during
implementation, including exact revision, commands, package digests, real service
results and unavailable checks. Do not mark mocked transport tests as a live
itch.io acceptance result.
| Phase | Deliverable | Required gate |
|---|---|---|
| R0 | Baseline audit, configuration/manifest/result schemas, compatibility and tool pins | Shared fixtures, limits, dependency/license inventory and supported matrix documented |
| R1 | Native CMake release component and installed package/verify operations | A relocated independent Release game runs from clean extraction without SDK or authoring tools |
| R2 | Offline plan, pinned butler adapter, credentials, locks, receipts and recovery | Exact package uploads to a separately authorized draft/private project; ambiguous results remain explicit |
| R3 | Optional project generation and GitHub workflow | Fresh generated repo packages and uploads retained bytes; PR job lacks secrets; ordering and retry behavior demonstrated |
| R4 | Editor Release panel and optional New Project setup | Real GUI package/inspect/upload/stop plus legacy Open/Build/Run regressions pass |
| R5 | Independent web package profile and itch.io browser integration | Exact archive passes extraction/hosting checks and real supported-device hosted play without experimental flags |
R0-R3 form the first useful headless delivery. R4 depends on installed Editor project creation. R5 depends on independent web SDK/project support and must not be claimed complete by uploading the engine smoke app. Initially native tests use a standalone fixture; external Ludus-Sandbox conversion is a separate change requiring access to that repository.
Required failure and regression coverage:
- Failed build with an old executable, mismatched SDK/flavor, unresolved lock, missing butler and SDK mutation during packaging.
- Missing assets/notices, undeclared native dependencies, producer paths, traversal, symlinks, oversized/duplicate entries and corrupted ZIP/sidecars.
- Stage cleanup, package destination races, source/configuration changes during planning, private snapshot tampering and interrupted release initialization.
- Wrong/revoked credentials with no secrets in logs, wrong destination, duplicate actions, local lock contention, Stop before upload and failure after possible remote acceptance. Fake transport tests cover uncertain outcomes reliably.
- CI secret isolation, retained-package retry, superseded tags, concurrent jobs, explicit old-package restoration and per-platform partial success reporting.
- Editor dirty state, stale events, close during upload, bounded output and cleanup uncertainty; headless tools remain independent of Qt.
- Browser asset requests, memory/hosting limits, load failures and hosted real rendering remain separate observable checks.
Before implementation handoff, run appropriate pinned warning-clean builds, unit/integration tests, format/tidy, ASan/UBSan and SDK consumer checks, plus existing browser and Editor gates affected by the changes. Recheck AGENTS.md, steering and ADR include/dependency rules. Report unavailable gates as incomplete; do not weaken them to enable uploading. Documentation of the proposed system alone does not satisfy these implementation gates.
Decisions reserved for implementation¶
The architecture fixes ownership, package identity, explicit upload semantics, secret isolation and first-platform scope. R0 must record actual native loader closure, redistributable license obligations, pinned butler version/interface and host-tool release availability before coding depends on them. No fabricated checksums, published releases or remote IDs belong in generated templates.
A service for distributed upload locks, unattended preview uploads on every commit, generalized store plugins, remote page automation and automatic game updates require separate use cases and designs. The initial workflow should be small enough that a generated game's release remains understandable and editable.
The Editor setup, browser Release packaging and explicit CI upload follow-up is documented in Editor-managed game releases.