@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.
- package/.claude-plugin/plugin.json +1 -1
- package/RELEASE.md +53 -11
- package/deploy/install-systemd.sh +29 -6
- package/deploy/systemd/rdc-skills-mcp-update.service +15 -0
- package/deploy/systemd/rdc-skills-mcp-update.timer +9 -0
- package/deploy/update-from-tag.sh +382 -0
- package/git-sha.json +3 -0
- package/guides/work-contract.md +11 -0
- package/package.json +3 -2
- package/scripts/install-rdc-skills.js +8 -1
- package/tests/deploy-updater.test.mjs +608 -0
- package/tests/npm-ignore-parity.test.mjs +26 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rdc",
|
|
3
|
-
"version": "0.35.
|
|
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`
|
|
73
|
-
straight from
|
|
74
|
-
from `bin/rdc-skills-mcp.mjs`'s runtime
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
22
|
-
|
|
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,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
package/guides/work-contract.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
});
|