ci: boot-test changed games before publishing
Validate game templates / validate (push) Successful in 9m32s
Validate game templates / publish (push) Canceled after 0s
Validate game templates / boot-test (push) Canceled after 5m15s

Only games whose folder changed are boot-tested (against the previous
main commit, or against main on branches; manual runs pick games). Each
one is deployed by the node-agent's real deploy code on a dedicated
game-test runner, in a network that can reach the internet but not the
LAN. Publishing to the panel runs only if they all booted.

- boottest-payload builds the DEPLOY_SERVER message the panel would send
- readyPattern/failPatterns in game.yaml; readyExample in test.yaml is
  checked against the pattern during validation
- secrets in test.yaml come from CI; a game missing one is skipped
- Minecraft gets a ready pattern; Dragonwilds needs DRAGONWILDS_OWNER_ID
- RUNNER.md + scripts/runner: runner, isolated network, systemd unit

Also fixes newline values ("\n" separators/footers) that yaml.v3 wrote
as "" on import, which would have broken ARK and Dragonwilds config
files once synced, and adds a round-trip test for the importer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-24 17:49:19 -04:00
1 parent 5fb7736be2
commit e9ff989246
21 files changed
+585 -18

No files matched your search

+46 -4
View File
@@ -8,6 +8,7 @@ import (
"math"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -21,7 +22,7 @@ var gameFields = []string{
"installMethod", "installConfig",
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig",
"logFilePath", "backupPaths", "maxBackupSizeMb",
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey",
"aiCoverPrompt",
}
@@ -82,11 +83,11 @@ func writeGame(path string, g map[string]any) error {
if !ok || isEmpty(v) {
continue
}
var val yaml.Node
if err := val.Encode(normalize(v)); err != nil {
val, err := toNode(normalize(v))
if err != nil {
return err
}
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, &val)
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
}
return writeYAML(path, gameHeader, doc)
}
@@ -128,6 +129,47 @@ func writeYAML(path, header string, doc *yaml.Node) error {
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
// whitespace, like a "\n" config separator, into "" and the value is silently lost.
func toNode(v any) (*yaml.Node, error) {
switch x := v.(type) {
case map[string]any:
n := &yaml.Node{Kind: yaml.MappingNode}
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val, err := toNode(x[k])
if err != nil {
return nil, err
}
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
}
return n, nil
case []any:
n := &yaml.Node{Kind: yaml.SequenceNode}
for _, e := range x {
val, err := toNode(e)
if err != nil {
return nil, err
}
n.Content = append(n.Content, val)
}
return n, nil
case string:
if x != "" && strings.TrimSpace(x) == "" {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
}
}
n := &yaml.Node{}
if err := n.Encode(v); err != nil {
return nil, err
}
return n, nil
}
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
func normalize(v any) any {
switch x := v.(type) {