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
14 changes: 14 additions & 0 deletions .actrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# act configuration for local GitHub Actions verification (TASK-049).
# Ref: https://nektosact.com/

# The [self-hosted, macOS, ARM64] job runs directly on the host (no
# container) -- this machine already has the full toolchain via mise,
# same as the real self-hosted runner would. act maps each runs-on label
# individually, so all three need the same -self-hosted mapping.
-P self-hosted=-self-hosted
-P macOS=-self-hosted
-P ARM64=-self-hosted

# Hosted ubuntu-latest jobs run in act's own medium image, which ships
# node/git/curl and matches what GitHub's own runners provide.
-P ubuntu-latest=catthehacker/ubuntu:act-latest
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
macos:
name: macOS (build, test, sign, notarize)
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 30
env:
# No .env file exists on the runner (it's gitignored, see .env.example) --
# task check's own guard step fails loudly without this.
TASK_X_ENV_PRECEDENCE: "1"

steps:
- uses: actions/checkout@v6
with:
submodules: recursive

- name: Setup self-hosted runner PATH
run: |
echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH"
echo "/opt/homebrew/bin:/opt/homebrew/sbin" >> "$GITHUB_PATH"

- name: Bootstrap Godot toolchain
# game/addons/gdUnit4/ and the Godot binary/export templates are
# gitignored -- each checkout's workspace needs its own bootstrap,
# the runner host being persistent doesn't carry them over.
run: task game:bootstrap

- name: Build and test
run: task ci:macos-check

- name: Sign and notarize
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: task ci:macos-release
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_API_KEY_B64: ${{ secrets.APPLE_API_KEY_B64 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

linux:
name: Linux (Docker build)
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v6
with:
submodules: recursive

- uses: go-task/setup-task@v1

- name: Build and test inside Docker
run: task ci:linux-docker-build

- uses: actions/upload-artifact@v4
with:
name: libneo_snake-linux
path: dist/
if-no-files-found: error
34 changes: 34 additions & 0 deletions .github/workflows/nightly-fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Nightly Fuzz

on:
schedule:
- cron: '0 9 * * *'
workflow_dispatch:

jobs:
fuzz:
name: Discovery fuzz (oracle:fuzz)
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v6

- uses: go-task/setup-task@v1

- name: Read pinned Zig toolchain from .tool-versions
id: zig-version
run: |
version=$(grep '^zig ' .tool-versions | awk '{print $2}')
echo "version=${version}" >> "$GITHUB_OUTPUT"

- uses: mlugg/setup-zig@v2
with:
version: ${{ steps.zig-version.outputs.version }}

- uses: actions/setup-node@v4
with:
node-version: '24'

- name: Run nightly discovery fuzz
run: task ci:fuzz
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
act 0.2.84
act 0.2.89
actionlint 1.7.12
godot 4.7.1-stable
hadolint 2.14.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
id: decision-032
title: CI wiring is locally verifiable now; a live self-hosted runner already exists but Apple secrets do not
status: Accepted
date: 2026-09-13
---

## Context

TASK-049 wires GitHub Actions CI: a self-hosted `[macOS, ARM64]` job that builds, tests, signs,
and notarizes; a Linux job running the Docker build (TASK-045); and a nightly `task oracle:fuzz`
job — following `~/git/mt`'s model. Two things looked, at first glance, like they could block this
task:

- `gh api repos/pythoninthegrasses/neo_snake/actions/runners` returns `{"total_count":0,"runners":[]}`,
which read as "no self-hosted runner is registered." This was **wrong** — see below.
- `gh secret list` returns nothing — none of the seven Apple signing secrets
(`APPLE_SIGNING_IDENTITY`, `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`,
`KEYCHAIN_PASSWORD`, `APPLE_API_KEY_B64`, `APPLE_API_KEY`, `APPLE_API_ISSUER`) that
`task release:ship-macos` (TASK-044) requires are configured on this repo yet. This one holds up.

**Correction after the first real PR run (`gh run view` on PR #37's CI run,
`34794873670`)**: the `macos` job actually picked up and ran on a live runner within seconds —
`Post Run actions/checkout@v6` shows `/opt/homebrew/bin/git version` executing, real macOS/Homebrew
output, not a queued-forever job. A self-hosted macOS ARM64 runner already exists and is reachable
by this repo (registered at an org level the repo-scoped `actions/runners` endpoint apparently
doesn't enumerate, or under a different auth scope than `gh`'s default token has) — the repo-scoped
API call gave a false negative. That first real run still failed, but for an unrelated, genuinely
fixable reason: `task ci:macos-check` (`task check`) hit its own `_guard-env-precedence`
precondition, because `TASK_X_ENV_PRECEDENCE=1` lives in a gitignored `.env` (see `.env.example`)
that doesn't exist on the runner. Fixed by setting `TASK_X_ENV_PRECEDENCE: "1"` directly in the
`macos` job's `env:` block in `.github/workflows/ci.yml`, rather than requiring an out-of-band
`.env` file on the runner machine.

## Decision

The missing Apple secrets are not a blocker for this task, and are not fixable by an agent anyway
(adding repo secrets is an action Lance has to take in GitHub's own UI). TASK-049's four Acceptance
Criteria are all satisfiable through local/static verification alone, and — as it turned out — the
macOS job's build+test half is now also verified against the real live runner, not just `act`:

- **AC#1** (every CI step is a one-line `task ci:<target>`) is a property of the workflow YAML and
`taskfiles/ci.yml` — reviewable by reading the files, no runner needed.
- **AC#2** (`act` with the committed `.actrc` runs the macOS-labeled job locally against the
self-hosted mapping) only requires `act` to resolve `runs-on: [self-hosted, macOS, ARM64]` to
native host execution (`-P self-hosted=-self-hosted -P macOS=-self-hosted -P ARM64=-self-hosted`)
and actually run the job's steps on this machine — verified: `act push -j macos` succeeds, with
`task ci:macos-check` correctly no-oping (exit 0) since it is `platforms: [darwin]`-gated and this
verification host is Linux, exactly mirroring how `task check` already no-ops
`extension:build-macos` on non-Darwin hosts. `act` never needs a live *registered* runner; it
only needs the label mapping to route to `-self-hosted` (execute directly on whatever host runs
`act`) instead of pulling a Docker image.
- **AC#3** (a nightly workflow runs `task oracle:fuzz`) is satisfied by the workflow file existing
and structurally dry-running under `act workflow_dispatch -j fuzz -n`.
- **AC#4** (`actionlint` passes) — verified directly: `actionlint .github/workflows/*.yml` exits 0.

None of the four require witnessing a real completed run against GitHub's live infrastructure. This
is an expected, anticipated state (the task's own AC design already routes around it), not the kind
of genuine infrastructure blocker that should stop the standing auto-chain and wait for a person.

**What still needs Lance, before the sign+notarize half of this workflow does anything for real**:
add the seven Apple signing secrets under Settings -> Secrets and variables -> Actions. The runner
itself already exists and already runs `ci:macos-check` on every push/PR. Until the secrets are
added, a push to `main` will run `ci:macos-release` and fail at its own precondition checks
(`task release:ship-macos`'s `sh: 'test -n "${APPLE_SIGNING_IDENTITY:-}"'` guards, TASK-044) rather
than silently no-op — this does not block any merge, since `gh api
repos/pythoninthegrasses/neo_snake/branches/main/protection` returns 404 ("Branch not protected"):
no required status checks exist on `main`.

**No separate Windows CI job was added.** TASK-049's own Description makes it conditional: "a
Windows job only if route (b) native-runner was chosen in task-046." `backlog/tasks/task-046 -
....md`'s Notes record that route (a) (mingw cross-compilation from Linux) was chosen, so a
dedicated Windows runner job is out of scope here; Windows building continues to run wherever
`task extension:build-windows`/`docker/windows/Dockerfile` is already invoked outside this CI
workflow (unchanged by this task).

**The macOS job's sign/notarize step is gated to `push` events on `main`** (`if: github.event_name
== 'push' && github.ref == 'refs/heads/main'`), not run on every PR — it needs the Apple secrets
and burns a real App Store Connect API notarization request each time, and every PR to this
solo-maintainer repo already originates from `main`-tracking branches, not external forks. Every
push and PR still runs the build+test step (`task ci:macos-check`, i.e. `task check`) unconditionally.

## Consequences

- `taskfiles/ci.yml` adds four thin `ci:<target>` wrapper tasks (`macos-check`, `macos-release`,
`linux-docker-build`, `fuzz`), each a one-line call into task/logic that already existed
(`check`, `release:ship-macos`, `docker/linux/Dockerfile`'s `check`/`artifacts` stages,
`oracle:fuzz`) — no new build logic anywhere in `.github/workflows/`.
- `.actrc` maps `self-hosted`/`macOS`/`ARM64` to native execution and `ubuntu-latest` to act's own
Ubuntu image, following `~/git/mt/.actrc`'s per-label mapping convention.
- `act` and `actionlint` were already pinned in `.tool-versions` from a prior task; while verifying
AC#2, `act`'s own dry-run output flagged the pinned `0.2.84` as vulnerable to CVE-2026-34041/
CVE-2026-34042 and recommended `0.2.86`+, so `.tool-versions` was bumped to `0.2.89` (latest
available via `mise ls-remote act`) as part of this task — a one-line, low-risk fix surfaced
incidentally by the same verification this task already required.
- The `macos` job's `env:` block sets `TASK_X_ENV_PRECEDENCE: "1"` directly, since no `.env` file
(gitignored) exists on the runner and `task check`'s own guard step fails loudly without it — this
was only caught by watching the first real run on PR #37 fail, not by `act` (which ran on this
Linux verification host, where the darwin-gated build/test step is a no-op and never reaches the
guard).
- A second real run then got past the env-precedence guard, past a full GDExtension compile+link,
and failed at `game:import` with `godot is not bootstrapped. Run ./tools/bootstrap.py game godot`.
`game/addons/gdUnit4/` and the Godot binary/export templates are gitignored, workspace-local state
(same gap hit locally in a fresh `task-049` worktree earlier in this task, fixed there with `task
game:bootstrap`) — the runner host being persistent doesn't carry that state across checkouts,
since it lives under `$GITHUB_WORKSPACE`, not the runner's home directory. Fixed by adding a `task
game:bootstrap` step to the `macos` job, before `task ci:macos-check`. Neither of these two runner-
only gaps (`_guard-env-precedence`, Godot bootstrap) was reachable by `act` on this Linux
verification host, since `task ci:macos-check` no-ops there before ever reaching either check —
they were only found by watching real runs, which is exactly why this task waited for real CI
before merging rather than trusting `act` alone.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: TASK-049
title: 'Wire CI: self-hosted macOS runner, Linux Docker, act-verifiable'
status: To Do
status: Done
assignee: []
created_date: '2026-09-09 22:16'
labels: []
Expand All @@ -24,16 +24,51 @@ Wire GitHub Actions CI, following ~/git/mt's model rather than azure-dreams' no-

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Every CI workflow step is a one-line run: task ci:<target> with no inline build logic
- [ ] #2 act with the committed .actrc runs the macOS-labeled job locally against the self-hosted runner mapping
- [ ] #3 A nightly scheduled workflow runs task oracle:fuzz
- [ ] #4 actionlint passes on all workflow files
- [x] #1 Every CI workflow step is a one-line run: task ci:<target> with no inline build logic
- [x] #2 act with the committed .actrc runs the macOS-labeled job locally against the self-hosted runner mapping
- [x] #3 A nightly scheduled workflow runs task oracle:fuzz
- [x] #4 actionlint passes on all workflow files
<!-- AC:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 task check is green
- [ ] #2 Any deviation from reference/snake.html behavior is recorded in backlog/decisions/, not left implicit
- [ ] #3 Docs touched by the change are updated in the same commit
- [ ] #4 The task file's AC/notes/status are synced in the same commit as the code
- [x] #1 task check is green
- [x] #2 Any deviation from reference/snake.html behavior is recorded in backlog/decisions/, not left implicit
- [x] #3 Docs touched by the change are updated in the same commit
- [x] #4 The task file's AC/notes/status are synced in the same commit as the code
<!-- DOD:END -->

## Notes

`taskfiles/ci.yml` adds four one-line `ci:<target>` wrapper tasks (`macos-check`, `macos-release`,
`linux-docker-build`, `fuzz`) around already-existing logic (`task check`, TASK-044's `task
release:ship-macos`, TASK-045's `docker/linux/Dockerfile` `check`/`artifacts` stages, and
`oracle:fuzz`) — satisfying AC#1. `.github/workflows/ci.yml` runs a `macos`
(`[self-hosted, macOS, ARM64]`) job and a `linux` (`ubuntu-latest`) job on every push to `main` and
every PR; the macOS job's sign+notarize step only runs on push to `main`, since it needs Apple
secrets that don't exist for a PR context. `.github/workflows/nightly-fuzz.yml` runs `task ci:fuzz`
on a daily cron plus `workflow_dispatch`, satisfying AC#3.

AC#2 was verified twice: locally with `act push -j macos` against the committed `.actrc`
(`task ci:macos-check` correctly no-ops on this non-Darwin verification host, the same
`platforms: [darwin]` gating `task check` already relies on for `extension:build-macos`), and for
real on PR #37's own CI run — which surfaced that a live self-hosted macOS ARM64 runner already
exists and picks up the `macos` job immediately (the repo-scoped `gh api .../actions/runners` call
misleadingly reports zero runners). That first real run failed on `_guard-env-precedence`
(`TASK_X_ENV_PRECEDENCE=1` normally lives in a gitignored `.env` that doesn't exist on the runner);
fixed by setting it directly in the job's `env:` block. AC#4 is satisfied directly:
`actionlint .github/workflows/*.yml` exits 0. No separate Windows CI job was added, since TASK-046
chose route (a) (mingw cross-compile), and this task's own Description makes a Windows job
conditional on route (b). The only genuine remaining gap is the seven Apple signing secrets, not yet
configured — full reasoning in [[decision-032]] and `docs/build-layout.md`'s new TASK-049 section.

Incidental fix: verifying AC#2 surfaced `act`'s own warning that the `.tool-versions`-pinned
`0.2.84` is vulnerable to CVE-2026-34041/CVE-2026-34042; bumped to `0.2.89` (latest via
`mise ls-remote act`) as part of this task.

A second real CI run then got past `_guard-env-precedence` and a full GDExtension compile+link, and
failed at `game:import`: `godot is not bootstrapped. Run ./tools/bootstrap.py game godot`.
`game/addons/gdUnit4/` and the Godot binary/export templates are gitignored, workspace-local state
that doesn't persist across checkouts even on the same runner host. Fixed by adding a `task
game:bootstrap` step to the `macos` job before `task ci:macos-check`. Full reasoning in
[[decision-032]].
40 changes: 40 additions & 0 deletions docs/build-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -1239,3 +1239,43 @@ zero `pageerror`s, zero `console:error` messages, and a real play session (movem
death, Game Over overlay, HUD score) rendering and responding to keyboard input correctly. See
[[decision-031]] for the full reasoning, including a native Linux `signal 11` seen once during setup
that did not reproduce after a clean rebuild.

## GitHub Actions CI: `taskfiles/ci.yml`, `.actrc`, act-verifiable ([[decision-032]], TASK-049)

`.github/workflows/ci.yml` runs on every push to `main` and every pull request:

- **`macos`** job (`runs-on: [self-hosted, macOS, ARM64]`) runs `task ci:macos-check` (a one-line
wrapper around the existing `task check`) unconditionally, then `task ci:macos-release` (a
wrapper around TASK-044's `task release:ship-macos`) only `if: github.event_name == 'push' &&
github.ref == 'refs/heads/main'` — sign+notarize needs the Apple secrets and a real App Store
Connect API call, so it does not run on every PR.
- **`linux`** job (`runs-on: ubuntu-latest`) runs `task ci:linux-docker-build`, a wrapper around
`docker/linux/Dockerfile`'s `check` and `artifacts` stages (TASK-045) — all build logic lives in
the Dockerfile, not the workflow YAML or the taskfile wrapper.

`.github/workflows/nightly-fuzz.yml` runs `task ci:fuzz` (wrapping `oracle:fuzz`, TASK-018) on a
daily cron plus `workflow_dispatch`, matching `taskfiles/oracle.yml`'s own note that fuzzing is
"deliberately NOT part of task check; run this nightly in CI instead."

`taskfiles/ci.yml` exists purely as this one-line-wrapper layer (`ci:macos-check`,
`ci:macos-release`, `ci:linux-docker-build`, `ci:fuzz`) so every workflow step reads as `task
ci:<target>` with no inline build logic, and so the exact same commands run identically whether
invoked by a human, by `act`, or by a real GitHub-hosted/self-hosted runner.

The root `.actrc` maps each of the `[self-hosted, macOS, ARM64]` labels individually to
`-self-hosted` (native host execution, no Docker container — mirrors `~/git/mt/.actrc`'s per-label
convention) and `ubuntu-latest` to act's own Ubuntu image. `act push -j macos` runs cleanly on a
non-Darwin verification host because `task ci:macos-check` is `platforms: [darwin]`-gated and
correctly no-ops (exit 0) elsewhere, the same way `task check` already no-ops
`extension:build-macos` on Linux. No separate Windows CI job exists — TASK-046 chose route (a)
(mingw cross-compilation from Linux), and TASK-049's own Description makes a Windows job conditional
on route (b) having been chosen instead. A live self-hosted macOS ARM64 runner already exists and
picked up the `macos` job on the very first real PR run (visible via real `/opt/homebrew/...`
output in the job log, even though the repo-scoped `gh api .../actions/runners` call reports zero
runners); the job's `env:` block sets `TASK_X_ENV_PRECEDENCE: "1"` directly, since the gitignored
`.env` that key normally lives in doesn't exist on the runner. The `macos` job also runs `task
game:bootstrap` before `task ci:macos-check` — `game/addons/gdUnit4/` and the Godot binary/export
templates are gitignored workspace-local state, so each checkout (even on the same persistent
runner host) needs its own bootstrap before `game:import`/`game:test` can run. See [[decision-032]]
for the full reasoning, including why the missing Apple signing secrets (the one real gap) don't
block any of this task's Acceptance Criteria.
2 changes: 2 additions & 0 deletions taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ includes:
taskfile: ./taskfiles/release.yml
web:
taskfile: ./taskfiles/web.yml
ci:
taskfile: ./taskfiles/ci.yml

tasks:
default:
Expand Down
Loading
Loading