diff --git a/.changeset/release-cli-from-kit.md b/.changeset/release-cli-from-kit.md new file mode 100644 index 0000000..fec23a6 --- /dev/null +++ b/.changeset/release-cli-from-kit.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2e9e10..d28f7e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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 @@ -94,10 +77,10 @@ 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: @@ -105,18 +88,13 @@ jobs: 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 @@ -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 diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 0000000..68b7375 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -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}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4c7e639 --- /dev/null +++ b/.goreleaser.yaml @@ -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 }}" diff --git a/CLAUDE.md b/CLAUDE.md index b11d7bc..13ca921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/shellcade-kit/artifact_test.go b/cmd/shellcade-kit/artifact_test.go index a4401c9..46145e6 100644 --- a/cmd/shellcade-kit/artifact_test.go +++ b/cmd/shellcade-kit/artifact_test.go @@ -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 : %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") + } } diff --git a/cmd/shellcade-kit/main.go b/cmd/shellcade-kit/main.go index 4ff6438..ebf4c8d 100644 --- a/cmd/shellcade-kit/main.go +++ b/cmd/shellcade-kit/main.go @@ -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] + 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) } @@ -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 @@ -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)) diff --git a/host/gameabi/conformance/conformance.go b/host/gameabi/conformance/conformance.go index 3e4bd77..f66d61a 100644 --- a/host/gameabi/conformance/conformance.go +++ b/host/gameabi/conformance/conformance.go @@ -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 {