Custom Automated — from a standalone repo¶
This page is for authors whose world lives in its own repository and who want
to provide their own pyproject.toml, call the reusable workflows from an
existing release workflow, or ship more than one world from the same repo.
The Standard Automated path does not cover the standalone-repo shape — this Custom Automated page is the maintained path for that layout.
When you want this¶
Use the advanced path when:
- You want package metadata beyond the generated default, such as custom classifiers or explicit entry points.
- You want dependencies declared in
pyproject.toml. If all you need is a simple dependency list,worlds/<apworld>/requirements.txtalso works. - You already have a release workflow and want to add the reusable wheel build job to it.
- You ship multiple worlds from one repo under separate
worlds/<apworld>/folders.
How the build consumes your pyproject.toml¶
gen-pymod-release's shape_tree.py script applies these rules when
worlds/<apworld>/pyproject.toml exists:
[project].versionis injected fromarchipelago.json:world_versionwhen it is absent or blank.[project].authorsis injected fromarchipelago.json:authorswhen it is absent or blank.[project].descriptionis always overwritten asMultiWorld: <game>fromarchipelago.json:game.- If your
pyproject.tomlalready declares[project].dependencies, it wins overworlds/<apworld>/requirements.txt. - Auto-discovered
mwgg.cliententry points are injected only when you have not declared[project.entry-points."mwgg.client"]yourself. - The nested
worlds/<apworld>/pyproject.tomlis removed from the wheel source tree. The synthesized root-levelpyproject.tomlis what gets built.
Do not hard-code version, authors, or description in your source
pyproject.toml. archipelago.json stays the source of truth for release
identity.
Good pyproject.toml¶
Place this at worlds/<apworld>/pyproject.toml, replacing your_apworld and
the fake dependencies with real values:
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "worlds.your_apworld"
requires-python = ">=3.13"
dependencies = [
"pillow>=10.3",
"platformdirs>=4.2",
]
classifiers = ["Private :: Do Not Upload"]
[project.entry-points."mwgg.client"]
"worlds.your_apworld.Client" = "worlds.your_apworld.mwgg_client:launch_client"
[tool.setuptools.packages.find]
where = ["src"]
include = ["worlds.your_apworld"]
namespaces = true
If you do not ship a client, omit the mwgg.client block. If you do ship one
and rely on the legacy Component(..., component_type=Type.CLIENT) registration,
the build can auto-discover it; the explicit entry point above is just less
magic.
Good mwgg_client.py¶
This is a launcher-integrated text-style client. It follows the same hooks as
the built-in text client in CommonClient.py: subclass CommonContext, override
server_auth, use on_package for server messages you care about, and expose a
zero-argument launch_client entry point.
from __future__ import annotations
import argparse
import asyncio
import logging
from CommonClient import CommonContext, get_base_parser, server_loop
from Utils import async_start, init_logging
logger = logging.getLogger("YourGameClient")
class YourGameContext(CommonContext):
game = "Your Game Name"
items_handling = 0b111
want_slot_data = True
async def server_auth(self, password_requested: bool = False) -> None:
if password_requested and not self.password:
await super().server_auth(password_requested)
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict) -> None:
if cmd == "Connected":
logger.info("Connected as %s in slot %s", self.auth, self.slot)
async def _main(args: argparse.Namespace) -> None:
ctx = YourGameContext(server_address=args.connect, password=args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if ctx._can_takeover_existing_ui():
await ctx._takeover_existing_ui()
else:
ctx.takeover_complete.set()
ctx.run_gui()
await ctx.server_auth()
await ctx.exit_event.wait()
await ctx.shutdown()
def launch_client() -> None:
init_logging("YourGameClient")
parser = get_base_parser("Your Game Name Client")
args = parser.parse_args()
async_start(_main(args), name="YourGameClient")
Calling the reusable workflows yourself¶
Oliver opens an Index PR via its workflow_run.completed handler for
successful release-event runs that call
MultiworldGG/gen-pymod-release/.github/workflows/build.yml. The caller
workflow name is up to you.
Releases created by another workflow using GITHUB_TOKEN
If your release-creating step runs in another workflow under the default
GITHUB_TOKEN, GitHub
will not trigger your downstream release-event build workflow.
Oliver's release.published handler still fires on the published release,
but with no workflow_run to consume and no .whl asset attached, Oliver
bails.
Fix the chain by using a token that is allowed to trigger workflows on the step that creates the release:
- A fine-grained personal access token
with
contents: writeandworkflows: writeon this repo, stored as a repository secret such assecrets.RELEASE_PAT, and passed in astoken: ${{ secrets.RELEASE_PAT }}; or - A GitHub App token minted by
actions/create-github-app-token, from a GitHub App that hasContentsandWorkflowswrite permission on this repo.
name: Create and Release Python APWorld
on:
release:
types: [published]
workflow_dispatch:
inputs:
apworld:
description: "World folder under worlds/ to build"
required: true
type: string
source_ref:
description: "Git ref to build for a manual dry-run"
required: false
type: string
permissions:
contents: write
jobs:
publish-wheel:
uses: MultiworldGG/gen-pymod-release/.github/workflows/build.yml@v3
with:
apworld: ${{ inputs.apworld || '' }}
source-ref: ${{ inputs.source_ref || '' }}
dry-run: ${{ github.event_name == 'workflow_dispatch' }}
publish-apworld:
uses: MultiworldGG/gen-pymod-release/.github/workflows/build-apworld.yml@v3
with:
game: "Your Game Name"
apworld: ${{ inputs.apworld || '' }}
apworld-source-ref: ${{ inputs.source_ref || '' }}
dry-run: ${{ github.event_name == 'workflow_dispatch' }}
On a release run, the reusable workflows ignore the apworld input and parse
the apworld from the tag prefix. On a manual run, there is no release tag, so
apworld is required and dry-run: true avoids uploading release assets.
Multiple worlds in one repo¶
For worlds/worlda/ and worlds/worldb/, gate each job by the release tag
prefix. A worlda-1.2.0 release builds only worlda; a manual dry-run builds
the world selected by the apworld input.
jobs:
publish-worlda:
if: ${{ startsWith(github.event.release.tag_name || '', 'worlda-') || inputs.apworld == 'worlda' }}
uses: MultiworldGG/gen-pymod-release/.github/workflows/build.yml@v3
with:
apworld: worlda
source-ref: ${{ inputs.source_ref || '' }}
dry-run: ${{ github.event_name == 'workflow_dispatch' }}
publish-worldb:
if: ${{ startsWith(github.event.release.tag_name || '', 'worldb-') || inputs.apworld == 'worldb' }}
uses: MultiworldGG/gen-pymod-release/.github/workflows/build.yml@v3
with:
apworld: worldb
source-ref: ${{ inputs.source_ref || '' }}
dry-run: ${{ github.event_name == 'workflow_dispatch' }}
Cut releases as <apworld>-<version>, for example worlda-1.2.0. Oliver opens
one Index PR for the world whose wheel was attached to that release.
Local development loop¶
For local testing, you have three options:
- Symlink + Launcher — symlink
worlds/<apworld>/into a MultiworldGG checkout and runpython Launcher.py "Build APWorlds" -- "<Game Name>". Produces a.apworldinbuild/apworlds/. - Manual dry-run — run the workflow from the Actions tab with
apworldset to your world folder. The wheel is built but not uploaded to a release. python -m build— construct the orphan tree manually withscripts/shape_tree.py, then runpython -m build --wheelin the output directory. This is mostly useful for debuggingpyproject.tomlissues.