feat(builder): repair existing templates, a weekly check that queues repairs, and eval
The builder could only create games; nothing noticed when a live template stopped working, and there was no way to tell whether a builder change made it better or worse. - repair -id ID: boots an existing template as nodes run it; if it fails, the same investigation as for a draft runs and the model changes only what the diagnosis says. The worker handles "repair" jobs (gameId from the panel) and opens "fix(games): repair ..." on draft/<id>; a template that boots as it is reports "nothing to repair" and opens no PR. The boot and fix loop is now shared by builds and repairs. - validate.yml runs weekly and boot-tests every game; failed games are sent to the panel (X-GBR-Event: games_boot_failed) to queue repairs. - eval -ids all: builds games that already have a template from scratch, blind, and scores each draft against it (boots, same image and app, ports, ready line, settings found) into eval.md and eval.json. - research skips our own sites, so neither research nor eval reads our templates back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f11c5eaf2f
commit
f1271341ef
12 files changed
+535
-20
No files matched your search
@@ -5,6 +5,10 @@ name: Validate game templates
|
|||||||
# still checked through their push.
|
# still checked through their push.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Weekly: boot-test every game, so a template that stopped working (a game update, a changed image)
|
||||||
|
# is found before players are, and a repair is queued for the AI builder
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
games:
|
games:
|
||||||
@@ -46,6 +50,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
games=$(scripts/changed-games.sh "${{ inputs.games }}")
|
games=$(scripts/changed-games.sh "${{ inputs.games }}")
|
||||||
|
elif [ "${{ github.event_name }}" = "schedule" ]; then
|
||||||
|
games=$(scripts/changed-games.sh all)
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
before="${{ github.event.before }}"
|
before="${{ github.event.before }}"
|
||||||
if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then
|
if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then
|
||||||
@@ -78,10 +84,26 @@ jobs:
|
|||||||
|
|
||||||
# Secrets named in a game's test.yaml must be listed here to reach the boot test
|
# Secrets named in a game's test.yaml must be listed here to reach the boot test
|
||||||
- name: Boot test changed games
|
- name: Boot test changed games
|
||||||
|
id: boottest
|
||||||
env:
|
env:
|
||||||
DRAGONWILDS_OWNER_ID: ${{ secrets.DRAGONWILDS_OWNER_ID }}
|
DRAGONWILDS_OWNER_ID: ${{ secrets.DRAGONWILDS_OWNER_ID }}
|
||||||
run: scripts/boot-test.sh ${{ needs.validate.outputs.games }}
|
run: scripts/boot-test.sh ${{ needs.validate.outputs.games }}
|
||||||
|
|
||||||
|
# The weekly run: each game that no longer boots gets a repair job in the panel; the AI builder takes
|
||||||
|
# it, and opens a pull request with the fix
|
||||||
|
- name: Queue repairs for games that stopped booting
|
||||||
|
if: failure() && github.event_name == 'schedule' && steps.boottest.outputs.failed != ''
|
||||||
|
env:
|
||||||
|
PANEL_WEBHOOK_SECRET: ${{ secrets.PANEL_WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
games=$(printf '"%s",' ${{ steps.boottest.outputs.failed }})
|
||||||
|
curl -sS --fail-with-body --max-time 30 --retry 3 --retry-delay 10 \
|
||||||
|
-X POST "${{ env.PANEL_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Token: $PANEL_WEBHOOK_SECRET" \
|
||||||
|
-H "X-GBR-Event: games_boot_failed" \
|
||||||
|
-d "{\"games\":[${games%,}],\"run\":\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}"
|
||||||
|
|
||||||
# main is the source of truth: send every template to the panel, which saves the changed ones
|
# main is the source of truth: send every template to the panel, which saves the changed ones
|
||||||
# (new games arrive disabled). Runs only if the changed games booted, or none changed.
|
# (new games arrive disabled). Runs only if the changed games booted, or none changed.
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
+17
@@ -42,6 +42,23 @@ example is an invented server). `TestBuilderIsGameAgnostic` fails the build if o
|
|||||||
|
|
||||||
The worker only connects out (to the panel, Gitea, the model and the search services).
|
The worker only connects out (to the panel, Gitea, the model and the search services).
|
||||||
|
|
||||||
|
## Keeping games working
|
||||||
|
|
||||||
|
- **Repair** (`gbrgames repair -id ID`): boots an existing template as nodes run it. If it boots, there is
|
||||||
|
nothing to do. If not, the same investigation as for a draft runs, the model changes only what the
|
||||||
|
diagnosis says, and the worker opens `fix(games): repair …` on `draft/<id>` (replacing any open draft of
|
||||||
|
that game). The panel queues repairs from a game's admin page, or by itself (next point).
|
||||||
|
- **Weekly check**: `validate.yml` boot-tests every game each Monday. Each game that no longer boots is sent
|
||||||
|
to the panel (`X-GBR-Event: games_boot_failed`), which queues a repair job for it.
|
||||||
|
- **Eval** (`gbrgames eval -ids all`): builds games that already have a template from scratch, blind (the
|
||||||
|
builder never reads `games/`, and research skips our own sites), and scores each draft against the merged
|
||||||
|
template: boots, same image and Steam app, same ports, ready line, settings found. Run it before and after
|
||||||
|
changing the builder or the model. It takes as long as the builds; on the builder VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec gbr-builder sh -c 'cd /work && rm -rf eval && git clone -q https://www.gitea.gbreed.com/gbreed-public/gbr-games.git eval && cd eval && gbrgames eval -ids all -out eval.md && cat eval.md'
|
||||||
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the
|
A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the
|
||||||
|
|||||||
+20
-8
@@ -28,6 +28,9 @@ type buildResult struct {
|
|||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
// What the builder diagnosed after each failed boot, the fix it made, and how that went
|
// What the builder diagnosed after each failed boot, the fix it made, and how that went
|
||||||
Diagnoses []attempt `json:"diagnoses,omitempty"`
|
Diagnoses []attempt `json:"diagnoses,omitempty"`
|
||||||
|
// Repairs only: the existing template, and whether it already booted as it is (nothing to change)
|
||||||
|
Repair bool `json:"repair,omitempty"`
|
||||||
|
NoChange bool `json:"noChange,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
|
const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
|
||||||
@@ -159,16 +162,26 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Boot the draft the way nodes run it. A failure is investigated and diagnosed before the model
|
// 3. Boot the draft the way nodes run it
|
||||||
// changes anything; a fix that changes nothing rules its cause out
|
if err := bootAndFix(o, d, res, len(outsideDirs(report)) > 0); err != nil {
|
||||||
wantOutside := len(outsideDirs(report)) > 0
|
return res, err
|
||||||
|
}
|
||||||
|
res.Summary = buildSummary(o, res)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootAndFix boots the template in games/<id> the way nodes run it, until it passes. A failure is
|
||||||
|
// investigated and diagnosed before the model changes anything, and a fix that changes nothing rules its
|
||||||
|
// cause out. Builds use it on a fresh draft; repairs on an existing template, where a first boot that
|
||||||
|
// passes means there is nothing to repair. wantOutside also checks no files are lost on a restart.
|
||||||
|
func bootAndFix(o buildOptions, d *drafter, res *buildResult, wantOutside bool) error {
|
||||||
iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage)
|
iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage)
|
||||||
lastFailure := ""
|
lastFailure := ""
|
||||||
for boot := 1; boot <= o.bootRounds; boot++ {
|
for boot := 1; boot <= o.bootRounds; boot++ {
|
||||||
fmt.Printf("[build] boot %d of the draft with the node-agent\n", boot)
|
fmt.Printf("[build] boot %d of the template with the node-agent\n", boot)
|
||||||
bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside)
|
bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return err
|
||||||
}
|
}
|
||||||
failure := strings.Join(bootProblems, "\n")
|
failure := strings.Join(bootProblems, "\n")
|
||||||
iv.outcome(len(bootProblems) == 0, lastFailure, failure)
|
iv.outcome(len(bootProblems) == 0, lastFailure, failure)
|
||||||
@@ -200,7 +213,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|||||||
problems, err := d.refine(o.attempts, fixRequest(failure, diag))
|
problems, err := d.refine(o.attempts, fixRequest(failure, diag))
|
||||||
res.Rounds = d.rounds
|
res.Rounds = d.rounds
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return err
|
||||||
}
|
}
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
res.Problems = problems
|
res.Problems = problems
|
||||||
@@ -211,8 +224,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|||||||
if res.Passed {
|
if res.Passed {
|
||||||
res.Problems = nil
|
res.Problems = nil
|
||||||
}
|
}
|
||||||
res.Summary = buildSummary(o, res)
|
return nil
|
||||||
return res, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSteamCMDImage(image string) bool {
|
func isSteamCMDImage(image string) bool {
|
||||||
|
|||||||
@@ -79,3 +79,32 @@ configSchema:
|
|||||||
t.Errorf("message: %s", msg)
|
t.Errorf("message: %s", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScoreDraftAgainstTheRealTemplate(t *testing.T) {
|
||||||
|
real := map[string]any{"defaultDockerImage": "cm2network/steamcmd", "installMethod": "STEAMCMD",
|
||||||
|
"installConfig": map[string]any{"appId": "1000000"}, "defaultPorts": []any{"30000/udp", "30001/udp"},
|
||||||
|
"configSchema": map[string]any{"properties": map[string]any{"ServerName": map[string]any{}, "MaxPlayers": map[string]any{}}}}
|
||||||
|
draft := map[string]any{"defaultDockerImage": "cm2network/steamcmd", "installMethod": "STEAMCMD",
|
||||||
|
"installConfig": map[string]any{"appId": "1000000"}, "defaultPorts": []any{"30000/udp", "30002/tcp"},
|
||||||
|
"readyPattern": `Server is ready`,
|
||||||
|
"configSchema": map[string]any{"properties": map[string]any{"SERVER_NAME": map[string]any{}, "WorldName": map[string]any{}}}}
|
||||||
|
var s evalScore
|
||||||
|
scoreDraft(&s, real, map[string]any{"readyExample": "12:00:01 Server is ready for players"}, draft)
|
||||||
|
if !s.SameRoute || s.PortsSame || !s.ReadyMatch || s.SettingsHit != 1 || s.RealSetting != 2 || s.Settings != 2 {
|
||||||
|
t.Errorf("score %+v", s)
|
||||||
|
}
|
||||||
|
if strings.Join(s.PortsDiff, ",") != "extra 30002/tcp,missing 30001/udp" {
|
||||||
|
t.Errorf("ports diff %v", s.PortsDiff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRTextForBuildsAndRepairs(t *testing.T) {
|
||||||
|
_, title := prText("Example", "job1", &buildResult{Passed: true})
|
||||||
|
if title != "feat(games): add Example" {
|
||||||
|
t.Errorf("build title %q", title)
|
||||||
|
}
|
||||||
|
commit, title := prText("Example", "job1", &buildResult{Repair: true})
|
||||||
|
if title != "WIP: fix(games): repair Example (did not boot yet)" || !strings.HasPrefix(commit, "fix(games): repair Example") {
|
||||||
|
t.Errorf("repair %q / %q", title, commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,8 @@ type draftJob struct {
|
|||||||
// Text of the pages research based the variant on; when set, every launch option in the startup
|
// Text of the pages research based the variant on; when set, every launch option in the startup
|
||||||
// command must appear in it (or in the image's scripts)
|
// command must appear in it (or in the image's scripts)
|
||||||
Docs string
|
Docs string
|
||||||
|
// Repair: games/<id> is an existing template to fix, not a new game
|
||||||
|
Repair bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// drafter keeps what the model needs about one game across rounds. Each request is rebuilt from it
|
// drafter keeps what the model needs about one game across rounds. Each request is rebuilt from it
|
||||||
@@ -109,8 +111,8 @@ func newDrafter(j draftJob) (*drafter, error) {
|
|||||||
id = gameID(j.Name)
|
id = gameID(j.Name)
|
||||||
}
|
}
|
||||||
d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)}
|
d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)}
|
||||||
if _, err := os.Stat(d.dir); err == nil {
|
if _, err := os.Stat(d.dir); err == nil && !j.Repair {
|
||||||
return nil, fmt.Errorf("%s already exists; drafts only create new games", d.dir)
|
return nil, fmt.Errorf("%s already exists; drafts only create new games (repair fixes existing ones)", d.dir)
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if d.m, err = newModel(j.Provider, j.Model); err != nil {
|
if d.m, err = newModel(j.Provider, j.Model); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eval measures the builder: it builds games that already have a working template from scratch (research,
|
||||||
|
// first boot, draft, boots), without opening a pull request and without the builder seeing that template,
|
||||||
|
// and scores each draft against the merged one. Run it before and after changing the builder or the model
|
||||||
|
// to see whether the change helped.
|
||||||
|
|
||||||
|
type evalScore struct {
|
||||||
|
Game string `json:"game"`
|
||||||
|
Boots bool `json:"boots"`
|
||||||
|
SameRoute bool `json:"sameRoute"` // same image, and same Steam app when installed with SteamCMD
|
||||||
|
PortsSame bool `json:"portsSame"`
|
||||||
|
PortsDiff []string `json:"portsDiff,omitempty"`
|
||||||
|
ReadyMatch bool `json:"readyMatch"` // the draft's readyPattern matches the real server's ready line
|
||||||
|
Settings int `json:"settings"`
|
||||||
|
SettingsHit int `json:"settingsHit"` // of the real template's settings, how many the draft has too
|
||||||
|
RealSetting int `json:"realSettings"`
|
||||||
|
Rounds int `json:"rounds"`
|
||||||
|
Minutes float64 `json:"minutes"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEval(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("eval", flag.ExitOnError)
|
||||||
|
gamesDir := fs.String("games", "games", "the repo's games directory: the templates to compare against")
|
||||||
|
schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas")
|
||||||
|
which := fs.String("ids", "all", "comma-separated game ids, or all")
|
||||||
|
provider := fs.String("provider", "ollama", "ollama or gemini")
|
||||||
|
modelName := fs.String("model", "", "model name (default as for draft)")
|
||||||
|
out := fs.String("out", "eval.md", "write the report here (and the scores as JSON next to it)")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
ids, err := evalIDs(*gamesDir, *which)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := newModel(*provider, *modelName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var scores []evalScore
|
||||||
|
for _, id := range ids {
|
||||||
|
fmt.Printf("[eval] ===== %s =====\n", id)
|
||||||
|
s := evalGame(m, *gamesDir, *schemaDir, id, *provider, *modelName)
|
||||||
|
fmt.Printf("[eval] %s: boots %v, same route %v, ports same %v, ready matches %v, settings %d/%d\n",
|
||||||
|
id, s.Boots, s.SameRoute, s.PortsSame, s.ReadyMatch, s.SettingsHit, s.RealSetting)
|
||||||
|
scores = append(scores, s)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, []byte(evalReport(scores)), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, _ := json.MarshalIndent(scores, "", " ")
|
||||||
|
return os.WriteFile(strings.TrimSuffix(*out, filepath.Ext(*out))+".json", raw, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalIDs(gamesDir, which string) ([]string, error) {
|
||||||
|
if which != "all" {
|
||||||
|
return strings.Split(which, ","), nil
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(gamesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if _, err := os.Stat(filepath.Join(gamesDir, e.Name(), "game.yaml")); err == nil {
|
||||||
|
ids = append(ids, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, errors.New("no templates to evaluate against")
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalGame builds one game blind, in an empty games folder, and compares the draft with the real template.
|
||||||
|
func evalGame(m model, gamesDir, schemaDir, id, provider, modelName string) evalScore {
|
||||||
|
s := evalScore{Game: id}
|
||||||
|
start := time.Now()
|
||||||
|
defer func() { s.Minutes = time.Since(start).Minutes() }()
|
||||||
|
real, err := loadMap(filepath.Join(gamesDir, id, "game.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
s.Error = err.Error()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
realTest, _ := loadMap(filepath.Join(gamesDir, id, "test.yaml"))
|
||||||
|
name, _ := real["name"].(string)
|
||||||
|
|
||||||
|
res, err := research(m, name, "", 6)
|
||||||
|
if err != nil {
|
||||||
|
s.Error = "research: " + err.Error()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var chosen *variant
|
||||||
|
for i := range res.Variants {
|
||||||
|
if res.Variants[i].Recommended {
|
||||||
|
chosen = &res.Variants[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
s.Error = "research recommended no runnable option: " + res.Message
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
blind, err := os.MkdirTemp("", "gbr-eval-")
|
||||||
|
if err != nil {
|
||||||
|
s.Error = err.Error()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(blind)
|
||||||
|
built, err := build(buildOptions{gamesDir: blind, schemaDir: schemaDir, name: name, id: id, variant: *chosen,
|
||||||
|
provider: provider, model: modelName, attempts: 3, bootRounds: 5, agentImage: agentImageFromEnv()})
|
||||||
|
if built != nil {
|
||||||
|
s.Boots, s.Rounds = built.Passed, built.Rounds
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.Error = "build: " + err.Error()
|
||||||
|
}
|
||||||
|
draft, derr := loadMap(filepath.Join(blind, id, "game.yaml"))
|
||||||
|
if derr != nil {
|
||||||
|
if s.Error == "" {
|
||||||
|
s.Error = "no draft written"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
scoreDraft(&s, real, realTest, draft)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreDraft compares a draft with the real template.
|
||||||
|
func scoreDraft(s *evalScore, real, realTest, draft map[string]any) {
|
||||||
|
s.SameRoute = real["defaultDockerImage"] == draft["defaultDockerImage"] && steamAppOf(real) == steamAppOf(draft)
|
||||||
|
|
||||||
|
want, got := portSet(real), portSet(draft)
|
||||||
|
for p := range want {
|
||||||
|
if !got[p] {
|
||||||
|
s.PortsDiff = append(s.PortsDiff, "missing "+p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p := range got {
|
||||||
|
if !want[p] {
|
||||||
|
s.PortsDiff = append(s.PortsDiff, "extra "+p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(s.PortsDiff)
|
||||||
|
s.PortsSame = len(s.PortsDiff) == 0
|
||||||
|
|
||||||
|
if pattern, _ := draft["readyPattern"].(string); pattern != "" {
|
||||||
|
example, _ := realTest["readyExample"].(string)
|
||||||
|
if re, err := regexp.Compile(pattern); err == nil && example != "" {
|
||||||
|
s.ReadyMatch = re.MatchString(example)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
realKeys := map[string]bool{}
|
||||||
|
for k := range schemaProperties(real) {
|
||||||
|
realKeys[envName(k)] = true
|
||||||
|
}
|
||||||
|
s.RealSetting = len(realKeys)
|
||||||
|
for k := range schemaProperties(draft) {
|
||||||
|
s.Settings++
|
||||||
|
if realKeys[envName(k)] {
|
||||||
|
s.SettingsHit++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func steamAppOf(g map[string]any) string {
|
||||||
|
if g["installMethod"] != "STEAMCMD" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ic, _ := g["installConfig"].(map[string]any)
|
||||||
|
return fmt.Sprint(ic["appId"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// portSet is a template's published container ports, as "port/proto".
|
||||||
|
func portSet(g map[string]any) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
ports, _ := g["defaultPorts"].([]any)
|
||||||
|
for _, p := range ports {
|
||||||
|
out[containerPort(fmt.Sprint(p))] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalReport(scores []evalScore) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Builder evaluation\n\nEach game was built blind (research, first boot, draft, boots) and compared " +
|
||||||
|
"with its merged template.\n\n")
|
||||||
|
b.WriteString("| Game | Boots | Same image/app | Ports | Ready line | Settings found | Rounds | Minutes | Note |\n")
|
||||||
|
b.WriteString("| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n")
|
||||||
|
passed := 0
|
||||||
|
for _, s := range scores {
|
||||||
|
if s.Boots {
|
||||||
|
passed++
|
||||||
|
}
|
||||||
|
ports := "same"
|
||||||
|
if !s.PortsSame {
|
||||||
|
ports = strings.Join(s.PortsDiff, ", ")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %d of %d (draft has %d) | %d | %.0f | %s |\n", s.Game, mark(s.Boots), mark(s.SameRoute),
|
||||||
|
ports, mark(s.ReadyMatch), s.SettingsHit, s.RealSetting, s.Settings, s.Rounds, s.Minutes, s.Error)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n%d of %d drafts booted.\n", passed, len(scores))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mark(ok bool) string {
|
||||||
|
if ok {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
// go run ./cmd/gbrgames draft -name N -image I # have an AI model write a new game's template from those facts
|
// go run ./cmd/gbrgames draft -name N -image I # have an AI model write a new game's template from those facts
|
||||||
// go run ./cmd/gbrgames research -name N # find how a game's server runs: variants, images, Steam app
|
// go run ./cmd/gbrgames research -name N # find how a game's server runs: variants, images, Steam app
|
||||||
// go run ./cmd/gbrgames build -name N -variant V # first boot, draft, and boot the draft until it works
|
// go run ./cmd/gbrgames build -name N -variant V # first boot, draft, and boot the draft until it works
|
||||||
// go run ./cmd/gbrgames worker # take research/build jobs from the panel (see BUILDER.md)
|
// go run ./cmd/gbrgames repair -id ID # fix an existing template that no longer boots
|
||||||
|
// go run ./cmd/gbrgames eval -ids all # build known games blind and score the drafts
|
||||||
|
// go run ./cmd/gbrgames worker # take research/build/repair jobs from the panel (see BUILDER.md)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -41,6 +43,10 @@ func main() {
|
|||||||
err = runResearch(os.Args[2:])
|
err = runResearch(os.Args[2:])
|
||||||
case "build":
|
case "build":
|
||||||
err = runBuild(os.Args[2:])
|
err = runBuild(os.Args[2:])
|
||||||
|
case "repair":
|
||||||
|
err = runRepair(os.Args[2:])
|
||||||
|
case "eval":
|
||||||
|
err = runEval(os.Args[2:])
|
||||||
case "worker":
|
case "worker":
|
||||||
err = runWorker(os.Args[2:])
|
err = runWorker(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
@@ -53,6 +59,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR] | draft -name NAME -image IMAGE [-steam-app ID] [-first-boot REPORT] [-provider ollama|gemini] [-model M] | research -name NAME [-notes N] [-out FILE] | build -name NAME -variant JSON|@FILE [-out FILE] | worker [-once]")
|
fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR] | draft -name NAME -image IMAGE [-steam-app ID] [-first-boot REPORT] [-provider ollama|gemini] [-model M] | research -name NAME [-notes N] [-out FILE] | build -name NAME -variant JSON|@FILE [-out FILE] | repair -id ID [-reason R] | eval [-ids all] [-out eval.md] | worker [-once]")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// repair fixes an existing template that stopped working (a game update, a changed image), the way build
|
||||||
|
// fixes a fresh draft: the template is booted as nodes run it, a failure is investigated and diagnosed,
|
||||||
|
// and the model changes only what the diagnosis says. A template that boots as it is needs no repair.
|
||||||
|
// The result replaces games/<id>/ in a pull request.
|
||||||
|
|
||||||
|
func runRepair(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("repair", flag.ExitOnError)
|
||||||
|
gamesDir := fs.String("games", "games", "directory holding one folder per game")
|
||||||
|
schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas")
|
||||||
|
id := fs.String("id", "", "the game to repair (its folder under -games)")
|
||||||
|
reason := fs.String("reason", "", "why it is being repaired, e.g. the failing boot test")
|
||||||
|
provider := fs.String("provider", "ollama", "ollama or gemini")
|
||||||
|
modelName := fs.String("model", "", "model name (default as for draft)")
|
||||||
|
attempts := fs.Int("attempts", 3, "fix rounds for validation problems, each time")
|
||||||
|
bootRounds := fs.Int("boot-rounds", 5, "boots of the template at most")
|
||||||
|
out := fs.String("out", "", "write the result JSON here")
|
||||||
|
fs.Parse(args)
|
||||||
|
if *id == "" {
|
||||||
|
return errors.New("repair needs -id")
|
||||||
|
}
|
||||||
|
res, err := repair(buildOptions{gamesDir: *gamesDir, schemaDir: *schemaDir, id: *id, notes: *reason,
|
||||||
|
provider: *provider, model: *modelName, attempts: *attempts, bootRounds: *bootRounds, agentImage: agentImageFromEnv()})
|
||||||
|
if res != nil && *out != "" {
|
||||||
|
raw, _ := json.MarshalIndent(res, "", " ")
|
||||||
|
if werr := os.WriteFile(*out, raw, 0o644); werr != nil && err == nil {
|
||||||
|
err = werr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case res.NoChange:
|
||||||
|
fmt.Printf("[repair] %s boots as it is: nothing to repair\n", res.Dir)
|
||||||
|
case res.Passed:
|
||||||
|
fmt.Printf("[repair] %s repaired and booted with the node-agent\n", res.Dir)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s still has problems:\n- %s", res.Dir, strings.Join(res.Problems, "\n- "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func repair(o buildOptions) (*buildResult, error) {
|
||||||
|
dir := filepath.Join(o.gamesDir, o.id)
|
||||||
|
g, err := loadMap(filepath.Join(dir, "game.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
test, _ := loadMap(filepath.Join(dir, "test.yaml"))
|
||||||
|
if test == nil {
|
||||||
|
test = map[string]any{}
|
||||||
|
}
|
||||||
|
o.name, _ = g["name"].(string)
|
||||||
|
image, _ := g["defaultDockerImage"].(string)
|
||||||
|
steamApp := ""
|
||||||
|
if ic, ok := g["installConfig"].(map[string]any); ok && g["installMethod"] == "STEAMCMD" {
|
||||||
|
steamApp = fmt.Sprint(ic["appId"])
|
||||||
|
}
|
||||||
|
res := &buildResult{GameID: o.id, Dir: dir, Image: image, Repair: true}
|
||||||
|
if out, err := runCmd("docker", "pull", "-q", image); err != nil {
|
||||||
|
return res, fmt.Errorf("docker pull %s: %v: %s", image, err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model starts from the template as it is, as if it had written it, with the facts read from the
|
||||||
|
// image; it only ever gets asked to fix what a diagnosis found
|
||||||
|
notes := "this game already has a template (your previous answer) that a node can no longer run. Repair it: " +
|
||||||
|
"change only what each diagnosis says, and keep everything that works"
|
||||||
|
if o.notes != "" {
|
||||||
|
notes += ". Why it is being repaired: " + o.notes
|
||||||
|
}
|
||||||
|
d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: image,
|
||||||
|
SteamApp: steamApp, Notes: notes, Provider: o.provider, Model: o.model, NoPull: true, Repair: true})
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
current, err := json.Marshal(map[string]any{"game": g, "test": test})
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
d.last = string(current)
|
||||||
|
|
||||||
|
if err := bootAndFix(o, d, res, true); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.NoChange = res.Passed && d.rounds == 0
|
||||||
|
if res.NoChange {
|
||||||
|
res.Verified = append(res.Verified, "the template boots as it is: nothing to repair")
|
||||||
|
}
|
||||||
|
res.Summary = repairSummary(o, res)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairSummary is the pull request's description for a repair.
|
||||||
|
func repairSummary(o buildOptions, res *buildResult) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Repaired by the GBR AI builder. **Review before merging**: the diff shows exactly what changed.\n\n")
|
||||||
|
fmt.Fprintf(&b, "- Game: **%s** (`games/%s`)\n- Image: `%s`\n- Model rounds: %d\n", o.name, o.id, res.Image, res.Rounds)
|
||||||
|
if o.notes != "" {
|
||||||
|
fmt.Fprintf(&b, "- Why: %s\n", o.notes)
|
||||||
|
}
|
||||||
|
if len(res.Verified) > 0 {
|
||||||
|
b.WriteString("\n**Checked for real** (measured by the builder, not the model's word)\n")
|
||||||
|
for _, v := range res.Verified {
|
||||||
|
fmt.Fprintf(&b, "- %s\n", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g, err := loadMap(filepath.Join(res.Dir, "game.yaml")); err == nil {
|
||||||
|
if urls := downloadURLs(g); len(urls) > 0 {
|
||||||
|
b.WriteString("\n**Review carefully: nodes download from these addresses**\n")
|
||||||
|
for _, u := range urls {
|
||||||
|
fmt.Fprintf(&b, "- %s\n", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(res.Problems) > 0 {
|
||||||
|
b.WriteString("\n**Still open** (the builder could not fix these)\n")
|
||||||
|
for _, p := range res.Problems {
|
||||||
|
fmt.Fprintf(&b, "- %s\n", firstLine(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(res.Diagnoses) > 0 {
|
||||||
|
b.WriteString("\n**What the builder found** (after each failed boot)\n")
|
||||||
|
for i, a := range res.Diagnoses {
|
||||||
|
fmt.Fprintf(&b, "%d. Cause: %s\n Fix: %s\n Outcome: %s\n", i+1, firstLine(a.Cause), firstLine(a.Fix), a.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -237,11 +237,15 @@ func pickPages(hits []searchHit, n int) []searchHit {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Our own sites (the public templates, the panel) are never a source: research would read our templates
|
||||||
|
// back, and an eval build would copy the answer it's being scored against
|
||||||
|
var ownSites = regexp.MustCompile(`(?i)^https?://([a-z0-9-]+\.)*(gbreed\.com|gbrhosting\.com)(/|:|$)`)
|
||||||
|
|
||||||
func dedupeHits(hits []searchHit) []searchHit {
|
func dedupeHits(hits []searchHit) []searchHit {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []searchHit
|
var out []searchHit
|
||||||
for _, h := range hits {
|
for _, h := range hits {
|
||||||
if h.URL == "" || seen[h.URL] {
|
if h.URL == "" || seen[h.URL] || ownSites.MatchString(h.URL) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[h.URL] = true
|
seen[h.URL] = true
|
||||||
|
|||||||
@@ -112,3 +112,14 @@ func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResearchSkipsOurOwnSites(t *testing.T) {
|
||||||
|
got := dedupeHits([]searchHit{
|
||||||
|
{URL: "https://www.gitea.gbreed.com/gbreed-public/gbr-games/src/branch/main/games/x/game.yaml"},
|
||||||
|
{URL: "https://gbrhosting.com/games"},
|
||||||
|
{URL: "https://example.com/server-guide"},
|
||||||
|
})
|
||||||
|
if len(got) != 1 || got[0].URL != "https://example.com/server-guide" {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-7
@@ -32,7 +32,8 @@ import (
|
|||||||
|
|
||||||
type builderJob struct {
|
type builderJob struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Kind string `json:"kind"` // "research" or "build"
|
Kind string `json:"kind"` // "research", "build" or "repair"
|
||||||
|
GameID string `json:"gameId"` // repair: the template to fix
|
||||||
GameName string `json:"gameName"`
|
GameName string `json:"gameName"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
Variant json.RawMessage `json:"variant"`
|
Variant json.RawMessage `json:"variant"`
|
||||||
@@ -43,6 +44,8 @@ type jobResult struct {
|
|||||||
Build *buildResult `json:"build,omitempty"`
|
Build *buildResult `json:"build,omitempty"`
|
||||||
PRURL string `json:"prUrl,omitempty"`
|
PRURL string `json:"prUrl,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
// A repair whose template already boots: no pull request
|
||||||
|
NoChange bool `json:"noChange,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type workerConfig struct {
|
type workerConfig struct {
|
||||||
@@ -258,6 +261,30 @@ func (c *workerConfig) handle(job *builderJob) *jobResult {
|
|||||||
return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()}
|
return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()}
|
||||||
}
|
}
|
||||||
return &jobResult{Build: res, PRURL: pr}
|
return &jobResult{Build: res, PRURL: pr}
|
||||||
|
|
||||||
|
case "repair":
|
||||||
|
if job.GameID == "" {
|
||||||
|
return &jobResult{Error: "a repair job needs gameId"}
|
||||||
|
}
|
||||||
|
args := append([]string{"repair", "-id", job.GameID, "-reason", job.Notes, "-out", resultFile}, modelArgs...)
|
||||||
|
runErr := c.runStreamed(job.ID, dir, self, args, "BOOT_TEST")
|
||||||
|
res := &buildResult{}
|
||||||
|
if err := readJSON(resultFile, res); err != nil {
|
||||||
|
msg := "the repair produced no result"
|
||||||
|
if runErr != nil {
|
||||||
|
msg = runErr.Error()
|
||||||
|
}
|
||||||
|
return &jobResult{Error: msg}
|
||||||
|
}
|
||||||
|
if res.NoChange {
|
||||||
|
return &jobResult{Build: res, NoChange: true}
|
||||||
|
}
|
||||||
|
c.progress(job.ID, "OPENING_PR", []string{"[worker] opening the pull request"})
|
||||||
|
pr, err := c.openPR(dir, job, res)
|
||||||
|
if err != nil {
|
||||||
|
return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()}
|
||||||
|
}
|
||||||
|
return &jobResult{Build: res, PRURL: pr}
|
||||||
}
|
}
|
||||||
return &jobResult{Error: "unknown job kind " + job.Kind}
|
return &jobResult{Error: "unknown job kind " + job.Kind}
|
||||||
}
|
}
|
||||||
@@ -369,14 +396,22 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st
|
|||||||
summary += fmt.Sprintf("\nReplaces #%d (closed, branch deleted).\n", n)
|
summary += fmt.Sprintf("\nReplaces #%d (closed, branch deleted).\n", n)
|
||||||
}
|
}
|
||||||
auth := "http.extraHeader=Authorization: token " + c.botToken
|
auth := "http.extraHeader=Authorization: token " + c.botToken
|
||||||
|
commit, title := prText(job.GameName, job.ID, res)
|
||||||
|
if res.Repair {
|
||||||
|
// A repair that changed nothing has nothing to propose
|
||||||
|
diff := exec.Command("git", "status", "--porcelain", "--", filepath.ToSlash(res.Dir))
|
||||||
|
diff.Dir = dir
|
||||||
|
if out, _ := diff.Output(); len(strings.TrimSpace(string(out))) == 0 {
|
||||||
|
return "", errors.New("the repair changed nothing in the template: " + strings.Join(res.Problems, "; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
steps := []struct {
|
steps := []struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
}{
|
}{
|
||||||
{"checkout", []string{"checkout", "-q", "-b", branch}},
|
{"checkout", []string{"checkout", "-q", "-b", branch}},
|
||||||
{"add", []string{"add", filepath.ToSlash(res.Dir)}},
|
{"add", []string{"add", filepath.ToSlash(res.Dir)}},
|
||||||
{"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m",
|
{"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m", commit}},
|
||||||
fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", job.GameName, job.ID)}},
|
|
||||||
// Forced: the branch is the bot's own, and whatever was on it was the draft being replaced
|
// Forced: the branch is the bot's own, and whatever was on it was the draft being replaced
|
||||||
{"push", []string{"-c", auth, "push", "-q", "--force", "origin", branch}},
|
{"push", []string{"-c", auth, "push", "-q", "--force", "origin", branch}},
|
||||||
}
|
}
|
||||||
@@ -388,10 +423,6 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st
|
|||||||
return "", fmt.Errorf("git %s: %s", s.name, strings.ReplaceAll(string(out), c.botToken, "***"))
|
return "", fmt.Errorf("git %s: %s", s.name, strings.ReplaceAll(string(out), c.botToken, "***"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title := "feat(games): add " + job.GameName
|
|
||||||
if !res.Passed {
|
|
||||||
title = "WIP: " + title + " (did not boot yet)"
|
|
||||||
}
|
|
||||||
var pr struct {
|
var pr struct {
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
}
|
}
|
||||||
@@ -489,3 +520,17 @@ func readJSON(p string, out any) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(raw, out)
|
return json.Unmarshal(raw, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prText is the commit message and pull request title for a new game or a repair.
|
||||||
|
func prText(gameName, jobID string, res *buildResult) (commit, title string) {
|
||||||
|
commit = fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", gameName, jobID)
|
||||||
|
title = "feat(games): add " + gameName
|
||||||
|
if res.Repair {
|
||||||
|
commit = fmt.Sprintf("fix(games): repair %s\n\nRepaired and boot-tested by the GBR builder (job %s).", gameName, jobID)
|
||||||
|
title = "fix(games): repair " + gameName
|
||||||
|
}
|
||||||
|
if !res.Passed {
|
||||||
|
title = "WIP: " + title + " (did not boot yet)"
|
||||||
|
}
|
||||||
|
return commit, title
|
||||||
|
}
|
||||||
@@ -54,4 +54,6 @@ done
|
|||||||
echo "passed: ${passed[*]:-none}"
|
echo "passed: ${passed[*]:-none}"
|
||||||
echo "skipped: ${skipped[*]:-none} (missing secrets)"
|
echo "skipped: ${skipped[*]:-none} (missing secrets)"
|
||||||
echo "failed: ${failed[*]:-none}"
|
echo "failed: ${failed[*]:-none}"
|
||||||
|
# For the workflow: the weekly run queues a repair for each failed game
|
||||||
|
[ -n "${GITHUB_OUTPUT:-}" ] && echo "failed=${failed[*]}" >> "$GITHUB_OUTPUT"
|
||||||
[ ${#failed[@]} -eq 0 ]
|
[ ${#failed[@]} -eq 0 ]
|
||||||
Reference in new issue
Block a user