@crvouga/dev-fleet 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +7 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +12 -3
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +89 -0
- package/docs/machine-setup.md +399 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +67 -0
- package/docs/ubuntu-sync.md +351 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +35 -0
- package/mise.lock +29 -0
- package/mise.toml +12 -1
- package/package.json +11 -2
- package/schema/fleet.schema.json +71 -15
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/native-verify.ts +167 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +64 -8
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +4 -1
- package/src/cli/fleet-sync-remote.ts +11 -5
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +123 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +51 -1
- package/src/cli/registry.ts +28 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +31 -1
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +8 -3
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +2 -0
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/remote-bootstrap.ts +17 -19
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +23 -1
- package/src/toolchain-checks.ts +25 -0
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +5 -0
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Executed over strict SSH from the selected verified Git revision. stdin is
|
|
3
|
-
#
|
|
2
|
+
# Executed over strict SSH from the selected verified Git revision. stdin is
|
|
3
|
+
# one line holding the base64 fleet configuration (empty when there is none),
|
|
4
|
+
# then the base64 Git bundle (nothing in cached mode). All executable content
|
|
5
|
+
# remains in this tracked source.
|
|
4
6
|
set -eu
|
|
5
7
|
umask 077
|
|
6
8
|
revision=$1
|
|
@@ -16,12 +18,47 @@ case "$node" in *[!a-z0-9-]*|'') exit 2;; esac
|
|
|
16
18
|
phase=bootstrap
|
|
17
19
|
bundle=''
|
|
18
20
|
code=0
|
|
21
|
+
apt_log=''
|
|
19
22
|
failure_code=REMOTE_BOOTSTRAP_INCOMPLETE
|
|
20
23
|
die() { failure_code=$1; exit 1; }
|
|
24
|
+
# One remedy per failure: what the operator does next. Never raw output.
|
|
25
|
+
remedy() {
|
|
26
|
+
case "$1" in
|
|
27
|
+
FLEET_REMOTE_LOCKED) echo 'Another fleet operation holds this machine. Wait for it to finish; rerun fleet-sync afterwards. Never remove the lock while its holder runs.';;
|
|
28
|
+
ROOT_DEVELOPMENT_USER_REJECTED) echo 'Connect as the non-root developer account the fleet configuration names.';;
|
|
29
|
+
UBUNTU_BASELINE_MISMATCH|UBUNTU_ARCHITECTURE_UNSUPPORTED|MACOS_BASELINE_MISMATCH|MACOS_ARCHITECTURE_UNSUPPORTED|PLATFORM_UNSUPPORTED) echo 'Workers run Ubuntu 26.04 on x86_64 or aarch64. Reinstall the machine; fleet-sync never upgrades an operating system.';;
|
|
30
|
+
FLEET_STATE_PATH_UNSAFE) echo 'A fleet state directory under ~/.local/share/personal is a symbolic link. Replace it with a real directory.';;
|
|
31
|
+
BOOTSTRAP_CAPACITY_UNVERIFIED|BOOTSTRAP_DISK_CAPACITY_REQUIRED) echo 'Free at least 2 GiB and 50000 inodes on the home file system, then rerun fleet-sync.';;
|
|
32
|
+
SOURCE_DIGEST_MISMATCH) echo 'The bundle changed in transit. Rerun fleet-sync; if it repeats, check the SSH route.';;
|
|
33
|
+
SUDO_AUTHORIZATION_REQUIRED) echo 'Grant the developer account passwordless sudo, or run fleet-sync interactively once to authorize it.';;
|
|
34
|
+
APT_LOCK_CONTENDED) echo 'Another package operation held the APT lock past the bounded wait. Let it finish (it is never killed), then rerun fleet-sync.';;
|
|
35
|
+
APT_DNS_UNAVAILABLE) echo 'The worker cannot resolve the Ubuntu archive. Restore its DNS, then rerun fleet-sync.';;
|
|
36
|
+
APT_REPOSITORY_UNAVAILABLE) echo 'The Ubuntu archive could not be reached. Check the network or archive status, then rerun fleet-sync.';;
|
|
37
|
+
APT_DISK_FULL) echo 'Package installation ran out of disk space. Free space under /var and /usr, then rerun fleet-sync.';;
|
|
38
|
+
DPKG_INTERRUPTED) echo 'An earlier package installation that fleet-sync did not start was interrupted. Run sudo dpkg --configure -a once no package process is running, then rerun fleet-sync.';;
|
|
39
|
+
APT_UPDATE_FAILED|APT_INSTALL_FAILED) echo 'Run sudo apt-get update and install the bootstrap packages by hand to see the error, then rerun fleet-sync.';;
|
|
40
|
+
XCODE_COMMAND_LINE_TOOLS_REQUIRED) echo 'Install the Xcode Command Line Tools (xcode-select --install), then rerun fleet-sync.';;
|
|
41
|
+
BOOTSTRAP_LOCK_TOOL_REQUIRED) echo 'Install python3 so the operation lock can be taken, then rerun fleet-sync.';;
|
|
42
|
+
SOURCE_CACHE_CHANGED|SOURCE_STATUS_FAILED|SOURCE_CHECKOUT_CONFLICT) echo 'The fleet-owned checkout of this revision holds changes fleet-sync did not make. Inspect and remove that revisions directory, then rerun fleet-sync.';;
|
|
43
|
+
CONFIGURATION_INSTALL_FAILED) echo 'The fleet configuration could not be written under ~/.config/dev-fleet. Check that directory, then rerun fleet-sync.';;
|
|
44
|
+
TOOLCHAIN_*) echo 'The pinned toolchain did not converge. Rerun fleet-sync; it resumes where it stopped. If it repeats, run devfleet doctor on the worker.';;
|
|
45
|
+
*) echo 'Inspect the remote operation lock and rerun the approved revision. No success receipt advanced.';;
|
|
46
|
+
esac
|
|
47
|
+
}
|
|
48
|
+
# Names an APT failure by the fixed messages of the pinned Ubuntu release.
|
|
49
|
+
apt_failure() {
|
|
50
|
+
if grep -q 'Could not get lock' "$apt_log" 2>/dev/null; then die APT_LOCK_CONTENDED
|
|
51
|
+
elif grep -q 'No space left on device' "$apt_log" 2>/dev/null; then die APT_DISK_FULL
|
|
52
|
+
elif grep -qE 'Temporary failure resolving|Could not resolve' "$apt_log" 2>/dev/null; then die APT_DNS_UNAVAILABLE
|
|
53
|
+
elif grep -qE 'Failed to fetch|Unable to connect|Connection failed|Unable to locate package' "$apt_log" 2>/dev/null; then die APT_REPOSITORY_UNAVAILABLE
|
|
54
|
+
elif grep -q 'dpkg was interrupted' "$apt_log" 2>/dev/null; then die DPKG_INTERRUPTED
|
|
55
|
+
else die "$1"
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
21
58
|
fail() {
|
|
22
|
-
printf '{"version":1,"checks":[{"id":"bootstrap:%s","status":"fail","summary":"%s","remedy":{"action":"
|
|
59
|
+
printf '{"version":1,"checks":[{"id":"bootstrap:%s","status":"fail","summary":"%s","remedy":{"action":"%s"}}]}\n' "$phase" "$failure_code" "$(remedy "$failure_code")"
|
|
23
60
|
}
|
|
24
|
-
trap 'code=$?; [ -z "$bundle" ] || rm -f -- "$bundle"; if [ "$code" != 0 ]; then fail; fi' EXIT
|
|
61
|
+
trap 'code=$?; [ -z "$bundle" ] || rm -f -- "$bundle"; [ -z "$apt_log" ] || rm -f -- "$apt_log"; if [ "$code" != 0 ]; then fail; fi' EXIT
|
|
25
62
|
# Preserve the lock holder if its SSH channel disappears mid-install.
|
|
26
63
|
trap '' HUP
|
|
27
64
|
trap 'exit 1' INT TERM
|
|
@@ -61,6 +98,16 @@ else
|
|
|
61
98
|
python3 -c 'import fcntl; fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)' 9>&9 || die FLEET_REMOTE_LOCKED
|
|
62
99
|
fi
|
|
63
100
|
printf '{"version":1,"pid":%s,"revision":"%s","node":"%s"}\n' "$$" "$revision" "$node" >"$root/remote-operation.json"
|
|
101
|
+
# The fleet configuration is installed under the lock, so two overlapping
|
|
102
|
+
# runs never write it at once. It holds no secret.
|
|
103
|
+
phase='configuration-install'
|
|
104
|
+
IFS= read -r configuration || configuration=''
|
|
105
|
+
if [ -n "$configuration" ]; then
|
|
106
|
+
config_dir="$HOME/.config/dev-fleet"
|
|
107
|
+
mkdir -p "$config_dir" || die CONFIGURATION_INSTALL_FAILED
|
|
108
|
+
printf '%s' "$configuration" | base64 -d >"$config_dir/fleet.json.new" || die CONFIGURATION_INSTALL_FAILED
|
|
109
|
+
mv "$config_dir/fleet.json.new" "$config_dir/fleet.json" || die CONFIGURATION_INSTALL_FAILED
|
|
110
|
+
fi
|
|
64
111
|
phase=resources
|
|
65
112
|
# A bootstrap floor protects source transfer and minimal prerequisites. The
|
|
66
113
|
# Workstation baseline subsequently enforces its stricter declared capacity.
|
|
@@ -87,11 +134,30 @@ for package in git curl ca-certificates unzip xz-utils build-essential python3 a
|
|
|
87
134
|
done
|
|
88
135
|
if [ -n "$missing" ]; then
|
|
89
136
|
sudo -n true >/dev/null 2>&1 || die SUDO_AUTHORIZATION_REQUIRED
|
|
137
|
+
# APT's own messages, in a private file read only for fixed patterns and
|
|
138
|
+
# removed on exit: they tell a held lock, DNS, an unreachable archive and a
|
|
139
|
+
# full disk apart. Nothing of them is reported.
|
|
140
|
+
apt_log=$(mktemp "$root/apt.XXXXXX")
|
|
141
|
+
pending="$root/prerequisites-pending"
|
|
142
|
+
# An installation this script started and that was interrupted (the shell
|
|
143
|
+
# killed, the machine powered off) leaves dpkg half-configured. Finishing
|
|
144
|
+
# our own transaction is safe; one fleet-sync did not start is reported.
|
|
145
|
+
# dpkg reads its database lock even to audit, which needs root.
|
|
146
|
+
if [ -n "$(sudo -n dpkg --audit 2>/dev/null)" ]; then
|
|
147
|
+
[ -f "$pending" ] || die DPKG_INTERRUPTED
|
|
148
|
+
# shellcheck disable=SC2024
|
|
149
|
+
sudo -n timeout 600 env DEBIAN_FRONTEND=noninteractive LC_ALL=C dpkg --configure -a >"$apt_log" 2>&1 || apt_failure APT_INSTALL_FAILED
|
|
150
|
+
fi
|
|
151
|
+
: >"$pending"
|
|
152
|
+
# The log belongs to the developer account; sudo only runs the command.
|
|
153
|
+
# shellcheck disable=SC2024
|
|
90
154
|
# Never remove APT locks or kill package processes. Timeout keeps genuine
|
|
91
155
|
# transactions bounded; Ubuntu archive signatures/TLS remain enabled.
|
|
92
|
-
sudo -n timeout 300 apt-get -o DPkg::Lock::Timeout=120 update
|
|
93
|
-
# shellcheck disable=SC2086
|
|
94
|
-
sudo -n timeout 600 env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 --no-install-recommends install -y $missing
|
|
156
|
+
sudo -n timeout 300 env LC_ALL=C apt-get -o DPkg::Lock::Timeout=120 update >"$apt_log" 2>&1 || apt_failure APT_UPDATE_FAILED
|
|
157
|
+
# shellcheck disable=SC2086,SC2024
|
|
158
|
+
sudo -n timeout 600 env DEBIAN_FRONTEND=noninteractive LC_ALL=C apt-get -o DPkg::Lock::Timeout=120 --no-install-recommends install -y $missing >>"$apt_log" 2>&1 || apt_failure APT_INSTALL_FAILED
|
|
159
|
+
rm -f -- "$pending" "$apt_log"
|
|
160
|
+
apt_log=''
|
|
95
161
|
fi
|
|
96
162
|
else
|
|
97
163
|
# Apple Command Line Tools provide git and python3; Ansible runs through the
|
|
@@ -107,18 +173,25 @@ if [ ! -e "$deployment/.git" ]; then
|
|
|
107
173
|
[ -z "$(ls -A "$deployment")" ] || exit 1
|
|
108
174
|
git -C "$deployment" init -q >/dev/null 2>&1 || exit 1
|
|
109
175
|
fi
|
|
110
|
-
# An interrupted initial fetch
|
|
111
|
-
#
|
|
176
|
+
# An interrupted initial fetch or checkout leaves a repository without a
|
|
177
|
+
# commit, possibly with some of the revision's files written. Resume only
|
|
178
|
+
# that state: every file present must be a file of the revision, so the
|
|
179
|
+
# checkout may write over it. An existing commit or other work is never reset.
|
|
112
180
|
if ! git -C "$deployment" rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
113
181
|
[ "$source_mode" = transfer ] || die SOURCE_CACHE_CHANGED
|
|
114
182
|
[ "$(git -C "$deployment" rev-parse --show-toplevel)" = "$deployment" ] || exit 1
|
|
115
|
-
clean=$(git -C "$deployment" status --porcelain) || die SOURCE_STATUS_FAILED
|
|
116
|
-
[ -z "$clean" ] || die SOURCE_CHECKOUT_CONFLICT
|
|
117
183
|
# The bundle holds this commit without its history: declare the checkout
|
|
118
184
|
# shallow at it so Git accepts a commit whose parents are absent.
|
|
119
185
|
printf '%s\n' "$revision" >"$deployment/.git/shallow"
|
|
120
186
|
git -C "$deployment" fetch --no-tags "$bundle" "$revision" >/dev/null 2>&1 || exit 1
|
|
121
|
-
|
|
187
|
+
present=$(mktemp "$root/present.XXXXXX")
|
|
188
|
+
expected=$(mktemp "$root/expected.XXXXXX")
|
|
189
|
+
git -C "$deployment" -c core.quotePath=false ls-files --others | LC_ALL=C sort >"$present" || { rm -f -- "$present" "$expected"; die SOURCE_STATUS_FAILED; }
|
|
190
|
+
git -C "$deployment" -c core.quotePath=false ls-tree -r --name-only "$revision" | LC_ALL=C sort >"$expected" || { rm -f -- "$present" "$expected"; die SOURCE_STATUS_FAILED; }
|
|
191
|
+
foreign=$(LC_ALL=C comm -23 "$present" "$expected")
|
|
192
|
+
rm -f -- "$present" "$expected"
|
|
193
|
+
[ -z "$foreign" ] || die SOURCE_CHECKOUT_CONFLICT
|
|
194
|
+
git -C "$deployment" checkout --force --detach "$revision" >/dev/null 2>&1 || exit 1
|
|
122
195
|
fi
|
|
123
196
|
[ -z "$bundle" ] || rm -f "$bundle"
|
|
124
197
|
bundle=''
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
readdirSync,
|
|
7
8
|
statSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from 'node:fs';
|
|
@@ -48,17 +49,23 @@ export function assertManagedConfig(dir: string): void {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** What `publishGlobalConfig` writes to config.toml, given what is there. */
|
|
53
|
+
function publishedConfig(root: string, previous: string | undefined): string {
|
|
54
|
+
return (
|
|
55
|
+
MARKER +
|
|
56
|
+
withTrustedPaths(
|
|
57
|
+
readFileSync(join(root, 'mise.toml'), 'utf8'),
|
|
58
|
+
trustedPaths(previous)
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
export function publishGlobalConfig(dir: string, root = workspaceRoot): void {
|
|
52
64
|
assertManagedConfig(dir);
|
|
53
65
|
mkdirSync(dir, { recursive: true });
|
|
54
66
|
const path = join(dir, 'config.toml');
|
|
55
67
|
const previous = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
56
|
-
const config =
|
|
57
|
-
MARKER +
|
|
58
|
-
withTrustedPaths(
|
|
59
|
-
readFileSync(join(root, 'mise.toml'), 'utf8'),
|
|
60
|
-
trustedPaths(previous)
|
|
61
|
-
);
|
|
68
|
+
const config = publishedConfig(root, previous);
|
|
62
69
|
if (previous !== config) writeFileSync(path, config);
|
|
63
70
|
cpSync(join(root, 'mise.lock'), join(dir, 'mise.lock'), {
|
|
64
71
|
filter: changedFile,
|
|
@@ -71,6 +78,57 @@ export function publishGlobalConfig(dir: string, root = workspaceRoot): void {
|
|
|
71
78
|
});
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/**
|
|
82
|
+
* How the global mise configuration stands against what this revision
|
|
83
|
+
* publishes. On a fleet machine it belongs to dev-fleet: a project pins its
|
|
84
|
+
* tools in its own mise.toml and never publishes a global one.
|
|
85
|
+
*
|
|
86
|
+
* - `published`: dev-fleet's marker, this revision's pins and lock
|
|
87
|
+
* - `missing`: there is no global config.toml
|
|
88
|
+
* - `replaced`: another owner (or nobody's marker) wrote it
|
|
89
|
+
* - `changed`: dev-fleet's marker, but not this revision's content
|
|
90
|
+
*/
|
|
91
|
+
export type GlobalConfigStanding =
|
|
92
|
+
'published' | 'missing' | 'replaced' | 'changed';
|
|
93
|
+
|
|
94
|
+
export function globalConfigStanding(
|
|
95
|
+
dir: string,
|
|
96
|
+
root = workspaceRoot
|
|
97
|
+
): GlobalConfigStanding {
|
|
98
|
+
const path = join(dir, 'config.toml');
|
|
99
|
+
if (!existsSync(path)) return 'missing';
|
|
100
|
+
const current = readFileSync(path, 'utf8');
|
|
101
|
+
if (!current.startsWith(MARKER)) return 'replaced';
|
|
102
|
+
const same = (source: string, target: string): boolean =>
|
|
103
|
+
existsSync(target) && readFileSync(source).equals(readFileSync(target));
|
|
104
|
+
const sidecars = join(root, '.mise/locks');
|
|
105
|
+
const locks = existsSync(sidecars)
|
|
106
|
+
? (readdirSync(sidecars, { recursive: true }) as string[])
|
|
107
|
+
.map((name) => join('.mise/locks', name))
|
|
108
|
+
.filter((name) => statSync(join(root, name)).isFile())
|
|
109
|
+
: [];
|
|
110
|
+
// Paths trusted on the machine are kept by every publish, in any order;
|
|
111
|
+
// the agent worktree roots must be among them.
|
|
112
|
+
const TRUSTED = /^trusted_config_paths\s*=.*\n/m;
|
|
113
|
+
const trusted = trustedPaths(current);
|
|
114
|
+
let listed: unknown;
|
|
115
|
+
try {
|
|
116
|
+
listed = (Bun.TOML.parse(current) as { settings?: Record<string, unknown> })
|
|
117
|
+
.settings?.['trusted_config_paths'];
|
|
118
|
+
} catch {
|
|
119
|
+
listed = undefined;
|
|
120
|
+
}
|
|
121
|
+
const unchanged =
|
|
122
|
+
Array.isArray(listed) &&
|
|
123
|
+
trusted.every((path) => listed.includes(path)) &&
|
|
124
|
+
current.replace(TRUSTED, '') ===
|
|
125
|
+
publishedConfig(root, current).replace(TRUSTED, '') &&
|
|
126
|
+
['mise.lock', ...locks].every((name) =>
|
|
127
|
+
same(join(root, name), join(dir, name))
|
|
128
|
+
);
|
|
129
|
+
return unchanged ? 'published' : 'changed';
|
|
130
|
+
}
|
|
131
|
+
|
|
74
132
|
/**
|
|
75
133
|
* The managed roots plus any entries already in the published config, so a
|
|
76
134
|
* local `mise settings add trusted_config_paths ...` survives the next publish.
|
|
@@ -3,5 +3,8 @@
|
|
|
3
3
|
set -eu
|
|
4
4
|
root=${1:-$(cd "$(dirname "$0")/.." && pwd)}
|
|
5
5
|
[ -x "$root/bootstrap/devfleet" ] || exit 1
|
|
6
|
+
# Only ever a checkout: an installed package sits wherever a package manager
|
|
7
|
+
# put it and may be gone tomorrow.
|
|
8
|
+
[ -e "$root/.git" ] || { echo 'The devfleet launcher links to a checkout of dev-fleet; fleet-sync installs it.' >&2; exit 1; }
|
|
6
9
|
mkdir -p "$HOME/.local/bin"
|
|
7
10
|
ln -sfn "$root/bootstrap/devfleet" "$HOME/.local/bin/devfleet"
|
package/bootstrap/npm.ts
CHANGED
|
@@ -162,7 +162,11 @@ export function plan(state: State): Step[] {
|
|
|
162
162
|
{
|
|
163
163
|
id: 'tag',
|
|
164
164
|
title: `Tag ${seedTag} on the commit that was published`,
|
|
165
|
-
|
|
165
|
+
// npm recorded no commit for a seed published without one, so there is
|
|
166
|
+
// nothing to tag: the Release workflow then counts from the newest
|
|
167
|
+
// version on npm (bootstrap/release-version.ts).
|
|
168
|
+
done:
|
|
169
|
+
state.tagRemote || (state.published && state.publishedCommit === null),
|
|
166
170
|
blocked: null,
|
|
167
171
|
},
|
|
168
172
|
];
|
|
@@ -204,9 +208,13 @@ function publishSeed(exec: Exec, target: Target): boolean {
|
|
|
204
208
|
);
|
|
205
209
|
const file = join(checkout, 'package.json');
|
|
206
210
|
const manifest = JSON.parse(readFileSync(file, 'utf8')) as object;
|
|
211
|
+
// As the Release workflow does: the package names the commit it is.
|
|
212
|
+
const gitHead = exec
|
|
213
|
+
.capture(['git', 'rev-parse', 'HEAD'], checkout)
|
|
214
|
+
.stdout.trim();
|
|
207
215
|
writeFileSync(
|
|
208
216
|
file,
|
|
209
|
-
`${JSON.stringify({ ...manifest, version: SEED_VERSION }, null, 2)}\n`
|
|
217
|
+
`${JSON.stringify({ ...manifest, version: SEED_VERSION, gitHead }, null, 2)}\n`
|
|
210
218
|
);
|
|
211
219
|
return exec.interactive(['npm', 'publish', '--access', 'public'], checkout);
|
|
212
220
|
} finally {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints the version the Release workflow should publish for `HEAD`, or
|
|
3
|
+
* nothing when there is nothing to release (the reason goes to stderr).
|
|
4
|
+
*
|
|
5
|
+
* The newest `vX.Y.Z` tag is the last release. Without one, the newest
|
|
6
|
+
* version on npm is: it was published by hand before the workflow tagged
|
|
7
|
+
* anything, and npm may not have recorded its commit, so every commit up to
|
|
8
|
+
* `HEAD` counts. Commits since the last release pick the bump, Conventional
|
|
9
|
+
* Commits style:
|
|
10
|
+
*
|
|
11
|
+
* `feat!:` / `BREAKING CHANGE:` major (minor while the major is 0)
|
|
12
|
+
* `feat:` minor
|
|
13
|
+
* anything else patch
|
|
14
|
+
*
|
|
15
|
+
* Exits 1 when nothing can be released because publishing was never set up
|
|
16
|
+
* (no tag and nothing on npm), so the Release workflow does not report a
|
|
17
|
+
* skipped release as a success.
|
|
18
|
+
*
|
|
19
|
+
* bun bootstrap/release-version.ts
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export interface Git {
|
|
26
|
+
/** Run git and return stdout, or null when it exits non-zero. */
|
|
27
|
+
(args: string[]): string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Decision =
|
|
31
|
+
| { release: true; version: string; previous: string }
|
|
32
|
+
| { release: false; reason: string; setup?: true };
|
|
33
|
+
|
|
34
|
+
const TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
|
|
35
|
+
const BREAKING_SUBJECT = /^\w+(\([^)]*\))?!:/;
|
|
36
|
+
const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
|
|
37
|
+
const FEATURE = /^feat(\([^)]*\))?:/;
|
|
38
|
+
|
|
39
|
+
export type Bump = 'major' | 'minor' | 'patch';
|
|
40
|
+
|
|
41
|
+
export function bumpFor(messages: string[]): Bump {
|
|
42
|
+
let bump: Bump = 'patch';
|
|
43
|
+
for (const message of messages) {
|
|
44
|
+
const subject = message.split('\n', 1)[0]!.trim();
|
|
45
|
+
if (BREAKING_SUBJECT.test(subject) || BREAKING_FOOTER.test(message))
|
|
46
|
+
return 'major';
|
|
47
|
+
if (FEATURE.test(subject)) bump = 'minor';
|
|
48
|
+
}
|
|
49
|
+
return bump;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function bumpVersion(tag: string, bump: Bump): string {
|
|
53
|
+
const match = TAG.exec(tag);
|
|
54
|
+
if (!match) throw new Error(`Not a release tag: ${tag}`);
|
|
55
|
+
const [major, minor, patch] = match.slice(1).map(Number) as [
|
|
56
|
+
number,
|
|
57
|
+
number,
|
|
58
|
+
number,
|
|
59
|
+
];
|
|
60
|
+
if (bump === 'major' && major > 0) return `${major + 1}.0.0`;
|
|
61
|
+
if (bump === 'patch') return `${major}.${minor}.${patch + 1}`;
|
|
62
|
+
return `${major}.${minor + 1}.0`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function latestTag(git: Git): string | null {
|
|
66
|
+
const tags = git(['tag', '--list', 'v*', '--sort=-v:refname']) ?? '';
|
|
67
|
+
return (
|
|
68
|
+
tags
|
|
69
|
+
.split('\n')
|
|
70
|
+
.map((tag) => tag.trim())
|
|
71
|
+
.find((tag) => TAG.test(tag)) ?? null
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The newest stable `X.Y.Z` of these npm versions, or null. */
|
|
76
|
+
export function newestPublished(versions: string[]): string | null {
|
|
77
|
+
const stable = versions
|
|
78
|
+
.filter((version) => TAG.test(`v${version}`))
|
|
79
|
+
.map((version) => version.split('.').map(Number) as number[]);
|
|
80
|
+
stable.sort((a, b) => b[0]! - a[0]! || b[1]! - a[1]! || b[2]! - a[2]!);
|
|
81
|
+
return stable[0]?.join('.') ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `published` is the newest stable version on npm, or null when the package
|
|
86
|
+
* is not there.
|
|
87
|
+
*/
|
|
88
|
+
export function decide(git: Git, published: string | null = null): Decision {
|
|
89
|
+
const tag = latestTag(git);
|
|
90
|
+
if (!tag && !published)
|
|
91
|
+
return {
|
|
92
|
+
release: false,
|
|
93
|
+
setup: true,
|
|
94
|
+
reason:
|
|
95
|
+
'No vX.Y.Z tag and nothing on npm yet: run `bun run bootstrap` once first.',
|
|
96
|
+
};
|
|
97
|
+
const previous = tag ?? `v${published}`;
|
|
98
|
+
if (tag && git(['merge-base', '--is-ancestor', 'HEAD', tag]) !== null)
|
|
99
|
+
return { release: false, reason: `HEAD is already in ${tag}.` };
|
|
100
|
+
const log =
|
|
101
|
+
git(['log', '--format=%B%x00', tag ? `${tag}..HEAD` : 'HEAD']) ?? '';
|
|
102
|
+
const messages = log
|
|
103
|
+
.split('\0')
|
|
104
|
+
.map((message) => message.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (messages.length === 0)
|
|
107
|
+
return { release: false, reason: `No commits since ${previous}.` };
|
|
108
|
+
return {
|
|
109
|
+
release: true,
|
|
110
|
+
version: bumpVersion(previous, bumpFor(messages)),
|
|
111
|
+
previous,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const system: Git = (args) => {
|
|
116
|
+
const result = spawnSync('git', args, { encoding: 'utf8' });
|
|
117
|
+
return result.status === 0 ? (result.stdout ?? '') : null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The versions of the package on npm; empty when it is not there. */
|
|
121
|
+
function npmVersions(): string[] {
|
|
122
|
+
const name = (
|
|
123
|
+
JSON.parse(
|
|
124
|
+
readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf8')
|
|
125
|
+
) as { name: string }
|
|
126
|
+
).name;
|
|
127
|
+
const result = spawnSync('npm', ['view', name, 'versions', '--json'], {
|
|
128
|
+
encoding: 'utf8',
|
|
129
|
+
});
|
|
130
|
+
if (result.status !== 0) return [];
|
|
131
|
+
const versions: unknown = JSON.parse(result.stdout || '[]');
|
|
132
|
+
return Array.isArray(versions)
|
|
133
|
+
? versions.filter((v): v is string => typeof v === 'string')
|
|
134
|
+
: typeof versions === 'string'
|
|
135
|
+
? [versions]
|
|
136
|
+
: [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (import.meta.main) {
|
|
140
|
+
const decision = decide(system, newestPublished(npmVersions()));
|
|
141
|
+
const summary = process.env['GITHUB_STEP_SUMMARY'];
|
|
142
|
+
const note = (line: string) => {
|
|
143
|
+
if (summary) appendFileSync(summary, `${line}\n`);
|
|
144
|
+
};
|
|
145
|
+
if (decision.release) {
|
|
146
|
+
console.error(`${decision.previous} -> v${decision.version}`);
|
|
147
|
+
note(`Releasing v${decision.version} (after ${decision.previous}).`);
|
|
148
|
+
console.log(decision.version);
|
|
149
|
+
} else if (decision.setup) {
|
|
150
|
+
console.error(`::error::${decision.reason}`);
|
|
151
|
+
note(`**Nothing was released.** ${decision.reason}`);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
} else {
|
|
154
|
+
console.error(decision.reason);
|
|
155
|
+
note(`Nothing to release: ${decision.reason}`);
|
|
156
|
+
}
|
|
157
|
+
}
|