Files
gen1recomp/tools/cart_release_workflow.yml
T
bryanthaboi ebf44f20d3 Add custom carts: named, version-pinned mod sets that play as their own game
A custom cart pairs an identity (title, shell colour, label art) with a base
game, a list of mods pinned to exact builds with their option values frozen,
a load order, and a seal. It ships no code of its own: every mod it names is
a separately published mod, which is what keeps a cart auditable before it
runs and reproducible after an author's repo disappears.

Format and storage:
- src/carts/CartManifest.lua parses and validates cart.json, canonicalises it
  for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only
  serialised table read through SaveSerializer, so an imported cart can never
  execute code. Canonical strings are length-prefixed because option keys and
  values are author-controlled and could otherwise forge a record boundary and
  collide two different carts onto one hash.
- Pins name a public source: a GitHub release with its sha256, a GameBanana
  file id with its md5, or "local" for a capture that only exists on this
  install. A local pin is unpublishable by construction, which is what makes
  "build it here, publish later" possible without inventing a hash.
- Label art rides alongside the manifest rather than inside its identity, so
  re-arting a cart does not tell every player their run is out of date.
  src/core/Base64.lua decodes it; strict, with no whitespace tolerance.

Saves:
- Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/),
  so a cart's file never sits beside a vanilla one and uninstalling a cart
  never orphans a save. Every save records the cart build it was made under.

The seal:
- A sealed cart loads its pinned list, in its order, with its options, and
  nothing else. A pinned mod with no frozen options gets an empty bucket so
  unfrozen keys fall to schema defaults, identical for everyone; otherwise two
  players on one cart quietly run different games.
- A sealed cart refuses to load when a pin is missing or installed at another
  version. Playing a subset of the cart is the exact dishonesty the seal
  exists to prevent, so the refusal loads nothing at all.
- Breaking the seal is permanent, marked per save slot, and downgrades that
  playthrough to open behaviour. It cannot be cleared through any public API.

Launcher:
- A game's page carries a Custom Carts control and a picker; choosing a cart
  turns the page into that cart's page, with its own cartridge, title and save
  slots. The rail of five games never grows and a cart id never reaches
  imp.tab or imp.panelVersion.
- Loader.planCart runs before boot so a refusal is visible on the page instead
  of being discovered as an error after launch.
- Save as cart captures the enabled mods for a game and names, before the
  player confirms, every mod that could only be pinned to this install and
  whether the result can be shared at all.

Authoring:
- tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a
  release workflow. Its writer is byte-identical to the engine's serialiser.
2026-08-23 12:18:29 -04:00

123 lines
3.9 KiB
YAML

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: "Existing v<version> tag to build and publish."
required: true
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
env:
CART_ID: "{{CART_ID}}"
CARTKIT_REPO: bryanthaboi/gen1recomp
CARTKIT_REF: dev
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- name: Fetch cartkit
run: |
set -euo pipefail
curl -fsSL --retry 3 -o "$RUNNER_TEMP/cartkit.py" \
"https://raw.githubusercontent.com/${CARTKIT_REPO}/${CARTKIT_REF}/tools/cartkit.py"
python3 "$RUNNER_TEMP/cartkit.py" selftest --quiet
- name: Check the tag against cart.json
id: cart
env:
TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, sys
with open("cart.json", encoding="utf-8") as fh:
cart = json.load(fh)
version = str(cart.get("version", ""))
cart_id = str(cart.get("id", ""))
tag = os.environ["TAG"]
if tag != f"v{version}":
print(f"::error::tag {tag} does not match cart.json version "
f"{version} (expected v{version})", file=sys.stderr)
raise SystemExit(1)
stamped = os.environ["CART_ID"]
if cart_id != stamped:
print(f"::error::cart.json id is {cart_id}, but this workflow "
f"was stamped for {stamped}; rerun cartkit "
"add-release-workflow", file=sys.stderr)
raise SystemExit(1)
print(f"version={version}")
print(f"id={cart_id}")
print(f"tag={tag}")
PY
- name: Validate every pin
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 "$RUNNER_TEMP/cartkit.py" validate . --online --strict
- name: Pack the cart
env:
CART_VERSION: ${{ steps.cart.outputs.version }}
CART_NAME: ${{ steps.cart.outputs.id }}
run: |
set -euo pipefail
out="$GITHUB_WORKSPACE/dist"
rm -rf "$out"
mkdir -p "$out"
python3 "$RUNNER_TEMP/cartkit.py" pack . \
-o "$out/${CART_NAME}-${CART_VERSION}.g1rcart"
(cd "$out" && sha256sum ./*.g1rcart > sha256sums.txt)
cat "$out/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
CART_VERSION: ${{ steps.cart.outputs.version }}
CART_NAME: ${{ steps.cart.outputs.id }}
TAG: ${{ steps.cart.outputs.tag }}
run: |
set -euo pipefail
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
range="${prev:+${prev}..}${TAG}"
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
notes=$'Download the .g1rcart and open it from the game to install this cart.'
notes+=$'\n\nThe cart is a manifest: it pins each mod to the exact build listed in cart.json and ships no code of its own.'
if [ -n "$changes" ]; then
notes+=$'\n\n## Changes\n\n'"$changes"
fi
asset="dist/${CART_NAME}-${CART_VERSION}.g1rcart"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "$asset" "dist/sha256sums.txt" --clobber
else
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$CART_VERSION" \
--notes "$notes" \
"$asset" \
"dist/sha256sums.txt"
fi
echo "Published $TAG"