Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/release-cli-from-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"kit": patch
---

The `shellcade-kit` CLI binary is now built and released from this repository.

On each `vX.Y.Z` tag, GoReleaser builds `./cmd/shellcade-kit`, attaches the
cross-platform archives + checksums to that release, and publishes the Homebrew
cask (`brew install shellcade/tap/shellcade-kit`). The published binary embeds
the same kit version it ships under. No behavior change for game authors.

`shellcade-kit check` now accepts an opt-in `--require-leaderboard` flag that
additionally fails any game that declares no leaderboard (the catalog
publishing policy used by the games-repo CI). The default `check` is
unaffected, so minimal ABI fixtures with no board still pass.
68 changes: 20 additions & 48 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,6 @@ on:
# Nightly (07:00 UTC): keep the slow release-profile artifact path honest.
- cron: "0 7 * * *"

# Pinned shellcade-kit toolchain: the author binary the arcade ships, attached
# to this repo's matching release. Bump the version AND the linux/amd64 sha
# (from that release's checksums.txt) together when adopting a new toolchain.
# The kit-pin job below fails CI when this pin falls behind the newest
# published binary release, so drift can't accumulate silently again.
env:
SHELLCADE_KIT_VERSION: "2.11.0"
SHELLCADE_KIT_SHA256: "ab459f4a08cef74b2c6bf709a35b07b75f1113ad7af4450250443e62ddb15eee"

jobs:
test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -49,9 +40,10 @@ jobs:
! grep -rn "shellcade/shellcade" --include="*.go" . || (echo "FORBIDDEN private import" && exit 1)

# Author journey: prove the experience an author actually has — scaffold a
# fresh game with the pinned shellcade-kit, build the dev-profile wasm, and
# run the arcade's acceptance check on it. The scaffold pins a kit version, so
# we replace it with the PR's checkout to gate THIS repo's code, not a release.
# fresh game with shellcade-kit built from this PR's source, build the
# dev-profile wasm, and run the arcade's acceptance check on it. The scaffold
# pins a released kit version, so we replace it with the PR's checkout to gate
# THIS repo's code, not a release.
wasm:
runs-on: ubuntu-latest
steps:
Expand All @@ -62,20 +54,11 @@ jobs:
- uses: acifani/setup-tinygo@v2
with: { tinygo-version: "0.41.1" }
- run: sudo apt-get update && sudo apt-get install -y binaryen
- name: fetch pinned shellcade-kit (sha256-verified)
- name: build shellcade-kit from this PR's source
run: |
set -euo pipefail
ver="${SHELLCADE_KIT_VERSION}"
asset="shellcade-kit_${ver}_linux_amd64.tar.gz"
base="https://github.com/shellcade/kit/releases/download/v${ver}"
curl -fsSL -o "${asset}" "${base}/${asset}"
# Verify against the exact sha we pin, then cross-check the release's
# own checksums.txt agrees (defence in depth against a swapped asset).
echo "${SHELLCADE_KIT_SHA256} ${asset}" | sha256sum -c -
curl -fsSL -o checksums.txt "${base}/checksums.txt"
grep " ${asset}\$" checksums.txt | sha256sum -c -
tar -xzf "${asset}" shellcade-kit
sudo install shellcade-kit /usr/local/bin/shellcade-kit
go -C kit build -o "${RUNNER_TEMP}/shellcade-kit" ./cmd/shellcade-kit
sudo install "${RUNNER_TEMP}/shellcade-kit" /usr/local/bin/shellcade-kit
shellcade-kit version
- name: scaffold a fresh game
run: shellcade-kit new cigame
Expand All @@ -94,29 +77,24 @@ jobs:
shellcade-kit check cigame.wasm

# Rust author journey: the Rust mirror of the wasm job above — scaffold with
# the pinned shellcade-kit (`new --rust`), point the scaffold's crate dep at
# the PR's checkout (the cargo equivalent of `go mod edit -replace`), build
# the release artifact, and run the arcade's acceptance check on it. This is
# the gate that catches scaffolder/crate drift at PR time.
# shellcade-kit built from this PR's source (`new --rust`), point the
# scaffold's crate dep at the PR's checkout (the cargo equivalent of `go mod
# edit -replace`), build the release artifact, and run the arcade's acceptance
# check on it. This is the gate that catches scaffolder/crate drift at PR time.
wasm-rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { path: kit }
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip1 }
- name: fetch pinned shellcade-kit (sha256-verified)
- uses: actions/setup-go@v5
with: { go-version-file: kit/go.mod }
- name: build shellcade-kit from this PR's source
run: |
set -euo pipefail
ver="${SHELLCADE_KIT_VERSION}"
asset="shellcade-kit_${ver}_linux_amd64.tar.gz"
base="https://github.com/shellcade/kit/releases/download/v${ver}"
curl -fsSL -o "${asset}" "${base}/${asset}"
echo "${SHELLCADE_KIT_SHA256} ${asset}" | sha256sum -c -
curl -fsSL -o checksums.txt "${base}/checksums.txt"
grep " ${asset}\$" checksums.txt | sha256sum -c -
tar -xzf "${asset}" shellcade-kit
sudo install shellcade-kit /usr/local/bin/shellcade-kit
go -C kit build -o "${RUNNER_TEMP}/shellcade-kit" ./cmd/shellcade-kit
sudo install "${RUNNER_TEMP}/shellcade-kit" /usr/local/bin/shellcade-kit
shellcade-kit version
- name: scaffold a fresh Rust game
run: shellcade-kit new --rust cigame
Expand Down Expand Up @@ -173,18 +151,12 @@ jobs:
- uses: acifani/setup-tinygo@v2
with: { tinygo-version: "0.41.1" }
- run: sudo apt-get update && sudo apt-get install -y binaryen
- name: fetch pinned shellcade-kit (sha256-verified)
- name: build shellcade-kit from this PR's source
run: |
set -euo pipefail
ver="${SHELLCADE_KIT_VERSION}"
asset="shellcade-kit_${ver}_linux_amd64.tar.gz"
base="https://github.com/shellcade/kit/releases/download/v${ver}"
curl -fsSL -o "${asset}" "${base}/${asset}"
echo "${SHELLCADE_KIT_SHA256} ${asset}" | sha256sum -c -
curl -fsSL -o checksums.txt "${base}/checksums.txt"
grep " ${asset}\$" checksums.txt | sha256sum -c -
tar -xzf "${asset}" shellcade-kit
sudo install shellcade-kit /usr/local/bin/shellcade-kit
go -C kit build -o "${RUNNER_TEMP}/shellcade-kit" ./cmd/shellcade-kit
sudo install "${RUNNER_TEMP}/shellcade-kit" /usr/local/bin/shellcade-kit
shellcade-kit version
- name: scaffold and release-build (-opt=2) against this PR's kit
run: |
set -euo pipefail
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Publishes the shellcade-kit CLI binary on a vX.Y.Z tag.
#
# The tag is pushed by the changesets publish step (release.yml ->
# scripts/tag-release.sh) and IS the kit module release. This workflow runs
# GoReleaser to build ./cmd/shellcade-kit and attach the cross-platform
# archives + checksums + Homebrew cask to that release, then notifies the
# downstream consumers so they can re-pin to the new toolchain.
name: Release CLI
on:
push:
tags: ["v*"]

permissions:
contents: read

jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
# Token with contents:write on shellcade/kit (release assets) AND
# shellcade/homebrew-tap (cask push).
GITHUB_TOKEN: ${{ secrets.KIT_RELEASE_TOKEN }}
# macOS signing/notarization — blank until the Apple secrets exist,
# which leaves notarize disabled (the isEnvSet gate in .goreleaser.yaml).
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}

# Tell the consumers a new shellcade-kit binary shipped so they can bump their
# pinned toolchain. client_payload.version is the released tag (e.g. v2.13.0).
notify-consumers:
needs: goreleaser
runs-on: ubuntu-latest
strategy:
matrix:
repo: [shellcade/shellcade, shellcade/games]
steps:
- name: dispatch kit-released to ${{ matrix.repo }}
env:
# Cross-repo token: needs contents:write (repository_dispatch) on the
# target repos. Reuses the same secret the release step uses.
GH_TOKEN: ${{ secrets.KIT_RELEASE_TOKEN }}
run: |
gh api "repos/${{ matrix.repo }}/dispatches" \
-f event_type=kit-released \
-F "client_payload[version]=${GITHUB_REF_NAME}"
83 changes: 83 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# GoReleaser for the shellcade-kit CLI binary, built from THIS module
# (./cmd/shellcade-kit). Produces the cross-platform archives, checksums, and
# the Homebrew cask, and attaches them to this repo's vX.Y.Z release.
#
# Trigger: the vX.Y.Z tag pushed by the changesets publish step
# (scripts/tag-release.sh) — see .github/workflows/release-cli.yml. The tag IS
# the kit module release, so the binary it builds embeds that same kit version.
#
# Requires a token (KIT_RELEASE_TOKEN in the workflow) with contents:write on
# shellcade/kit (release assets) AND shellcade/homebrew-tap (cask push).
version: 2
project_name: shellcade-kit

builds:
- id: shellcade-kit
main: ./cmd/shellcade-kit
binary: shellcade-kit
env: [CGO_ENABLED=0]
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
ldflags: ["-s -w"]

archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]

checksum:
name_template: checksums.txt

changelog:
disable: true

# Homebrew tap: `brew install shellcade/tap/shellcade-kit`. Publishing needs the
# release token to ALSO have contents:write on shellcade/homebrew-tap. The
# post-install hook strips any quarantine attribute until notarization is on
# (harmless afterwards).
homebrew_casks:
- name: shellcade-kit
repository:
owner: shellcade
name: homebrew-tap
token: "{{ .Env.GITHUB_TOKEN }}"
homepage: https://github.com/shellcade/kit
description: "shellcade game developer kit: scaffold, verify, and play wasm games"
hooks:
post:
install: |
if system_command("/usr/bin/xattr", args: ["-h"]).exit_status == 0
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/shellcade-kit"]
end

# macOS signing + notarization (quill, runs fine on the Linux runner). Activates
# only when the Apple secrets are present; until then releases ship unsigned and
# brew users are unaffected (brew downloads skip the quarantine attribute).
# Required (human steps): Apple Developer Program membership; a Developer ID
# Application certificate exported as base64 p12 (MACOS_SIGN_P12 /
# MACOS_SIGN_PASSWORD); an App Store Connect API key (MACOS_NOTARY_ISSUER_ID /
# MACOS_NOTARY_KEY_ID / MACOS_NOTARY_KEY = the .p8 contents).
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
ids: [shellcade-kit]
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY }}"
wait: true
timeout: 20m

# Attach the binaries + checksums to this repo's own vX.Y.Z release (the tag the
# changesets flow pushed). GoReleaser creates the GitHub release if the publish
# step only pushed the git tag.
release:
github:
owner: shellcade
name: kit
name_template: "shellcade-kit v{{ .Version }}"
18 changes: 10 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,16 @@ Versions/changelogs are driven by **changesets** — THIS repo is the single
version authority. Every user-visible change adds a `.changeset/*.md` (run
`npx changeset`); merging to main lets the changesets action open a Version
Packages PR; merging THAT pushes the `vX.Y.Z` tag, and the tag IS the module
release.

**Lockstep**: `shellcade-kit` adopts this repo's version number. After a kit
version tags, the private repo follows (`go get …/kit@vX.Y.Z`, tag `vX.Y.Z`
there) and its GoReleaser attaches the binaries + Homebrew cask to THIS repo's
existing `vX.Y.Z` release — it must NEVER create new tags here (that pollutes
the module's version space). A shellcade-kit-only fix still rides a kit patch
bump. Never hand-edit CHANGELOG.md or push tags manually.
release. Never hand-edit CHANGELOG.md or push tags manually.

The `vX.Y.Z` tag also drives the `shellcade-kit` **CLI binary** release: the
`release-cli.yml` workflow runs GoReleaser (`.goreleaser.yaml`) to build
`./cmd/shellcade-kit`, attach the cross-platform archives + checksums to that
release, publish the Homebrew cask (`brew install shellcade/tap/shellcade-kit`),
and then fire a `kit-released` `repository_dispatch` to the consumer repos so
they can re-pin to the new toolchain. The binary therefore embeds the same kit
version it ships under — no separate version authority, and a CLI-only fix
still rides a kit patch bump.

## Build & test

Expand Down
7 changes: 6 additions & 1 deletion cmd/shellcade-kit/artifact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ func TestCheckAcceptsGameDirectory(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := check(dir); err != nil {
if err := check(dir, false); err != nil {
t.Fatalf("check <gamedir>: %v", err)
}
// --require-leaderboard is an opt-in catalog publishing policy: the parity
// fixture declares no leaderboard, so the strict check must fail it.
if err := check(dir, true); err == nil {
t.Fatalf("check --require-leaderboard should fail a game with no declared leaderboard")
}
}
19 changes: 17 additions & 2 deletions cmd/shellcade-kit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,16 @@ func main() {
fmt.Printf("Scaffolded %s/ — try it now:\n\n cd %s && go mod tidy && go run .\n", name, name)
}
case "check":
if err := check(path); err != nil {
// check [--require-leaderboard] <gamedir|game.wasm>
fs := flag.NewFlagSet("check", flag.ExitOnError)
requireLB := fs.Bool("require-leaderboard", false, "also fail unless the game declares a leaderboard (catalog publishing policy)")
if err := fs.Parse(os.Args[2:]); err != nil {
usage()
}
if fs.NArg() != 1 {
usage()
}
if err := check(fs.Arg(0), *requireLB); err != nil {
fmt.Fprintln(os.Stderr, "FAIL:", err)
os.Exit(1)
}
Expand Down Expand Up @@ -193,7 +202,7 @@ func newRoom(path string, seed int64, seedSet bool, heartbeat time.Duration, cfg
// argument may be a built .wasm or the game directory (built first), so
// `shellcade-kit check .` is the one-command merge-gate rehearsal for any
// source language.
func check(arg string) error {
func check(arg string, requireLeaderboard bool) error {
path, _, cleanup, err := resolveArtifact(arg)
if err != nil {
return err
Expand All @@ -213,6 +222,12 @@ func check(arg string) error {
if err != nil {
return err
}
// Catalog publishing policy (opt-in): every published game must declare a
// leaderboard. Not part of generic ABI conformance, so minimal fixtures and
// `shellcade-kit check` without the flag are unaffected.
if requireLeaderboard {
rep.Verdicts = append(rep.Verdicts, conformance.LeaderboardVerdict(meta))
}
printReport(rep)
if !rep.Pass() {
return fmt.Errorf("%d budget verdict(s) failed", countFailing(rep))
Expand Down
27 changes: 27 additions & 0 deletions host/gameabi/conformance/conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,33 @@ func (rn *run) verdicts(rep Report) []Verdict {
return vs
}

// LeaderboardVerdict is the publishing-policy gate that every PUBLISHED game must
// declare a leaderboard so its results are recorded and ranked. It is NOT part of
// the generic ABI conformance verdicts (which also run against minimal test
// fixtures that legitimately declare no board): callers that enforce the catalog
// publishing policy — `shellcade-kit check --require-leaderboard`, used by the
// games-repo CI — append it to the Report before checking Pass().
//
// It is deliberately STATIC (a check on GameMeta), not a behavioral "posts on
// leave" assertion. A mid-play disconnect in a round-based multiplayer game is
// recorded at round SETTLEMENT (the leaver ranked dnf), not necessarily on the
// leave callback, and the remaining player may keep playing — so a behavioral
// gate false-fails correct games. The disconnect-save and continuous
// periodic-save behavior is specified in game-sdk and verified by each game's
// own tests.
func LeaderboardVerdict(meta sdk.GameMeta) Verdict {
declared := meta.Leaderboard != nil
v := Verdict{Name: "leaderboard declared", OK: declared, Limit: "Meta().Leaderboard set", Step: -1}
if declared {
v.Measured = "declared"
} else {
v.Measured = "missing"
v.Detail = fmt.Sprintf("game %q declares no leaderboard; every published game must declare a LeaderboardSpec "+
"in GameMeta so its results are recorded and ranked", meta.Slug)
}
return v
}

// ---- helpers -----------------------------------------------------------------

func inputFor(s Step) sdk.Input {
Expand Down
Loading