gowalk-cicd 1.0.187 → 1.0.189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -341,7 +341,9 @@ Other factory containers plus the proposed stack may reserve at most 70% of phys
341
341
  10% (and 512 MiB) stays available after additional reservations. One-minute load above twice the CPU
342
342
  count or full memory pressure of 1% refuses a start. Starts share a host lock (120-second acquisition
343
343
  limit); image builds stay parallel. A failed admission emits `backend_runtime_refused` with a fixed
344
- `gowalk-cicd/backend-runtime-refused.v1 code=…` and leaves existing containers running.
344
+ `gowalk-cicd/backend-runtime-refused.v1 code=…` and leaves existing containers running. A shared host's
345
+ [memory agent](docs/BACKEND_MEMORY.md) keeps running reservations at measured use, and a redeploy is charged only
346
+ for growth beyond the reservations its project already holds.
345
347
 
346
348
  An operator-managed `/opt/gowalk-backend-runtime/<app>.json`, outside the application mirror, may set
347
349
  `schema: "gowalk-cicd/backend-runtime.v1"`, `disabled: true`, `remove_services: ["db"]`, and per-service
@@ -0,0 +1,229 @@
1
+ """Keep running backend reservations at measured use and compress idle app memory into zswap."""
2
+ import argparse
3
+ import calendar
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from pathlib import Path
13
+
14
+ from native_profile import running_native
15
+ from runtime_host import AGENT_STATE
16
+ from runtime_model import MIB
17
+
18
+ SCHEMA = "gowalk-cicd/backend-memory.v1"
19
+ APPS = "/opt/gowalk-backends/"
20
+ PROC = Path("/proc")
21
+ CGROUP = Path("/sys/fs/cgroup")
22
+ ZSWAP = Path("/sys/module/zswap/parameters/enabled")
23
+ SETTLE_SECONDS = 600
24
+ DEFAULT, FLOOR, STEP, HYSTERESIS = 64 * MIB, 16 * MIB, 4 * MIB, 8 * MIB
25
+ DATASTORES = ("postgres", "postgis", "timescale", "redis", "valkey", "keydb", "mysql", "mariadb", "mongo",
26
+ "memcached")
27
+ TEMPLATE = ('{"id":{{json .Id}},"name":{{json .Name}},"running":{{.State.Running}},"pid":{{.State.Pid}},'
28
+ '"started":{{json .State.StartedAt}},"image":{{json .Config.Image}},"cmd":{{json .Config.Cmd}},'
29
+ '"labels":{{json .Config.Labels}},"reservation":{{.HostConfig.MemoryReservation}},'
30
+ '"limit":{{.HostConfig.Memory}}}')
31
+
32
+
33
+ def discover():
34
+ """Every existing container ID (a stopped one keeps its reservation), and the running ones in admission's scope."""
35
+ try:
36
+ ids = subprocess.run(["docker", "ps", "-a", "-q", "--no-trunc"], capture_output=True, text=True, timeout=30,
37
+ check=True).stdout.split()
38
+ # Unchecked: a container removed between both calls is only missing from inspect's output.
39
+ output = subprocess.run(["docker", "inspect", "--format", TEMPLATE, *ids], capture_output=True, text=True,
40
+ timeout=60).stdout if ids else ""
41
+ except (OSError, subprocess.SubprocessError):
42
+ raise ValueError("docker_unavailable") from None
43
+ containers = []
44
+ for line in output.splitlines():
45
+ container = json.loads(line)
46
+ labels = container.pop("labels") or {}
47
+ if container.pop("running") and labels.get("com.docker.compose.project.working_dir", "").startswith(APPS):
48
+ containers.append(dict(container, project=labels.get("com.docker.compose.project", ""),
49
+ service=labels.get("com.docker.compose.service", "")))
50
+ return ids, containers
51
+
52
+
53
+ def meminfo():
54
+ pairs = (line.split(":", 1) for line in (PROC / "meminfo").read_text().splitlines())
55
+ return {key: int(value.split()[0]) * 1024 for key, value in pairs}
56
+
57
+
58
+ def cgroup_dir(container):
59
+ for line in (PROC / str(container["pid"]) / "cgroup").read_text().splitlines():
60
+ # A reused PID names another cgroup, which is never measured or reclaimed.
61
+ if line.startswith("0::") and container["id"] in line:
62
+ return CGROUP / line[3:].lstrip("/")
63
+ raise FileNotFoundError(container["id"]) # the container's process has exited
64
+
65
+
66
+ def measure(directory, store):
67
+ """The working set, anon and pressure; a datastore's working set also leaves out its active file cache."""
68
+ stat = dict(line.split() for line in (directory / "memory.stat").read_text().splitlines())
69
+ cache = int(stat["inactive_file"]) + (int(stat["active_file"]) if store else 0)
70
+ working_set = int((directory / "memory.current").read_text()) - cache
71
+ pressure = 0.0
72
+ for line in (directory / "memory.pressure").read_text().splitlines():
73
+ if line.startswith("some "):
74
+ pressure = float(dict(part.split("=") for part in line.split()[1:])["avg60"])
75
+ return working_set, int(stat["anon"]), pressure
76
+
77
+
78
+ def next_reservation(current, working_set, limit):
79
+ """Working set plus 20% in 4 MiB steps, 16 MiB to the limit; rise at once, fall by 8 MiB or more."""
80
+ target = max(FLOOR, -(-working_set * 6 // (5 * STEP)) * STEP)
81
+ target = min(target, limit) if limit > 0 else target
82
+ return target if not current or target > current or target <= current - HYSTERESIS else current
83
+
84
+
85
+ def datastore(container):
86
+ names = " ".join([container["image"], container["service"],
87
+ os.path.basename((container["cmd"] or [""])[0])]).lower()
88
+ return container["service"].lower() in ("db", "database") or any(word in names for word in DATASTORES)
89
+
90
+
91
+ def record(container_id, reservation):
92
+ """Keep the reservation admission counted before the agent first changes it; a record is never overwritten."""
93
+ path = AGENT_STATE / container_id
94
+ if path.exists():
95
+ return
96
+ AGENT_STATE.mkdir(mode=0o755, exist_ok=True)
97
+ descriptor, temporary = tempfile.mkstemp(prefix=".record-", dir=str(AGENT_STATE))
98
+ try:
99
+ with os.fdopen(descriptor, "w") as stream:
100
+ stream.write("%d\n" % reservation)
101
+ stream.flush()
102
+ os.fsync(stream.fileno()) # Docker persists the update: a crash must not leave an empty record
103
+ os.chmod(temporary, 0o644)
104
+ os.replace(temporary, str(path))
105
+ except OSError:
106
+ os.unlink(temporary)
107
+ raise
108
+ directory = os.open(str(AGENT_STATE), os.O_RDONLY)
109
+ try:
110
+ os.fsync(directory)
111
+ finally:
112
+ os.close(directory)
113
+
114
+
115
+ def prune(ids):
116
+ """Forget the records of containers that no longer exist; no other name is ever touched."""
117
+ for name in os.listdir(str(AGENT_STATE)) if AGENT_STATE.is_dir() else []:
118
+ if re.fullmatch("[0-9a-f]{64}", name) and name not in ids:
119
+ (AGENT_STATE / name).unlink()
120
+
121
+
122
+ def update_reservation(container_id, reservation):
123
+ subprocess.run(["docker", "update", "--memory-reservation", str(reservation), container_id],
124
+ capture_output=True, timeout=30, check=True)
125
+
126
+
127
+ def reclaim(directory, mib):
128
+ descriptor = os.open(str(directory / "memory.reclaim"), os.O_WRONLY)
129
+ try:
130
+ os.write(descriptor, b"%dM" % mib)
131
+ finally:
132
+ os.close(descriptor)
133
+
134
+
135
+ def handle(container, now, zswap, dry_run):
136
+ """Measure one container first, then record and update its reservation and reclaim its cold memory."""
137
+ entry = {"name": container["name"].lstrip("/"), "id": container["id"][:12], "project": container["project"],
138
+ "service": container["service"]}
139
+ current, store = container["reservation"], datastore(container)
140
+ try:
141
+ started = calendar.timegm(time.strptime(container["started"][:19], "%Y-%m-%dT%H:%M:%S"))
142
+ if now - started < SETTLE_SECONDS: # startup imports have not settled yet
143
+ return dict(entry, settling=True)
144
+ directory = cgroup_dir(container)
145
+ working_set, anon, pressure = measure(directory, store)
146
+ except (FileNotFoundError, ProcessLookupError):
147
+ return dict(entry, exited=True)
148
+ except (OSError, ValueError, KeyError):
149
+ return dict(entry, error="measure_failed")
150
+ target = next_reservation(current, working_set, container["limit"])
151
+ if store and target <= (current or DEFAULT):
152
+ target = current # datastores only rise: a quiet moment must not cut the cache they refill under load
153
+ amount = 0 if not zswap or pressure >= 0.5 or store else anon // 4 // MIB
154
+ entry.update(working_set_mib=working_set // MIB, reservation_mib=current // MIB, target_mib=target // MIB,
155
+ reclaim_mib=amount, updated=target != current)
156
+ if dry_run:
157
+ return entry
158
+ if target != current:
159
+ error = "record_failed" # admission credits the declared reservation only from this record
160
+ try:
161
+ record(container["id"], current or DEFAULT)
162
+ error = "reservation_update_failed"
163
+ update_reservation(container["id"], target)
164
+ except (OSError, subprocess.SubprocessError):
165
+ entry.update(updated=False, error=error)
166
+ if amount:
167
+ try:
168
+ reclaim(directory, amount)
169
+ except OSError: # EAGAIN: the kernel reclaimed less than requested
170
+ entry["partial"] = True
171
+ return entry
172
+
173
+
174
+ def run(dry_run, now=None):
175
+ ids, containers = discover()
176
+ now = time.time() if now is None else now
177
+ try:
178
+ zswap = meminfo().get("SwapTotal", 0) > 0 and ZSWAP.read_text().strip() == "Y"
179
+ except OSError:
180
+ zswap = False
181
+ with ThreadPoolExecutor(max_workers=8) as pool:
182
+ entries = list(pool.map(lambda container: handle(container, now, zswap, dry_run), containers))
183
+ reserved = sum((entry["target_mib"] * MIB if entry.get("updated") else container["reservation"]) or DEFAULT
184
+ for container, entry in zip(containers, entries))
185
+ if not dry_run:
186
+ try:
187
+ prune(set(ids))
188
+ except OSError:
189
+ entries.append({"error": "prune_failed"})
190
+ for entry in entries:
191
+ if dry_run or "error" in entry:
192
+ print(json.dumps(entry))
193
+ elif entry.get("updated"): # the rollback history
194
+ print(json.dumps({"name": entry["name"], "id": entry["id"],
195
+ "from_mib": entry["reservation_mib"] or DEFAULT // MIB, "to_mib": entry["target_mib"]}))
196
+ try:
197
+ natives = sum(unit["HostConfig"]["MemoryReservation"] for unit in running_native())
198
+ except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
199
+ natives = None
200
+ memory = meminfo()
201
+ reserved_mib, cap_mib = (reserved + (natives or 0)) // MIB, int(memory["MemTotal"] * 0.7) // MIB
202
+
203
+ def count(key):
204
+ return sum(1 for entry in entries if entry.get(key))
205
+
206
+ return {"schema": SCHEMA, "dry_run": dry_run, "containers": len(containers), "settling": count("settling"),
207
+ "updated": count("updated"), "partial": count("partial"), "errors": count("error"), "zswap": zswap,
208
+ "reclaimed_mib": sum(entry.get("reclaim_mib", 0) for entry in entries if not entry.get("partial")),
209
+ "released_mib": sum((entry["reservation_mib"] or DEFAULT // MIB) - entry["target_mib"]
210
+ for entry in entries if entry.get("updated")),
211
+ "reserved_mib": reserved_mib, "headroom_mib": cap_mib - reserved_mib, "cap_mib": cap_mib,
212
+ "native_mib": None if natives is None else natives // MIB, "available_mib": memory["MemAvailable"] // MIB,
213
+ "swap_used_mib": (memory.get("SwapTotal", 0) - memory.get("SwapFree", 0)) // MIB}
214
+
215
+
216
+ def main(argv=None):
217
+ parser = argparse.ArgumentParser(description=__doc__)
218
+ parser.add_argument("--dry-run", action="store_true", help="print the plan and write nothing")
219
+ try:
220
+ summary = run(parser.parse_args(argv).dry_run)
221
+ except ValueError as error:
222
+ print(json.dumps({"schema": SCHEMA, "error": str(error)}))
223
+ return 1
224
+ print(json.dumps(summary))
225
+ return 0
226
+
227
+
228
+ if __name__ == "__main__":
229
+ sys.exit(main())
@@ -98,8 +98,10 @@ def running_native():
98
98
  for unit, limits in model(profile)['services'].items():
99
99
  if states[unit]['ActiveState'] not in ('active', 'activating', 'reloading'):
100
100
  continue
101
- reservation = max(int(states[unit]['MemoryLow']), limits['mem_reservation'])
101
+ # Others count the larger of the applied and declared reservation; the unit's own deploy only the applied.
102
+ applied = int(states[unit]['MemoryLow'])
103
+ reservation = max(applied, limits['mem_reservation'])
102
104
  containers.append({'Config': {'Labels': {'com.docker.compose.project': path.stem,
103
105
  'com.docker.compose.project.working_dir': str(APPS / path.stem)}},
104
- 'HostConfig': {'MemoryReservation': reservation}})
106
+ 'HostConfig': {'MemoryReservation': reservation}, 'Applied': applied})
105
107
  return containers
@@ -1,6 +1,8 @@
1
1
  """Admit backend starts against live host headroom and existing container reservations."""
2
2
  import json
3
3
  import os
4
+ import re
5
+ import stat
4
6
  import subprocess
5
7
  import time
6
8
  import fcntl
@@ -11,6 +13,7 @@ from runtime_model import MIB
11
13
  from native_profile import running_native
12
14
 
13
15
  HOST_POLICY = Path('/etc/gowalk-backend-ssh.json')
16
+ AGENT_STATE = Path("/var/lib/gowalk-backend-memory")
14
17
  ROOT_UID = 0
15
18
 
16
19
 
@@ -63,7 +66,7 @@ def running_containers():
63
66
  ids = result.stdout.split()
64
67
  if not ids:
65
68
  return running_native()
66
- template = ('{"Config":{"Labels":{"com.docker.compose.project":'
69
+ template = ('{"Id":{{json .Id}},"Config":{"Labels":{"com.docker.compose.project":'
67
70
  '{{json (index .Config.Labels "com.docker.compose.project")}},'
68
71
  '"com.docker.compose.project.working_dir":'
69
72
  '{{json (index .Config.Labels "com.docker.compose.project.working_dir")}}}},'
@@ -73,20 +76,40 @@ def running_containers():
73
76
  return [json.loads(line) for line in result.stdout.splitlines()] + running_native()
74
77
 
75
78
 
79
+ def declared(container, live):
80
+ """The reservation a running container started with, as the memory agent recorded it before resizing."""
81
+ identifier = str(container.get("Id"))
82
+ if not re.fullmatch("[0-9a-f]{64}", identifier):
83
+ return live
84
+ try:
85
+ info = (AGENT_STATE / identifier).lstat()
86
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022:
87
+ return live
88
+ value = int((AGENT_STATE / identifier).read_text())
89
+ except (OSError, ValueError):
90
+ return live
91
+ return value if value > 0 else live
92
+
93
+
76
94
  def admit(app, model, facts, containers):
77
95
  host_enabled()
78
96
  required = sum(service["mem_reservation"] for service in model["services"].values())
79
- reserved, current = 0, 0
97
+ reserved, current, held = 0, 0, 0
80
98
  for container in containers:
81
99
  labels = container.get("Config", {}).get("Labels") or {}
82
100
  if not labels.get("com.docker.compose.project.working_dir", "").startswith("/opt/gowalk-backends/"):
83
101
  continue
84
102
  amount = container["HostConfig"].get("MemoryReservation") or 64 * MIB
85
103
  if labels.get("com.docker.compose.project") == app:
104
+ amount = container.get("Applied", amount) # a native unit holds its MemoryLow, not the declaration deployed
86
105
  current += amount
106
+ held += declared(container, amount)
87
107
  else:
88
108
  reserved += amount
89
- if reserved + required > facts["total"] * 0.7:
109
+ # A redeploy is charged only for growth beyond what its project holds: each container at the reservation it
110
+ # started with (the agent's record), else its live one; an agent rise is never credited to new growth.
111
+ growth = required - held
112
+ if growth > 0 and reserved + current + growth > facts["total"] * 0.7:
90
113
  raise ValueError("host_reservations_exhausted")
91
114
  if facts["available"] - max(0, required - current) < max(512 * MIB, facts["total"] * 0.1):
92
115
  raise ValueError("host_memory_exhausted")
@@ -0,0 +1,85 @@
1
+ # Backend host memory agent
2
+
3
+ Admission counts reservations (64 MiB when unset), not use, within 70% of RAM. Idle Python APIs use less than
4
+ that default and a third of their memory is cold, so a shared host refused stacks with
5
+ `host_reservations_exhausted` while RAM was still free. `backend-action/memory_agent.py` runs as root every five
6
+ minutes on eight threads. For each running container under `/opt/gowalk-backends/` (the scope admission counts)
7
+ that started at least ten minutes ago, it first measures the working set (`memory.current - inactive_file`), then:
8
+
9
+ - sets the reservation to the working set plus 20%, rounded up to 4 MiB, at least 16 MiB and at most the
10
+ container's limit (`docker update --memory-reservation`). App containers rise at once and fall by 8 MiB or more.
11
+ Datastores (PostgreSQL, PostGIS, TimescaleDB, Redis, Valkey, KeyDB, MySQL, MariaDB, MongoDB or Memcached by
12
+ image, service or command, or service `db`/`database`) only rise, and only with memory beyond their file cache;
13
+ - while swap is present, zswap is enabled (`/sys/module/zswap/parameters/enabled` is `Y`) and `memory.pressure`
14
+ `some avg60` is below 0.5, writes a quarter of an app container's anonymous memory to `memory.reclaim`,
15
+ compressing cold pages into zswap. Datastores are never reclaimed.
16
+
17
+ Before its first change to a container, the agent records the reservation admission counted (bytes; 64 MiB when
18
+ unset) in `/var/lib/gowalk-backend-memory/<full container id>` (root-owned, 0644, atomic, fsynced, never
19
+ overwritten) and never resizes without it; each real run removes only the 64-hex-named records of containers that
20
+ no longer exist. Admission (`runtime_host.py`) charges a redeploy only for growth beyond what its project holds:
21
+ each running container at its root-owned record (not group- or other-writable) when one exists, otherwise at its
22
+ live reservation, so an agent rise never pays for new growth; each native unit at its applied `MemoryLow`. An
23
+ unchanged or shrinking redeploy is never refused on reservations; a first deploy, an added service or a raised
24
+ declaration needs room under the cap for what exceeds that. The agent never changes limits, CPU, PIDs, images,
25
+ Compose files, runtime profiles or native units.
26
+
27
+ ## Host setup
28
+
29
+ Install `backend-action/` from the integrity-verified published package as the [SSH entry](BACKEND_SSH_GATE.md)
30
+ describes. Before enabling the timer, move both panel keys' forced commands in `/root/.ssh/authorized_keys` to the
31
+ new release directory: the admission credit lives in its `runtime_host.py`. Add these root-owned units to
32
+ `/etc/systemd/system/`, then create a 16 GiB swap file and enable them:
33
+
34
+ ```ini
35
+ # gowalk-backend-zswap.service
36
+ [Unit]
37
+ DefaultDependencies=no
38
+ Before=swap.target
39
+ [Service]
40
+ Type=oneshot
41
+ RemainAfterExit=yes
42
+ ExecStart=/bin/sh -c 'modprobe -q zstd; modprobe -q zsmalloc; cd /sys/module/zswap/parameters && \
43
+ echo zsmalloc > zpool && echo zstd > compressor && echo 25 > max_pool_percent && \
44
+ { [ ! -e exclusive_loads ] || echo Y > exclusive_loads; } && echo Y > enabled'
45
+ [Install]
46
+ WantedBy=swap.target
47
+ # gowalk-backend-memory.service
48
+ [Unit]
49
+ After=docker.service
50
+ [Service]
51
+ Type=oneshot
52
+ Nice=10
53
+ ExecStart=/usr/bin/python3 -B /opt/gowalk-cicd-runtime/<version>/backend-action/memory_agent.py
54
+ # gowalk-backend-memory.timer
55
+ [Timer]
56
+ OnBootSec=10min
57
+ OnUnitActiveSec=5min
58
+ [Install]
59
+ WantedBy=timers.target
60
+ ```
61
+
62
+ ```sh
63
+ fallocate -l 16G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon --priority 10 /swapfile
64
+ echo '/swapfile none swap sw,pri=10 0 0' >> /etc/fstab
65
+ systemctl daemon-reload && systemctl enable --now gowalk-backend-zswap.service gowalk-backend-memory.timer
66
+ ```
67
+
68
+ ## Verification and rollback
69
+
70
+ `systemctl start gowalk-backend-memory.service` runs the agent at once; the oneshot unit never overlaps runs.
71
+ `memory_agent.py --dry-run` prints every container's plan and the summary and writes nothing (no state directory,
72
+ record, prune, `docker update` or reclaim). A real run prints a `{"name","id","from_mib","to_mib"}` history line
73
+ per changed container and a line per error, then the `gowalk-cicd/backend-memory.v1` summary that
74
+ `journalctl _SYSTEMD_UNIT=gowalk-backend-memory.service -n 1 -o cat` shows: `reserved_mib` is the
75
+ admission-equivalent sum after the run, `headroom_mib` what remains of `cap_mib`, `released_mib` what the run
76
+ returned, `zswap` whether reclaim was allowed, `partial` the reclaims completed only in part and `native_mib` null
77
+ (natives excluded) when native discovery failed. Only unavailable Docker exits 1.
78
+
79
+ Roll back with `systemctl disable --now gowalk-backend-memory.timer && systemctl stop gowalk-backend-memory.service`
80
+ (safe midway: a record precedes each update); Compose restores declared reservations when it recreates containers.
81
+ Restore every recorded value now with this loop, then move both forced commands back to the previous release:
82
+
83
+ ```sh
84
+ for f in /var/lib/gowalk-backend-memory/*; do docker update --memory-reservation "$(cat "$f")" "$(basename "$f")"; done
85
+ ```
@@ -76,6 +76,9 @@ are retained as sparse `workflows.<file>.overrides` entries. An override identif
76
76
  a YAML path, with `value` or `remove: true`; step paths use stable IDs/names rather
77
77
  than numeric positions. These extensions are app-owned configuration, including
78
78
  their execution conditions, dependencies and secret references.
79
+ Reusable workflow jobs may retain GitHub's `secrets: inherit` handoff. This literal is accepted
80
+ only at `jobs.<job>.secrets`; named secret mappings and other credential inputs still require
81
+ GitHub secret references. Sparse overrides are checked at their rendered workflow paths.
79
82
 
80
83
  Candidate artifacts are enabled automatically for recognized execution. Custom deployment
81
84
  commands that the installer cannot establish as candidate-safe retain the normal main
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.187",
3
+ "version": "1.0.189",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,6 +41,7 @@
41
41
  "docs/BACKEND_EDGE.md",
42
42
  "docs/BACKEND_SSH_GATE.md",
43
43
  "docs/BACKEND_NATIVE.md",
44
+ "docs/BACKEND_MEMORY.md",
44
45
  "docs/CONFIGURATION.md",
45
46
  "docs/CI_PERFORMANCE.md",
46
47
  "docs/LOCAL_VALIDATION.md"
@@ -59,8 +59,11 @@ export function assertReferences(value, file, path = []) {
59
59
  return;
60
60
  }
61
61
  if (typeof value !== 'string' || !value.trim()) return;
62
+ // GitHub's reusable-workflow handoff is syntax, not an inline credential.
63
+ if (value === 'inherit' && path.length === 3 && path[0] === 'jobs' && path[2] === 'secrets') return;
64
+ const secretValue = path[0] === 'jobs' && path[2] === 'secrets';
62
65
  const key = path.at(-1) || '';
63
- if (key === 'runtime-env' && value.includes('\n')) {
66
+ if (key === 'runtime-env' && value.includes('\n') && !secretValue) {
64
67
  for (const line of value.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))) {
65
68
  const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
66
69
  if (!match) throw new MigrationRepair('unsupported_runtime_environment', file, path);
@@ -68,7 +71,7 @@ export function assertReferences(value, file, path = []) {
68
71
  }
69
72
  return;
70
73
  }
71
- const credentialKey = /(?:password|private.key|ssh-key|runtime-env|token|secret|credential)/i.test(key);
74
+ const credentialKey = secretValue || /(?:password|private.key|ssh-key|runtime-env|token|secret|credential)/i.test(key);
72
75
  const referencesOnly = /^\s*\$\{\{[^}]+\}\}\s*$/.test(value);
73
76
  if ((credentialKey && !referencesOnly) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)
74
77
  || /https?:\/\/[^\s/@]+:[^\s/@]+@/.test(value)) {
@@ -108,8 +108,9 @@ export function planWorkflows(root, templateDir, files, version) {
108
108
  delete state.lock.workflows[file];
109
109
  }
110
110
  writes.push(...removed.map(file => ({ file: `.github/workflows/${file}`, content: null })));
111
- assertReferences(state.config, CONFIG);
112
- for (const [file, entry] of Object.entries(state.config.workflows)) {
111
+ const { workflows, ...settings } = state.config;
112
+ assertReferences(settings, CONFIG);
113
+ for (const [file, entry] of Object.entries(workflows)) {
113
114
  for (const change of entry.overrides) assertReferences(change.value, file, change.path);
114
115
  }
115
116
  writes.push(...planValidation(root, [...selected, WARM, RECOVERY]));