@lifeaitools/rdc-skills 0.35.24 → 0.35.26

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.35.24",
3
+ "version": "0.35.26",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
5
5
  "author": {
6
6
  "name": "LIFEAI",
package/RELEASE.md CHANGED
@@ -69,21 +69,63 @@ The public `/health` version must equal the released npm version and its skill
69
69
  count must match the packaged catalog. The local installer never restarts or
70
70
  deploys that endpoint.
71
71
 
72
- The Vultr host checks out this repository at `/srv/regen/rdc-skills` it runs
73
- straight from the checkout (no `npm install`), so `/health`'s `git_sha` comes
74
- from `bin/rdc-skills-mcp.mjs`'s runtime `git rev-parse HEAD` fallback, NOT a
75
- stamped `git-sha.json` (that file is `.gitignore`d on purposeit's a
76
- pack-time-only artifact for npm-installed copies; see the comment above its
77
- `.gitignore` entry). `install-systemd.sh` alone does not restart the running
78
- process. After pulling `master`:
72
+ The Vultr host checks out this repository at `/srv/regen/rdc-skills` and runs the
73
+ server straight from that checkout, with production dependencies installed there
74
+ by `npm ci`. `/health`'s `git_sha` comes from `bin/rdc-skills-mcp.mjs`'s runtime
75
+ `git rev-parse HEAD`, resolved once when the process starts NOT a stamped
76
+ `git-sha.json` (that file is `.gitignore`d on purpose it's a pack-time-only
77
+ artifact for npm-installed copies; the updater deletes one if it appears in the
78
+ checkout). `version`, by contrast, is re-read from `package.json` on every
79
+ request, so **only `git_sha` proves the process restarted onto a release.**
80
+
81
+ **The host follows release tags by itself.** `rdc-skills-mcp-update.timer` runs
82
+ the updater ([`deploy/update-from-tag.sh`](deploy/update-from-tag.sh), installed
83
+ at `/usr/local/libexec/rdc-skills/` so no rollback can delete it) every five
84
+ minutes. The service is only ever restarted onto a **release**: the newest plain
85
+ `vX.Y.Z` tag merged into `master`, or the last release the updater proved serving.
86
+ Untagged commits, pre-releases and tags off `master` are never served — a checkout
87
+ pulled ahead to untagged `master` is moved back to the tag. The updater reinstalls
88
+ dependencies and unit files only when they differ from the last install, restarts
89
+ the MCP, and succeeds only when `/health` reports the release's version **and**
90
+ commit. "Current" is decided by `/health`, never by where `HEAD` is — a run that was
91
+ interrupted converges on the next tick.
92
+
93
+ A release only moves forward: a tag that is not a descendant of the proved release
94
+ (a higher number put on an older commit) is held off, so rolling back means tagging
95
+ a new, higher release. A release that restarts but never answers is recorded as
96
+ failed in `/var/lib/rdc-skills-update/failed`, the proved release is served again,
97
+ and the failed one is not retried until a newer tag exists. An install or restart
98
+ error says nothing about the release: the proved release is served again and the
99
+ new one is retried next run. A registry that cannot be reached is not counted
100
+ unless it stays unreachable for that release for a whole day; any other install or
101
+ restart error, the third time against the same release, marks it failed. To retry a release marked failed without tagging again, delete its line
102
+ from that file on the host. A host with no history adopts the commit it is already
103
+ serving (if that commit is on `master`) as the proved release before touching
104
+ anything, so the first move has somewhere to go back to. Tracked changes on the
105
+ host, or a commit made on the host, are refused and the service is left as it is.
106
+ So pushing the tag is the whole public MCP deploy — expect it live within about
107
+ five minutes:
79
108
 
80
109
  ```bash
81
- git pull --ff-only origin master
82
- sudo ./deploy/install-systemd.sh # re-verify/re-enable the unit config
83
- sudo systemctl restart rdc-skills-mcp.service # actually load the new commit
84
- curl -fsS https://rdc-skills.regendevcorp.com/health # confirm git_sha == new HEAD
110
+ tag=v$(node -p "require('./package.json').version")
111
+ curl -fsS https://rdc-skills.regendevcorp.com/health # git_sha == the line below
112
+ git rev-list -n 1 "$tag"
113
+ # on the host: journalctl -u rdc-skills-mcp-update.service -n 20
114
+ ```
115
+
116
+ `install-systemd.sh` is the one-time host bootstrap. It installs the units and the
117
+ updater, runs the updater once (which restarts the MCP onto the checked-out release
118
+ and proves it), then enables the timer:
119
+
120
+ ```bash
121
+ cd /srv/regen/rdc-skills
122
+ git fetch --tags origin && git merge --ff-only v<X.Y.Z>
123
+ sudo ./deploy/install-systemd.sh
85
124
  ```
86
125
 
126
+ Before the timer existed the checkout moved only when someone logged in and
127
+ pulled, and the public catalog sat on 0.35.18 through six releases.
128
+
87
129
  Port `3110` is reserved for this public MCP ingress. Application fleet manifests
88
130
  must not claim it.
89
131
 
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env bash
2
+ # One-time host bootstrap for the public MCP. From then on the timer keeps the
3
+ # checkout on the newest release tag (deploy/update-from-tag.sh).
4
+ #
5
+ # cd /srv/regen/rdc-skills
6
+ # git fetch --tags origin && git merge --ff-only v<X.Y.Z>
7
+ # sudo ./deploy/install-systemd.sh
2
8
  set -euo pipefail
3
9
 
4
10
  repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
- unit_source="$repo_root/deploy/systemd/rdc-skills-mcp.service"
6
- unit_target="/etc/systemd/system/rdc-skills-mcp.service"
11
+ unit_dir="/etc/systemd/system"
12
+ libexec_dir="/usr/local/libexec/rdc-skills"
7
13
 
8
14
  if [[ "$repo_root" != "/srv/regen/rdc-skills" ]]; then
9
15
  echo "rdc-skills must be checked out at /srv/regen/rdc-skills" >&2
@@ -14,9 +20,26 @@ cd "$repo_root"
14
20
  # The MCP imports declared runtime dependencies (including express). A Git
15
21
  # checkout alone cannot satisfy those imports after a clean host or dependency
16
22
  # cleanup, so install the committed production graph before the unit is enabled.
17
- npm ci --omit=dev --no-audit --no-fund
23
+ # No install scripts: nothing the server imports needs one, and this runs as root.
24
+ npm ci --omit=dev --no-audit --no-fund --ignore-scripts
18
25
 
19
- install -m 0644 "$unit_source" "$unit_target"
26
+ install -m 0644 deploy/systemd/rdc-skills-mcp.service "$unit_dir/rdc-skills-mcp.service"
27
+ install -m 0644 deploy/systemd/rdc-skills-mcp-update.service "$unit_dir/rdc-skills-mcp-update.service"
28
+ install -m 0644 deploy/systemd/rdc-skills-mcp-update.timer "$unit_dir/rdc-skills-mcp-update.timer"
29
+ install -D -m 0755 deploy/update-from-tag.sh "$libexec_dir/update-from-tag.sh"
20
30
  systemctl daemon-reload
21
- systemctl enable --now rdc-skills-mcp.service
22
- systemctl --no-pager --full status rdc-skills-mcp.service
31
+ systemctl enable rdc-skills-mcp.service
32
+
33
+ # `enable --now` does not restart a process that is already running, so an
34
+ # existing host would keep serving the old commit. The updater restarts into the
35
+ # checked-out release and succeeds only when /health proves it; if that release
36
+ # does not come up it returns to the commit that was serving. The timer is
37
+ # enabled either way — the updater's own state decides what the next run does —
38
+ # and the installer exits with the first run's status.
39
+ first_run=0
40
+ systemctl start rdc-skills-mcp-update.service || first_run=$?
41
+ systemctl enable --now rdc-skills-mcp-update.timer
42
+ systemctl --no-pager --full status rdc-skills-mcp.service || true
43
+ systemctl --no-pager list-timers rdc-skills-mcp-update.timer
44
+ journalctl -u rdc-skills-mcp-update.service -n 20 --no-pager || true
45
+ exit "$first_run"
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=Keep the public RDC Skills MCP on the newest release tag
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ User=root
9
+ WorkingDirectory=/srv/regen/rdc-skills
10
+ # A copy outside the checkout: a rollback to a release that predates the
11
+ # updater must not be able to delete the program doing the rollback.
12
+ ExecStart=/usr/local/libexec/rdc-skills/update-from-tag.sh
13
+ # Proved and failed commits, and install hashes — outside the checkout it judges.
14
+ StateDirectory=rdc-skills-update
15
+ TimeoutStartSec=20min
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=Check for a new RDC Skills release every five minutes
3
+
4
+ [Timer]
5
+ OnBootSec=2min
6
+ OnUnitActiveSec=5min
7
+
8
+ [Install]
9
+ WantedBy=timers.target
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env bash
2
+ # Keep the public MCP on the newest release tag, and prove it is serving it.
3
+ #
4
+ # The tag is the promotion for rdc-skills (RELEASE.md). npm consumers get a tag
5
+ # when the registry does; this is the same act for the one consumer that runs
6
+ # from a git checkout — the public MCP at https://rdc-skills.regendevcorp.com.
7
+ # Before this existed the checkout moved only when someone logged in and pulled,
8
+ # which the fleet's hand-rolled-deploy guard refuses, so the public catalog sat
9
+ # on 0.35.18 through six releases while npm moved on.
10
+ #
11
+ # THE MODEL (classified per .claude/rules/stateful-inspection.md)
12
+ # identity a RELEASE: a plain vX.Y.Z tag merged into origin/master, or the
13
+ # PROVED commit — the last one this updater restarted onto and saw
14
+ # /health report. On a host with no history, the commit the running
15
+ # process reports is adopted as proved: it is demonstrably serving.
16
+ # state HEAD; the recorded dependency and unit installs; the FAILED set;
17
+ # the install-error ATTEMPTS against one release.
18
+ # liveness /health's git_sha (fixed at process start) and version (re-read
19
+ # from package.json on every request — so only git_sha proves a
20
+ # restart took).
21
+ #
22
+ # THE INVARIANT: the service is restarted only onto a release. HEAD is state; it
23
+ # is moved to a release, never served for being checked out. A HEAD that is not
24
+ # a release and not on origin/master (a commit made on the host) is refused and
25
+ # the service is left alone.
26
+ #
27
+ # EACH RUN: desired = the newest tag unless it has FAILED or is not ahead of the
28
+ # proved release, else the proved release. Move HEAD there if needed; if /health
29
+ # already reports it and the installs match, done. Otherwise converge (install
30
+ # what changed, restart, prove):
31
+ # proved record it, refresh the updater copy
32
+ # never answers the release is bad: record FAILED (never the proved one),
33
+ # return to the proved release
34
+ # install/restart says nothing about the release, the first two times: return
35
+ # error to the proved release and retry next run. The third time
36
+ # against the same release it is treated as bad. A registry
37
+ # that cannot be reached is not counted — unless it has been
38
+ # unreachable for that release for a whole day.
39
+ #
40
+ # Run by rdc-skills-mcp-update.timer from a copy OUTSIDE the checkout
41
+ # (/usr/local/libexec/rdc-skills), refreshed only after a proved move — a
42
+ # rollback to a release that predates this file can never delete the updater.
43
+ # State lives in /var/lib/rdc-skills-update, not in the checkout it judges.
44
+ #
45
+ # Exit codes: 0 serving the newest release · 2 wrong checkout · 3 tracked changes
46
+ # (preserved, never touched) · 4 HEAD is a commit made on the host (refused, service
47
+ # untouched) · 5 the newest release failed and the proved one is serving again ·
48
+ # 6 nothing proved serving this run (retried next run) · 7 the newest release
49
+ # failed before; the proved one is held · 8 serving and proved, but recording it
50
+ # or refreshing the updater copy failed (retried next run) · 9 the newest tag is
51
+ # not ahead of the proved release (a downgrade); the proved one is held.
52
+ #
53
+ # Everything runs from functions called on the last line. Bash reads a script as
54
+ # it executes; by the time main runs, every line it will execute has been read.
55
+ set -euo pipefail
56
+
57
+ ROOT="${RDC_SKILLS_DEPLOY_ROOT:-/srv/regen/rdc-skills}"
58
+ EXPECTED_ROOT="${RDC_SKILLS_EXPECTED_ROOT:-/srv/regen/rdc-skills}"
59
+ UNIT="rdc-skills-mcp.service"
60
+ UNIT_DIR="${RDC_SKILLS_UNIT_DIR:-/etc/systemd/system}"
61
+ LIBEXEC_DIR="${RDC_SKILLS_LIBEXEC_DIR:-/usr/local/libexec/rdc-skills}"
62
+ STATE_DIR="${RDC_SKILLS_STATE_DIR:-/var/lib/rdc-skills-update}"
63
+ LOCK_FILE="${RDC_SKILLS_LOCK_FILE:-/run/lock/rdc-skills-update.lock}"
64
+ HEALTH_URL="${RDC_SKILLS_HEALTH_URL:-http://127.0.0.1:3110/health}"
65
+ HEALTH_DEADLINE="${RDC_SKILLS_HEALTH_DEADLINE:-120}"
66
+ HEALTH_INTERVAL="${RDC_SKILLS_HEALTH_INTERVAL:-2}"
67
+ HEALTH_SETTLE_ATTEMPTS="${RDC_SKILLS_HEALTH_SETTLE_ATTEMPTS:-3}"
68
+ INSTALL_ERROR_LIMIT="${RDC_SKILLS_INSTALL_ERROR_LIMIT:-3}"
69
+ # How long one release may keep failing to reach the registry before it is judged
70
+ # bad after all (a lockfile `resolved` URL on a host that no longer exists).
71
+ NETWORK_ERROR_WINDOW="${RDC_SKILLS_NETWORK_ERROR_WINDOW:-86400}"
72
+ SYSTEMCTL="${SYSTEMCTL:-systemctl}"
73
+ # Release tags are fetched with force into a namespace this updater owns, so a
74
+ # tag moved or re-created on origin updates here instead of failing the fetch.
75
+ RELEASE_REFS="refs/rdc-release-tags"
76
+
77
+ log() { printf 'rdc-skills-update: %s\n' "$*"; }
78
+
79
+ state_get() { cat "$STATE_DIR/$1" 2>/dev/null || true; }
80
+ state_put() { printf '%s\n' "$2" > "$STATE_DIR/$1.tmp" && mv -f "$STATE_DIR/$1.tmp" "$STATE_DIR/$1"; }
81
+
82
+ # Never fails, and never reads stdin: no arguments hash as empty input.
83
+ hash_files() { { if (($#)); then cat -- "$@" 2>/dev/null || true; fi; } | sha256sum | cut -d' ' -f1; }
84
+ unit_files() { (shopt -s nullglob; printf '%s\n' deploy/systemd/*.service deploy/systemd/*.timer); }
85
+
86
+ version_at() {
87
+ git show "$1:package.json" | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0, "utf8")).version)'
88
+ }
89
+
90
+ health_field() {
91
+ HEALTH="$1" FIELD="$2" node -e '
92
+ try { process.stdout.write(String(JSON.parse(process.env.HEALTH)[process.env.FIELD] ?? "")); } catch {}
93
+ ' 2>/dev/null || true
94
+ }
95
+ health_body() { curl -fsS -m 5 "$HEALTH_URL" 2>/dev/null || true; }
96
+ answers_as() { # <body> <sha> <version>
97
+ [[ -n "$1" && "$(health_field "$1" git_sha)" == "$2" && "$(health_field "$1" version)" == "$3" ]]
98
+ }
99
+
100
+ # The first ANSWER decides; only silence is retried — one slow response never
101
+ # restarts a healthy service. Prints the answer (empty when there was none).
102
+ settled_body() {
103
+ local i body=""
104
+ for ((i = 1; i <= HEALTH_SETTLE_ATTEMPTS; i++)); do
105
+ body="$(health_body)"
106
+ [[ -n "$body" ]] && break
107
+ ((i < HEALTH_SETTLE_ATTEMPTS)) && sleep "$HEALTH_INTERVAL"
108
+ done
109
+ printf '%s' "$body"
110
+ }
111
+
112
+ wait_serving() { # <sha> <version>: after a restart, until the deadline
113
+ local end=$((SECONDS + HEALTH_DEADLINE)) body=""
114
+ while :; do
115
+ body="$(health_body)"
116
+ answers_as "$body" "$1" "$2" && return 0
117
+ ((SECONDS >= end)) && break
118
+ sleep "$HEALTH_INTERVAL"
119
+ done
120
+ log "health at $HEALTH_URL did not report $2 @ ${1:0:7} within ${HEALTH_DEADLINE}s; last: ${body:-<no answer>}"
121
+ return 1
122
+ }
123
+
124
+ installs_match() {
125
+ [[ -d node_modules ]] \
126
+ && [[ "$(hash_files package-lock.json)" == "$(state_get lock-sha256)" ]] \
127
+ && [[ "$(hash_files $(unit_files))" == "$(state_get units-sha256)" ]]
128
+ }
129
+
130
+ held() { # <sha> <version>: /health reports the release and the installs match (serve moved HEAD to it)
131
+ answers_as "$(settled_body)" "$1" "$2" && installs_match
132
+ }
133
+
134
+ # converge <sha> <version>: with HEAD at <sha>, install what changed since the
135
+ # last install, restart, and prove it.
136
+ # 0 proved · 1 install or restart error · 2 restarted, but /health never reported it
137
+ # 3 the dependency install could not reach the registry (never counted against a release)
138
+ # Each recorded hash is cleared BEFORE its install and written after, so a run
139
+ # killed halfway is redone rather than trusted. errexit is suspended inside a
140
+ # function called from a condition, so every step checks its own status.
141
+ converge() {
142
+ local sha="$1" version="$2" lock_hash units_hash units npm_out npm_rc
143
+ if [[ -e git-sha.json ]]; then
144
+ # A pack-time stamp outranks `git rev-parse` in the MCP and would pin
145
+ # /health to whatever commit was packed here, failing every proof.
146
+ log "removing a stale git-sha.json stamp from the checkout"
147
+ rm -f git-sha.json || return 1
148
+ fi
149
+ lock_hash="$(hash_files package-lock.json)"
150
+ if [[ ! -d node_modules || "$lock_hash" != "$(state_get lock-sha256)" ]]; then
151
+ log "installing the locked production dependency graph"
152
+ state_put lock-sha256 "" || return 1
153
+ # --ignore-scripts: nothing the server imports needs an install script, and
154
+ # this runs as root, unattended, within minutes of a tag.
155
+ npm_rc=0
156
+ npm_out="$(npm ci --omit=dev --no-audit --no-fund --ignore-scripts 2>&1)" || npm_rc=$?
157
+ if ((npm_rc != 0)); then
158
+ printf '%s\n' "$npm_out" | tail -n 5 | sed 's/^/rdc-skills-update: /'
159
+ # A registry that cannot be reached says nothing about the release; a
160
+ # lockfile that cannot install does.
161
+ if grep -qE '^npm (ERR!|error) (code (E5[0-9]{2}|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|ENETUNREACH|EHOSTUNREACH)|network )' <<<"$npm_out"; then
162
+ log "npm ci could not reach the registry"
163
+ return 3
164
+ fi
165
+ log "npm ci failed"
166
+ return 1
167
+ fi
168
+ state_put lock-sha256 "$lock_hash" || return 1
169
+ fi
170
+ units="$(unit_files)"
171
+ # shellcheck disable=SC2086 # unit file names carry no spaces
172
+ units_hash="$(hash_files $units)"
173
+ if [[ "$units_hash" != "$(state_get units-sha256)" ]]; then
174
+ log "installing unit files"
175
+ state_put units-sha256 "" || return 1
176
+ if [[ -n "$units" ]]; then
177
+ # shellcheck disable=SC2086
178
+ install -m 0644 $units "$UNIT_DIR/" || return 1
179
+ fi
180
+ "$SYSTEMCTL" daemon-reload || return 1
181
+ state_put units-sha256 "$units_hash" || return 1
182
+ fi
183
+ "$SYSTEMCTL" restart "$UNIT" || { log "restart failed"; return 1; }
184
+ wait_serving "$sha" "$version" || return 2
185
+ }
186
+
187
+ is_failed() { [[ -f "$STATE_DIR/failed" ]] && grep -qx "$1" "$STATE_DIR/failed"; }
188
+ mark_failed() { is_failed "$1" || printf '%s\n' "$1" >> "$STATE_DIR/failed"; }
189
+
190
+ # Install/restart errors counted against one release; any other release resets it.
191
+ bump_attempts() {
192
+ local sha="$1" recorded count=0
193
+ recorded="$(state_get attempts)"
194
+ [[ "${recorded%% *}" == "$sha" ]] && count="${recorded##* }"
195
+ count=$((count + 1))
196
+ state_put attempts "$sha $count"
197
+ printf '%s' "$count"
198
+ }
199
+
200
+ # Seconds this release has been failing to reach the registry (0 on the first failure).
201
+ network_failing_for() {
202
+ local sha="$1" recorded now since
203
+ now="$(date +%s)"
204
+ recorded="$(state_get network-since)"
205
+ if [[ "${recorded%% *}" == "$sha" ]]; then
206
+ since="${recorded##* }"
207
+ else
208
+ since="$now"
209
+ state_put network-since "$sha $now"
210
+ fi
211
+ printf '%s' "$((now - since))"
212
+ }
213
+
214
+ # serve <sha> <version>: move HEAD to a release and make /health prove it.
215
+ # Returns converge's code (0 also when it was already held).
216
+ serve() {
217
+ local sha="$1" version="$2" rc=0
218
+ if [[ "$(git rev-parse HEAD)" != "$sha" ]]; then
219
+ git reset --quiet --hard "$sha" || return 1
220
+ fi
221
+ held "$sha" "$version" && return 0
222
+ log "converging ${sha:0:7} ($version)"
223
+ converge "$sha" "$version" || rc=$?
224
+ return "$rc"
225
+ }
226
+
227
+ main() {
228
+ if [[ "$ROOT" != "$EXPECTED_ROOT" ]]; then
229
+ log "refused: checkout is $ROOT, expected $EXPECTED_ROOT"
230
+ return 2
231
+ fi
232
+ mkdir -p "$STATE_DIR"
233
+ # systemd never overlaps two timer runs; this also covers a run started by hand.
234
+ if command -v flock >/dev/null 2>&1; then
235
+ exec 9>"$LOCK_FILE"
236
+ if ! flock -n 9; then
237
+ log "another update run holds $LOCK_FILE"
238
+ return 0
239
+ fi
240
+ fi
241
+ cd "$ROOT"
242
+
243
+ local tracked
244
+ tracked="$(git status --porcelain --untracked-files=no)"
245
+ if [[ -n "$tracked" ]]; then
246
+ log "refused: tracked changes in $ROOT — preserved, not updated"
247
+ printf '%s\n' "$tracked" | sed 's/^/rdc-skills-update: /'
248
+ return 3
249
+ fi
250
+
251
+ # Releases are read only from the forced namespace below, so a tag moved on
252
+ # origin updates there instead of being refused as a clobber (which, with
253
+ # `--tags` into refs/tags, fails the whole fetch every run). --no-tags keeps
254
+ # refs/tags out of it entirely.
255
+ git fetch --quiet --no-tags --prune origin \
256
+ '+refs/heads/master:refs/remotes/origin/master' \
257
+ "+refs/tags/v*:$RELEASE_REFS/v*"
258
+ # Plain vX.Y.Z only: a pre-release or a stray `v*` name is never a release.
259
+ local tag target=""
260
+ tag="$(git for-each-ref --merged refs/remotes/origin/master --sort=-v:refname \
261
+ --format='%(refname:lstrip=2)' "$RELEASE_REFS" \
262
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
263
+ [[ -n "$tag" ]] && target="$(git rev-parse "$RELEASE_REFS/$tag^{commit}")"
264
+
265
+ local proved
266
+ proved="$(state_get proved)"
267
+ if [[ -z "$proved" ]]; then
268
+ # No history: adopt what is demonstrably serving, so a bad first release has
269
+ # somewhere to go back to — recorded BEFORE anything is restarted.
270
+ local running
271
+ running="$(health_field "$(settled_body)" git_sha)"
272
+ # Only a commit on origin/master: adopting a host-made commit would let the
273
+ # move below reset it off every branch.
274
+ if [[ -n "$running" ]] && git cat-file -e "$running^{commit}" 2>/dev/null \
275
+ && git merge-base --is-ancestor "$running" refs/remotes/origin/master; then
276
+ state_put proved "$running"
277
+ proved="$running"
278
+ log "adopted the serving commit ${running:0:7} as the proved release"
279
+ fi
280
+ fi
281
+
282
+ # A release only moves forward: a tag that is not the proved commit or a
283
+ # descendant of it (a higher number put on an older commit) would downgrade the
284
+ # public MCP. Rolling back is a new, higher tag — the tag is the promotion.
285
+ local desired="" holding=""
286
+ if [[ -n "$target" && -n "$proved" && "$target" != "$proved" ]] \
287
+ && ! git merge-base --is-ancestor "$proved" "$target"; then
288
+ desired="$proved"
289
+ holding="behind"
290
+ elif [[ -n "$target" ]] && ! is_failed "$target"; then
291
+ desired="$target"
292
+ elif [[ -n "$proved" ]]; then
293
+ desired="$proved"
294
+ [[ -n "$target" ]] && holding="failed"
295
+ fi
296
+ if [[ -z "$desired" ]]; then
297
+ if [[ -n "$target" ]]; then
298
+ log "$tag failed its health proof and there is no proved release to serve"
299
+ else
300
+ log "no release tag reachable from origin/master, and no proved release"
301
+ fi
302
+ return 6
303
+ fi
304
+
305
+ # HEAD is state, but a commit made ON the host is work that exists nowhere else:
306
+ # never discard it, and never serve it.
307
+ local head
308
+ head="$(git rev-parse HEAD)"
309
+ if [[ "$head" != "$desired" && "$head" != "$proved" && "$head" != "$target" ]] \
310
+ && ! git merge-base --is-ancestor "$head" refs/remotes/origin/master; then
311
+ log "refused: HEAD ${head:0:7} is a commit made on this host (not on origin/master) — preserved; the service is left as it is"
312
+ return 4
313
+ fi
314
+
315
+ local version rc=0
316
+ version="$(version_at "$desired")"
317
+ serve "$desired" "$version" || rc=$?
318
+ local name="${tag:-release}"
319
+ [[ "$desired" != "$target" ]] && name="${desired:0:7}"
320
+
321
+ if ((rc == 0)); then
322
+ state_put attempts "" || true
323
+ state_put network-since "" || true
324
+ if [[ "$(state_get proved)" != "$desired" ]] && ! state_put proved "$desired"; then
325
+ log "serving $name (${desired:0:7}), but recording it as proved failed"
326
+ return 8
327
+ fi
328
+ # The updater copy follows the newest release only — a held older release may
329
+ # predate this file. Compared by content, so a failed refresh is retried.
330
+ if [[ "$desired" == "$target" ]] && ! cmp -s deploy/update-from-tag.sh "$LIBEXEC_DIR/update-from-tag.sh"; then
331
+ if ! install -D -m 0755 deploy/update-from-tag.sh "$LIBEXEC_DIR/update-from-tag.sh"; then
332
+ log "serving $name (${desired:0:7}), but refreshing the updater copy in $LIBEXEC_DIR failed"
333
+ return 8
334
+ fi
335
+ fi
336
+ if [[ "$holding" == failed ]]; then
337
+ log "holding ${desired:0:7}: $tag (${target:0:7}) failed its health proof before; waiting for a newer tag"
338
+ return 7
339
+ fi
340
+ if [[ "$holding" == behind ]]; then
341
+ log "holding ${desired:0:7}: the newest tag $tag (${target:0:7}) is not ahead of the proved release; waiting for a newer tag"
342
+ return 9
343
+ fi
344
+ log "current at $name (${desired:0:7})"
345
+ return 0
346
+ fi
347
+
348
+ # Not proved. The proved release itself is never marked failed: it served.
349
+ local bad=0
350
+ if [[ "$desired" != "$proved" ]]; then
351
+ if ((rc == 2)); then
352
+ bad=1
353
+ elif ((rc == 3)); then
354
+ if (( $(network_failing_for "$desired") >= NETWORK_ERROR_WINDOW )); then
355
+ log "installing $name has failed to reach the registry for over ${NETWORK_ERROR_WINDOW}s; treating it as a bad release"
356
+ bad=1
357
+ else
358
+ log "the registry could not be reached installing $name; retrying next run (not counted)"
359
+ fi
360
+ elif (( $(bump_attempts "$desired") >= INSTALL_ERROR_LIMIT )); then
361
+ log "install or restart error on $name for the ${INSTALL_ERROR_LIMIT}th run; treating it as a bad release"
362
+ bad=1
363
+ else
364
+ log "install or restart error on $name; retrying next run"
365
+ fi
366
+ ((bad)) && mark_failed "$desired"
367
+ fi
368
+
369
+ if [[ -n "$proved" && "$desired" != "$proved" ]]; then
370
+ log "returning to the proved release ${proved:0:7}"
371
+ local back_rc=0
372
+ serve "$proved" "$(version_at "$proved")" || back_rc=$?
373
+ if ((back_rc == 0)); then
374
+ ((bad)) && { log "$name failed; the proved release is serving again"; return 5; }
375
+ return 6
376
+ fi
377
+ log "the proved release ${proved:0:7} could not be proved serving either"
378
+ fi
379
+ return 6
380
+ }
381
+
382
+ main "$@"; exit $?
package/git-sha.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "sha": "31c8a8246ec68e424893534d7da36166f52a6f73"
3
+ }
@@ -69,6 +69,17 @@ A refused proof is recorded as exit 126 and **never runs**. Prove an action by
69
69
  observing its result: a push with `git merge-base --is-ancestor HEAD origin/<branch>`,
70
70
  a deploy with a read-only probe, a file with `test -f`.
71
71
 
72
+ **A proof is a POSIX shell command, on every engine.** `rdc-work` runs it with
73
+ `-c '<proof>'` in Git Bash on Windows and in `/bin/sh` everywhere else (dash on
74
+ Debian and Ubuntu) — whatever shell the agent itself uses. Write plain `sh`: a
75
+ bash-only form (`[[ ]]`, `$RANDOM`, arrays) passes on Windows and fails on Linux.
76
+ `$var`, `"…"` and backticks are expanded by that shell before any program sees
77
+ them. A PowerShell check goes inside single quotes, where POSIX
78
+ expands nothing: `pwsh -NoProfile -Command '(Get-Service x).Status -eq "Running"'`
79
+ — and since that prints `True` or `False` and exits 0 either way, end it with
80
+ `| grep -qx True`, or put the check in a script that exits non-zero, and prove with
81
+ `pwsh -NoProfile -File check.ps1`.
82
+
72
83
  ## Tier — the agent picks
73
84
 
74
85
  | `--type` | tier | also |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.35.24",
3
+ "version": "0.35.26",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -55,7 +55,8 @@
55
55
  "test:channel-formatter:remote": "node tests/channel-formatter.contract.test.mjs --remote",
56
56
  "test:brochure": "node tests/rdc-brochure.test.mjs",
57
57
  "mcp": "node bin/rdc-skills-mcp.mjs",
58
- "prepack": "node scripts/prepack.mjs"
58
+ "prepack": "node scripts/prepack.mjs",
59
+ "test:deploy": "node tests/deploy-updater.test.mjs"
59
60
  },
60
61
  "dependencies": {
61
62
  "@lifeaitools/clauth-surface-sdk": "^0.1.0",
@@ -1277,7 +1277,14 @@ async function main() {
1277
1277
  const pkg = readJson(path.join(repoRoot, 'package.json'));
1278
1278
  const version = pkg.version || '0.7.0';
1279
1279
  let gitSha = '';
1280
- try { gitSha = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); } catch {}
1280
+ if (fs.existsSync(path.join(repoRoot, '.git'))) {
1281
+ try { gitSha = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); } catch {}
1282
+ } else {
1283
+ // An npm-installed package has no .git — and `git rev-parse` would walk up into
1284
+ // whatever repository encloses it. The commit is the pack-time stamp instead.
1285
+ gitSha = String(readJson(path.join(repoRoot, 'git-sha.json'))?.sha || '');
1286
+ if (gitSha === 'unknown') gitSha = '';
1287
+ }
1281
1288
 
1282
1289
  // 0.5a. User-skills cleanup — remove any rdc: skills from ~/.claude/skills/user/
1283
1290
  // (older installer versions wrote there; plugin cache is the only authoritative source)
@@ -0,0 +1,608 @@
1
+ // deploy/update-from-tag.sh — the public MCP follows release tags by itself.
2
+ //
3
+ // Behavioral: a real origin, a real deploy clone, and stub systemctl/npm/curl
4
+ // that model the running MCP the way it actually behaves — `git_sha` is fixed
5
+ // when the process starts, `version` is re-read from package.json on every
6
+ // request. So a restart that did not take, a release that never comes up, and a
7
+ // checkout moved without a restart are each observable exactly as on the host,
8
+ // and every outcome is proved by the commit the checkout ends on and what
9
+ // /health reports, not by a log line.
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { spawnSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const UPDATER = path.join(REPO_ROOT, 'deploy', 'update-from-tag.sh');
20
+ const read = (relative) => fs.readFileSync(path.join(REPO_ROOT, relative), 'utf8');
21
+
22
+ function bashExecutable() {
23
+ if (process.platform !== 'win32') return 'bash';
24
+ // `bash` on a Windows PATH can be WSL's System32 shim, which cannot see these paths.
25
+ const git = 'C:/Program Files/Git/bin/bash.exe';
26
+ return fs.existsSync(git) ? git : 'bash';
27
+ }
28
+ const BASH = bashExecutable();
29
+ const slash = (p) => p.replace(/\\/g, '/');
30
+ // Git for Windows' bash.exe prepends /mingw64/bin and /usr/bin, so the stubs go
31
+ // on PATH from inside the shell, in its own path form.
32
+ const posix = (p) => (process.platform === 'win32' ? slash(p).replace(/^([A-Za-z]):/, (_, d) => `/${d.toLowerCase()}`) : p);
33
+
34
+ function sandbox() {
35
+ const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'rdc-skills-updater-')));
36
+ const gitConfig = path.join(dir, 'gitconfig');
37
+ fs.writeFileSync(gitConfig, '[user]\n\tname = updater-test\n\temail = updater-test@example.invalid\n[init]\n\tdefaultBranch = master\n');
38
+ const env = { ...process.env, GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' };
39
+ const run = (cwd, args) => {
40
+ const r = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
41
+ assert.equal(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`);
42
+ return r.stdout.trim();
43
+ };
44
+
45
+ const origin = path.join(dir, 'origin.git');
46
+ const author = path.join(dir, 'author');
47
+ const deploy = path.join(dir, 'deploy');
48
+ const units = path.join(dir, 'units');
49
+ const libexec = path.join(dir, 'libexec');
50
+ const stateDir = path.join(dir, 'state');
51
+ const stubs = path.join(dir, 'stubs');
52
+ const svc = path.join(dir, 'svc');
53
+ for (const d of [author, units, stubs, svc]) fs.mkdirSync(d, { recursive: true });
54
+ run(dir, ['init', '--bare', '-q', origin]);
55
+ run(author, ['init', '-q']);
56
+ run(author, ['remote', 'add', 'origin', slash(origin)]);
57
+
58
+ const write = (relative, body) => {
59
+ const file = path.join(author, relative);
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, body);
62
+ };
63
+ // release(version, { tag }) — tag: true → `v<version>`, a string → that name, false → untagged.
64
+ const release = (version, { lock = `lock-${version}`, unit = 'unit-v1', broken = false, tag = true, branch = 'master' } = {}) => {
65
+ write('.gitignore', 'node_modules/\ngit-sha.json\n');
66
+ write('package.json', JSON.stringify({ name: 'rdc-skills-fixture', version }, null, 2) + '\n');
67
+ write('package-lock.json', JSON.stringify({ marker: lock }) + '\n');
68
+ write('deploy/systemd/rdc-skills-mcp.service', `# ${unit}\n`);
69
+ write('deploy/update-from-tag.sh', `# updater as of ${version}\n`);
70
+ if (broken) write('BROKEN', 'this release never answers /health\n');
71
+ else fs.rmSync(path.join(author, 'BROKEN'), { force: true });
72
+ run(author, ['add', '-A']);
73
+ run(author, ['commit', '-q', '--allow-empty', '-m', `release ${version}`]);
74
+ const name = tag === true ? `v${version}` : tag;
75
+ if (name) run(author, ['tag', name]);
76
+ run(author, ['push', '-q', 'origin', `HEAD:${branch}`, ...(name ? [`refs/tags/${name}`] : [])]);
77
+ return run(author, ['rev-parse', 'HEAD']);
78
+ };
79
+
80
+ const stub = (name, body) => {
81
+ const file = path.join(stubs, name);
82
+ fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`);
83
+ fs.chmodSync(file, 0o755);
84
+ };
85
+ const calls = path.join(dir, 'calls.log');
86
+ const started = path.join(svc, 'started-sha');
87
+ // restart: the process starts on whatever commit the checkout holds — unless
88
+ // this release is BROKEN (it never answers), or the restart silently does not
89
+ // take (svc/no-restart), in which case the old process keeps running.
90
+ stub('systemctl', [
91
+ `echo "systemctl $*" >> "${slash(calls)}"`,
92
+ 'if [[ "$1" == restart ]]; then',
93
+ // svc/restart-fail-once: the restart stops the old process and fails to start.
94
+ ` if [[ -f "${slash(svc)}/restart-fail-once" ]]; then rm -f "${slash(svc)}/restart-fail-once" "${slash(started)}"; exit 1; fi`,
95
+ ` [[ -f "${slash(svc)}/no-restart" ]] && exit 0`,
96
+ ` if [[ -f "${slash(deploy)}/BROKEN" ]]; then rm -f "${slash(started)}"; exit 0; fi`,
97
+ ` git -C "${slash(deploy)}" rev-parse HEAD > "${slash(started)}"`,
98
+ 'fi',
99
+ ].join('\n'));
100
+ // npm: svc/npm-fail fails every install (a registry outage); svc/npm-fail-once fails one.
101
+ stub('npm', [
102
+ `echo "npm $*" >> "${slash(calls)}"`,
103
+ `if [[ -f "${slash(svc)}/npm-fail" ]]; then echo "npm error code ETIMEDOUT" >&2; echo "npm error network request to https://registry.npmjs.org failed" >&2; exit 1; fi`,
104
+ // A release whose own lockfile can never install.
105
+ 'if grep -q uninstallable package-lock.json; then echo "npm error code ETARGET" >&2; echo "npm error notarget No matching version found for $(cat package-lock.json)" >&2; exit 1; fi',
106
+ `if [[ -f "${slash(svc)}/npm-fail-once" ]]; then rm -f "${slash(svc)}/npm-fail-once"; echo "npm error code EINTEGRITY" >&2; exit 1; fi`,
107
+ 'rm -rf node_modules; mkdir -p node_modules',
108
+ ].join('\n'));
109
+ // /health: git_sha fixed at process start; version read from disk per request.
110
+ // svc/curl-fail-once: one request times out (a slow answer).
111
+ stub('curl', [
112
+ `if [[ -f "${slash(svc)}/curl-fail-once" ]]; then rm -f "${slash(svc)}/curl-fail-once"; exit 28; fi`,
113
+ `[[ -f "${slash(started)}" ]] || exit 7`,
114
+ `ver="$(node -p "require('fs').readFileSync('${slash(deploy)}/package.json','utf8').match(/\\"version\\": \\"([^\\"]+)/)[1]")"`,
115
+ `printf '{"status":"ok","version":"%s","git_sha":"%s"}' "$ver" "$(tr -d '\\r\\n' < "${slash(started)}")"`,
116
+ ].join('\n'));
117
+
118
+ const updaterEnv = (overrides = {}) => ({
119
+ ...env,
120
+ RDC_TEST_STUBS: posix(stubs),
121
+ RDC_TEST_UPDATER: slash(UPDATER),
122
+ RDC_SKILLS_DEPLOY_ROOT: slash(deploy),
123
+ RDC_SKILLS_EXPECTED_ROOT: slash(deploy),
124
+ RDC_SKILLS_UNIT_DIR: slash(units),
125
+ RDC_SKILLS_LIBEXEC_DIR: slash(libexec),
126
+ RDC_SKILLS_STATE_DIR: slash(stateDir),
127
+ RDC_SKILLS_LOCK_FILE: slash(path.join(dir, 'update.lock')),
128
+ RDC_SKILLS_HEALTH_URL: 'http://127.0.0.1:1/health',
129
+ RDC_SKILLS_HEALTH_DEADLINE: '0',
130
+ RDC_SKILLS_HEALTH_INTERVAL: '0',
131
+ ...overrides,
132
+ });
133
+ const update = (overrides) => {
134
+ const r = spawnSync(BASH, ['-c', 'PATH="$RDC_TEST_STUBS:$PATH" exec bash "$RDC_TEST_UPDATER"'], { env: updaterEnv(overrides), encoding: 'utf8', input: '' });
135
+ return { status: r.status, out: `${r.stdout}${r.stderr}` };
136
+ };
137
+ const clearCalls = () => fs.rmSync(calls, { force: true });
138
+ const cloneOnly = () => run(dir, ['clone', '-q', '--branch', 'master', slash(origin), slash(deploy)]);
139
+ // The service as an operator left it: started by hand on whatever is checked out.
140
+ const startService = () => {
141
+ const r = spawnSync(BASH, ['-c', `"${posix(path.join(stubs, 'systemctl'))}" restart rdc-skills-mcp.service`], { env: updaterEnv(), encoding: 'utf8' });
142
+ assert.equal(r.status, 0, r.stderr);
143
+ clearCalls();
144
+ };
145
+ // A host brought up by the bootstrap: cloned, then converged and proved once.
146
+ const bootstrap = () => {
147
+ cloneOnly();
148
+ const r = update();
149
+ assert.equal(r.status, 0, `bootstrap did not converge: ${r.out}`);
150
+ clearCalls();
151
+ return r;
152
+ };
153
+ const head = () => run(deploy, ['rev-parse', 'HEAD']);
154
+ const servingSha = () => (fs.existsSync(started) ? fs.readFileSync(started, 'utf8').trim() : null);
155
+ const callLog = () => (fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '');
156
+ const restarts = () => (callLog().match(/systemctl restart/g) || []).length;
157
+ const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
158
+ const failedList = () => {
159
+ const f = path.join(stateDir, 'failed');
160
+ return fs.existsSync(f) ? fs.readFileSync(f, 'utf8').split('\n').filter(Boolean) : [];
161
+ };
162
+ return { release, bootstrap, cloneOnly, startService, update, head, servingSha, callLog, restarts, clearCalls, failedList, run, deploy, author, origin, units, libexec, svc, started, cleanup };
163
+ }
164
+
165
+ test('a fresh checkout converges on first run: installs, restarts, and proves the tag', (t) => {
166
+ const s = sandbox(); t.after(s.cleanup);
167
+ const v1 = s.release('1.0.0');
168
+ s.run(path.dirname(s.deploy), ['clone', '-q', '--branch', 'master', slash(s.origin), slash(s.deploy)]);
169
+ const r = s.update();
170
+ assert.equal(r.status, 0, r.out);
171
+ assert.equal(s.servingSha(), v1, 'the service was restarted onto the tag');
172
+ assert.match(s.callLog(), /npm ci --omit=dev --no-audit --no-fund --ignore-scripts/);
173
+ assert.equal(fs.readFileSync(path.join(s.libexec, 'update-from-tag.sh'), 'utf8'), '# updater as of 1.0.0\n',
174
+ 'the proved release refreshed the updater copy outside the checkout');
175
+ });
176
+
177
+ test('a proved host at the newest tag is left alone', (t) => {
178
+ const s = sandbox(); t.after(s.cleanup);
179
+ s.release('1.0.0');
180
+ s.bootstrap();
181
+ const r = s.update();
182
+ assert.equal(r.status, 0, r.out);
183
+ assert.match(r.out, /current at v1\.0\.0/);
184
+ assert.equal(s.callLog(), '', 'nothing installed, nothing restarted');
185
+ });
186
+
187
+ test('a new tag is fast-forwarded, reinstalled when the lock moved, restarted, and proved', (t) => {
188
+ const s = sandbox(); t.after(s.cleanup);
189
+ s.release('1.9.0');
190
+ s.bootstrap();
191
+ // v1.10.0 sorts BEFORE v1.9.0 lexically; the updater must pick it by version.
192
+ const v110 = s.release('1.10.0');
193
+ const r = s.update();
194
+ assert.equal(r.status, 0, r.out);
195
+ assert.equal(s.head(), v110);
196
+ assert.equal(s.servingSha(), v110);
197
+ assert.match(s.callLog(), /npm ci/);
198
+ assert.equal(fs.readFileSync(path.join(s.libexec, 'update-from-tag.sh'), 'utf8'), '# updater as of 1.10.0\n');
199
+ });
200
+
201
+ test('a lock file that did not change is not reinstalled', (t) => {
202
+ const s = sandbox(); t.after(s.cleanup);
203
+ s.release('2.0.0', { lock: 'same' });
204
+ s.bootstrap();
205
+ const v201 = s.release('2.0.1', { lock: 'same' });
206
+ const r = s.update();
207
+ assert.equal(r.status, 0, r.out);
208
+ assert.equal(s.servingSha(), v201);
209
+ assert.doesNotMatch(s.callLog(), /npm ci/);
210
+ });
211
+
212
+ test('an untagged commit on master is not deployed — the tag is the promotion', (t) => {
213
+ const s = sandbox(); t.after(s.cleanup);
214
+ const v1 = s.release('1.0.0');
215
+ s.bootstrap();
216
+ s.release('1.0.1', { tag: false });
217
+ const r = s.update();
218
+ assert.equal(r.status, 0, r.out);
219
+ assert.equal(s.head(), v1);
220
+ });
221
+
222
+ test('a newer release tag that is not on master is not deployed', (t) => {
223
+ const s = sandbox(); t.after(s.cleanup);
224
+ const v1 = s.release('1.0.0');
225
+ s.bootstrap();
226
+ s.release('9.0.0', { branch: 'side' });
227
+ const r = s.update();
228
+ assert.equal(r.status, 0, r.out);
229
+ assert.equal(s.head(), v1);
230
+ assert.equal(s.servingSha(), v1);
231
+ });
232
+
233
+ test('pre-release and stray v* tags are never served and never outrank a release', (t) => {
234
+ const s = sandbox(); t.after(s.cleanup);
235
+ const v3 = s.release('3.0.0');
236
+ s.bootstrap();
237
+ s.release('3.1.0-rc.1', { tag: 'v3.1.0-rc.1' });
238
+ s.release('3.1.0-wip', { tag: 'v99-test' });
239
+ const r = s.update();
240
+ assert.equal(r.status, 0, r.out);
241
+ assert.match(r.out, /current at v3\.0\.0/, 'v99-test and v3.1.0-rc.1 both sort above v3.0.0 and are ignored');
242
+ assert.equal(s.servingSha(), v3, 'still serving the last plain release');
243
+ });
244
+
245
+ test('a release that never answers is rolled back, then skipped until a newer tag', (t) => {
246
+ const s = sandbox(); t.after(s.cleanup);
247
+ const good = s.release('3.0.0');
248
+ s.bootstrap();
249
+ s.release('3.1.0', { broken: true });
250
+
251
+ const first = s.update();
252
+ assert.equal(first.status, 5, first.out);
253
+ assert.equal(s.head(), good, 'checkout ends on the release that answered');
254
+ assert.equal(s.servingSha(), good, '/health proves the rollback took');
255
+
256
+ s.clearCalls();
257
+ const again = s.update();
258
+ assert.equal(again.status, 7, again.out);
259
+ assert.equal(s.restarts(), 0, 'a failed release is not retried every tick');
260
+ assert.equal(s.head(), good);
261
+
262
+ const fixed = s.release('3.2.0');
263
+ const next = s.update();
264
+ assert.equal(next.status, 0, next.out);
265
+ assert.equal(s.servingSha(), fixed);
266
+ });
267
+
268
+ test('an install outage is not a bad release: nothing is recorded, and the release ships once the outage ends', (t) => {
269
+ const s = sandbox(); t.after(s.cleanup);
270
+ const good = s.release('2.0.0');
271
+ s.bootstrap();
272
+ const next = s.release('2.1.0');
273
+ fs.writeFileSync(path.join(s.svc, 'npm-fail'), '');
274
+ // Longer than the install-error limit: a registry outage is never counted.
275
+ for (let i = 0; i < 4; i += 1) {
276
+ const during = s.update();
277
+ assert.equal(during.status, 6, during.out);
278
+ }
279
+ assert.equal(s.head(), good, 'the checkout is back on the release that was serving');
280
+ assert.equal(s.servingSha(), good);
281
+ assert.deepEqual(s.failedList(), [], 'an outage says nothing about the release');
282
+
283
+ fs.rmSync(path.join(s.svc, 'npm-fail'));
284
+ const after = s.update();
285
+ assert.equal(after.status, 0, after.out);
286
+ assert.equal(s.servingSha(), next);
287
+ });
288
+
289
+ test('a release that never reaches the registry is judged bad once the network-error window passes', (t) => {
290
+ const s = sandbox(); t.after(s.cleanup);
291
+ const good = s.release('2.0.0');
292
+ s.bootstrap();
293
+ const next = s.release('2.1.0', { lock: 'network-only' });
294
+ fs.writeFileSync(path.join(s.svc, 'npm-fail'), '');
295
+ const r = s.update({ RDC_SKILLS_NETWORK_ERROR_WINDOW: '0' });
296
+ assert.equal(r.status, 6, r.out); // bad, but its own return to 2.0.0 hits the same outage
297
+ assert.deepEqual(s.failedList(), [next]);
298
+ assert.equal(s.servingSha(), good);
299
+ });
300
+
301
+ test('only npm\'s own error lines count as a network failure', (t) => {
302
+ const s = sandbox(); t.after(s.cleanup);
303
+ s.release('2.0.0');
304
+ s.bootstrap();
305
+ // A lockfile problem whose message merely mentions a network-ish package name.
306
+ const next = s.release('2.1.0', { lock: 'uninstallable network-utils' });
307
+ const statuses = [s.update(), s.update(), s.update()].map((r) => r.status);
308
+ assert.deepEqual(statuses, [6, 6, 5], 'counted like any install error');
309
+ assert.deepEqual(s.failedList(), [next]);
310
+ });
311
+
312
+ test('a one-off install failure is retried on the next run, not skipped until a newer tag', (t) => {
313
+ const s = sandbox(); t.after(s.cleanup);
314
+ const good = s.release('2.0.0');
315
+ s.bootstrap();
316
+ const next = s.release('2.1.0');
317
+ fs.writeFileSync(path.join(s.svc, 'npm-fail-once'), '');
318
+ const first = s.update();
319
+ assert.equal(first.status, 6, first.out);
320
+ assert.equal(s.servingSha(), good, 'the rollback reinstalled and restarted the previous release');
321
+ const second = s.update();
322
+ assert.equal(second.status, 0, second.out);
323
+ assert.equal(s.servingSha(), next);
324
+ assert.deepEqual(s.failedList(), []);
325
+ });
326
+
327
+ test('bootstrap with no history: a bad release returns to the commit that was serving', (t) => {
328
+ const s = sandbox(); t.after(s.cleanup);
329
+ const serving = s.release('1.0.0');
330
+ s.cloneOnly();
331
+ s.startService();
332
+ const bad = s.release('1.1.0', { broken: true });
333
+ // The operator's bootstrap step: fast-forward the checkout to the new tag.
334
+ s.run(s.deploy, ['fetch', '-q', '--tags', 'origin']);
335
+ s.run(s.deploy, ['merge', '-q', '--ff-only', 'v1.1.0']);
336
+
337
+ const first = s.update();
338
+ assert.equal(first.status, 5, first.out);
339
+ assert.equal(s.head(), serving);
340
+ assert.equal(s.servingSha(), serving, '/health proves the host is back on what it served before');
341
+
342
+ s.clearCalls();
343
+ for (let i = 0; i < 2; i += 1) {
344
+ const again = s.update();
345
+ assert.equal(again.status, 7, again.out);
346
+ }
347
+ assert.equal(s.restarts(), 0, 'a held, healthy release is not restarted');
348
+ assert.deepEqual(s.failedList(), [bad], 'recorded once, however many runs');
349
+ });
350
+
351
+ test('a bad release with nothing to return to fails every run, and is recorded once', (t) => {
352
+ const s = sandbox(); t.after(s.cleanup);
353
+ s.release('1.0.0', { broken: true });
354
+ s.cloneOnly(); // never served anything: no history, and /health does not answer
355
+ const bad = s.head();
356
+ for (let i = 0; i < 3; i += 1) {
357
+ const r = s.update();
358
+ assert.equal(r.status, 6, r.out);
359
+ }
360
+ assert.deepEqual(s.failedList(), [bad]);
361
+ });
362
+
363
+ test('a skipped release still holds what is checked out: a service that died is brought back', (t) => {
364
+ const s = sandbox(); t.after(s.cleanup);
365
+ const good = s.release('3.0.0');
366
+ s.bootstrap();
367
+ s.release('3.1.0', { broken: true });
368
+ assert.equal(s.update().status, 5);
369
+ fs.rmSync(s.started); // the process crashed after the rollback
370
+ const r = s.update();
371
+ assert.equal(r.status, 7, r.out);
372
+ assert.equal(s.servingSha(), good);
373
+ assert.equal(s.restarts() > 0, true);
374
+ });
375
+
376
+ test('the proved release is never marked failed, even when it does not come back up', (t) => {
377
+ const s = sandbox(); t.after(s.cleanup);
378
+ s.release('1.0.0');
379
+ s.bootstrap();
380
+ fs.rmSync(s.started); // it crashed
381
+ fs.writeFileSync(path.join(s.svc, 'no-restart'), ''); // and the restart does not take
382
+ const r = s.update();
383
+ assert.equal(r.status, 6, r.out);
384
+ assert.deepEqual(s.failedList(), [], 'it served before; a slow or broken host is not a bad release');
385
+ });
386
+
387
+ test('one slow /health answer does not restart a healthy service', (t) => {
388
+ const s = sandbox(); t.after(s.cleanup);
389
+ s.release('1.0.0');
390
+ s.bootstrap();
391
+ fs.writeFileSync(path.join(s.svc, 'curl-fail-once'), '');
392
+ const r = s.update();
393
+ assert.equal(r.status, 0, r.out);
394
+ assert.match(r.out, /current at v1\.0\.0/);
395
+ assert.equal(s.restarts(), 0);
396
+ });
397
+
398
+ test('a restart that silently does not take is not reported as a deploy', (t) => {
399
+ const s = sandbox(); t.after(s.cleanup);
400
+ const v1 = s.release('4.0.0');
401
+ s.bootstrap();
402
+ s.release('4.1.0');
403
+ fs.writeFileSync(path.join(s.svc, 'no-restart'), '');
404
+ const r = s.update();
405
+ assert.notEqual(r.status, 0, r.out);
406
+ assert.equal(s.servingSha(), v1, 'the old process is still the one serving');
407
+ assert.equal(s.head(), v1, 'the checkout is back on what is actually running');
408
+ });
409
+
410
+ test('a checkout moved without a restart (an interrupted run) converges instead of reading as current', (t) => {
411
+ const s = sandbox(); t.after(s.cleanup);
412
+ const v1 = s.release('5.0.0');
413
+ s.bootstrap();
414
+ const v2 = s.release('5.1.0');
415
+ s.run(s.deploy, ['fetch', '-q', 'origin']);
416
+ s.run(s.deploy, ['reset', '-q', '--hard', v2]);
417
+ assert.equal(s.servingSha(), v1, 'precondition: the service never restarted');
418
+ const r = s.update();
419
+ assert.equal(r.status, 0, r.out);
420
+ assert.match(r.out, /converging/);
421
+ assert.match(s.callLog(), /npm ci/, 'the lock moved with the interrupted checkout, so it is reinstalled');
422
+ assert.equal(s.servingSha(), v2);
423
+ });
424
+
425
+ test('a tag moved on origin does not block later releases', (t) => {
426
+ const s = sandbox(); t.after(s.cleanup);
427
+ s.release('6.0.0');
428
+ s.bootstrap();
429
+ // Re-created on an unrelated commit: not a fast-forward of where it was, so
430
+ // only a forced fetch can follow it.
431
+ const unrelated = s.run(s.author, ['commit-tree', 'HEAD^{tree}', '-m', 'v6.0.0 re-created elsewhere']);
432
+ s.run(s.author, ['tag', '-f', 'v6.0.0', unrelated]);
433
+ s.run(s.author, ['push', '-q', '-f', 'origin', 'refs/tags/v6.0.0']);
434
+ const v61 = s.release('6.1.0');
435
+ const r = s.update();
436
+ assert.equal(r.status, 0, r.out);
437
+ assert.equal(s.servingSha(), v61);
438
+ });
439
+
440
+ test('tracked changes on the host are preserved and nothing moves', (t) => {
441
+ const s = sandbox(); t.after(s.cleanup);
442
+ const v1 = s.release('7.0.0');
443
+ s.bootstrap();
444
+ s.release('7.1.0');
445
+ fs.appendFileSync(path.join(s.deploy, 'package.json'), '\n');
446
+ const r = s.update();
447
+ assert.equal(r.status, 3, r.out);
448
+ assert.equal(s.head(), v1);
449
+ assert.match(fs.readFileSync(path.join(s.deploy, 'package.json'), 'utf8'), /\n\n$/, 'the local edit survives');
450
+ assert.equal(s.callLog(), '');
451
+ });
452
+
453
+ test('a host commit that the tag does not descend from is refused, not overwritten', (t) => {
454
+ const s = sandbox(); t.after(s.cleanup);
455
+ s.release('8.0.0');
456
+ s.bootstrap();
457
+ s.release('8.1.0');
458
+ s.run(s.deploy, ['commit', '-q', '--allow-empty', '-m', 'hotfix made on the host']);
459
+ const local = s.head();
460
+ const r = s.update();
461
+ assert.equal(r.status, 4, r.out);
462
+ assert.equal(s.head(), local, 'the host commit is preserved');
463
+ assert.notEqual(s.servingSha(), local, 'and never served');
464
+ assert.equal(s.restarts(), 0, 'the service is left as it is');
465
+ });
466
+
467
+ test('a higher tag on an older commit is not a release: the proved one is held, and a real newer tag ships', (t) => {
468
+ const s = sandbox(); t.after(s.cleanup);
469
+ const old = s.release('1.0.0');
470
+ const current = s.release('2.0.0');
471
+ s.bootstrap();
472
+ s.run(s.author, ['tag', 'v2.0.1', old]);
473
+ s.run(s.author, ['push', '-q', 'origin', 'refs/tags/v2.0.1']);
474
+ const r = s.update();
475
+ assert.equal(r.status, 9, r.out);
476
+ assert.equal(s.servingSha(), current, 'never downgraded');
477
+ assert.equal(s.restarts(), 0);
478
+ const next = s.release('3.0.0');
479
+ const after = s.update();
480
+ assert.equal(after.status, 0, after.out);
481
+ assert.equal(s.servingSha(), next);
482
+ });
483
+
484
+ test('with no history, a service running a host-made commit is not adopted, and the commit is kept', (t) => {
485
+ const s = sandbox(); t.after(s.cleanup);
486
+ s.release('1.0.0');
487
+ s.cloneOnly();
488
+ s.run(s.deploy, ['commit', '-q', '--allow-empty', '-m', 'made on the host']);
489
+ const local = s.head();
490
+ s.startService();
491
+ s.release('1.1.0');
492
+ const r = s.update();
493
+ assert.equal(r.status, 4, r.out);
494
+ assert.doesNotMatch(r.out, /adopted/);
495
+ assert.equal(s.head(), local);
496
+ assert.equal(s.servingSha(), local, 'the service is left as it is');
497
+ });
498
+
499
+ test('a failed refresh of the updater copy is reported, and retried until it lands', (t) => {
500
+ const s = sandbox(); t.after(s.cleanup);
501
+ s.release('1.0.0');
502
+ s.bootstrap();
503
+ s.release('1.1.0');
504
+ const blocked = path.join(s.svc, 'not-a-directory');
505
+ fs.writeFileSync(blocked, '');
506
+ const first = s.update({ RDC_SKILLS_LIBEXEC_DIR: slash(path.join(blocked, 'libexec')) });
507
+ assert.equal(first.status, 8, first.out);
508
+ const second = s.update();
509
+ assert.equal(second.status, 0, second.out);
510
+ assert.equal(fs.readFileSync(path.join(s.libexec, 'update-from-tag.sh'), 'utf8'), '# updater as of 1.1.0\n');
511
+ });
512
+
513
+ test('a pull to untagged master is moved back to the release, not served', (t) => {
514
+ const s = sandbox(); t.after(s.cleanup);
515
+ const v1 = s.release('1.0.0', { lock: 'same' });
516
+ s.bootstrap();
517
+ const untagged = s.release('1.0.1', { lock: 'same', tag: false });
518
+ // The old manual procedure: git pull --ff-only origin master.
519
+ s.run(s.deploy, ['fetch', '-q', 'origin']);
520
+ s.run(s.deploy, ['merge', '-q', '--ff-only', untagged]);
521
+ const r = s.update();
522
+ assert.equal(r.status, 0, r.out);
523
+ assert.equal(s.head(), v1);
524
+ assert.equal(s.servingSha(), v1);
525
+ assert.equal(s.restarts(), 0, 'it was still serving the release; only the checkout moved back');
526
+ });
527
+
528
+ test('a release whose lockfile never installs is retried twice, then treated as bad', (t) => {
529
+ const s = sandbox(); t.after(s.cleanup);
530
+ const good = s.release('2.0.0');
531
+ s.bootstrap();
532
+ const broken = s.release('2.1.0', { lock: 'uninstallable' });
533
+ const statuses = [s.update(), s.update(), s.update()].map((r) => r.status);
534
+ assert.deepEqual(statuses, [6, 6, 5]);
535
+ assert.equal(s.servingSha(), good);
536
+ assert.deepEqual(s.failedList(), [broken]);
537
+ s.clearCalls();
538
+ const held = s.update();
539
+ assert.equal(held.status, 7, held.out);
540
+ assert.equal(s.restarts(), 0);
541
+ assert.doesNotMatch(s.callLog(), /npm ci/);
542
+ });
543
+
544
+ test('bootstrap: a restart that fails on the new release returns to the adopted serving commit', (t) => {
545
+ const s = sandbox(); t.after(s.cleanup);
546
+ const serving = s.release('1.0.0');
547
+ s.cloneOnly();
548
+ s.startService();
549
+ const next = s.release('1.1.0');
550
+ s.run(s.deploy, ['fetch', '-q', '--tags', 'origin']);
551
+ s.run(s.deploy, ['merge', '-q', '--ff-only', 'v1.1.0']);
552
+ fs.writeFileSync(path.join(s.svc, 'restart-fail-once'), '');
553
+ const first = s.update();
554
+ assert.equal(first.status, 6, first.out);
555
+ assert.equal(s.servingSha(), serving, 'the way back survived the failed restart');
556
+ const second = s.update();
557
+ assert.equal(second.status, 0, second.out);
558
+ assert.equal(s.servingSha(), next);
559
+ });
560
+
561
+ test('changed unit files are installed and systemd reloaded', (t) => {
562
+ const s = sandbox(); t.after(s.cleanup);
563
+ s.release('9.0.0', { unit: 'unit-v1' });
564
+ s.bootstrap();
565
+ s.release('9.1.0', { unit: 'unit-v2' });
566
+ const r = s.update();
567
+ assert.equal(r.status, 0, r.out);
568
+ assert.match(fs.readFileSync(path.join(s.units, 'rdc-skills-mcp.service'), 'utf8'), /unit-v2/);
569
+ assert.match(s.callLog(), /systemctl daemon-reload/);
570
+ });
571
+
572
+ test('a stale git-sha.json stamp in the checkout is removed so the proof can pass', (t) => {
573
+ const s = sandbox(); t.after(s.cleanup);
574
+ s.release('10.0.0');
575
+ s.bootstrap();
576
+ const v2 = s.release('10.1.0');
577
+ fs.writeFileSync(path.join(s.deploy, 'git-sha.json'), '{"sha":"0000000"}\n');
578
+ const r = s.update();
579
+ assert.equal(r.status, 0, r.out);
580
+ assert.equal(fs.existsSync(path.join(s.deploy, 'git-sha.json')), false);
581
+ assert.equal(s.servingSha(), v2);
582
+ });
583
+
584
+ test('the updater refuses any checkout other than the host path', (t) => {
585
+ const s = sandbox(); t.after(s.cleanup);
586
+ s.release('11.0.0');
587
+ s.bootstrap();
588
+ const r = s.update({ RDC_SKILLS_EXPECTED_ROOT: '/srv/regen/rdc-skills' });
589
+ assert.equal(r.status, 2, r.out);
590
+ });
591
+
592
+ test('the units and installer run the updater from outside the checkout', () => {
593
+ const timer = read('deploy/systemd/rdc-skills-mcp-update.timer');
594
+ const service = read('deploy/systemd/rdc-skills-mcp-update.service');
595
+ const installer = read('deploy/install-systemd.sh');
596
+ const updater = read('deploy/update-from-tag.sh');
597
+ assert.match(timer, /OnUnitActiveSec=/);
598
+ assert.doesNotMatch(timer, /Persistent=/, 'Persistent= only applies to OnCalendar=');
599
+ assert.match(service, /Type=oneshot/);
600
+ assert.match(service, /^ExecStart=\/usr\/local\/libexec\/rdc-skills\/update-from-tag\.sh$/m);
601
+ assert.match(service, /^StateDirectory=rdc-skills-update$/m);
602
+ assert.match(installer, /install -D -m 0755 deploy\/update-from-tag\.sh "\$libexec_dir\/update-from-tag\.sh"/);
603
+ assert.match(installer, /systemctl start rdc-skills-mcp-update\.service/, 'the bootstrap restarts into the release and proves it');
604
+ assert.match(installer, /systemctl enable --now rdc-skills-mcp-update\.timer/);
605
+ assert.match(installer, /--ignore-scripts/);
606
+ // The script replaces itself mid-run; only a body behind a final call is safe.
607
+ assert.match(updater.trimEnd(), /\nmain "\$@"; exit \$\?$/);
608
+ });
@@ -0,0 +1,26 @@
1
+ // .npmignore replaces .gitignore for npm. It must carry every .gitignore rule except
2
+ // git-sha.json — the pack-time commit stamp that is untracked in git and exists to be
3
+ // shipped — or the package either loses the stamp or starts shipping what git ignores.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const rules = (file) => new Set(fs.readFileSync(path.join(ROOT, file), 'utf8')
12
+ .split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')));
13
+
14
+ test('.npmignore is .gitignore without the commit stamp', () => {
15
+ const git = rules('.gitignore');
16
+ const npm = rules('.npmignore');
17
+ assert.ok(git.has('git-sha.json'), 'the stamp stays out of git');
18
+ assert.ok(!npm.has('git-sha.json'), 'and stays IN the package');
19
+ const expected = [...git].filter((r) => r !== 'git-sha.json').sort();
20
+ assert.deepEqual([...npm].sort(), expected);
21
+ });
22
+
23
+ test('the installer records the stamped commit when it runs from a package with no .git', () => {
24
+ const installer = fs.readFileSync(path.join(ROOT, 'scripts', 'install-rdc-skills.js'), 'utf8');
25
+ assert.match(installer, /git-sha\.json/);
26
+ });