@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.
Files changed (110) hide show
  1. package/README.md +237 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +7 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/monitoring/README.md +86 -25
  6. package/ansible/monitoring/agent.yml +40 -2
  7. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  8. package/ansible/monitoring/lock.json +5 -0
  9. package/ansible/pyproject.toml +12 -0
  10. package/ansible/ubuntu/baseline.yml +1 -1
  11. package/ansible/ubuntu/disk-gc.py +336 -0
  12. package/ansible/ubuntu/policy.json +12 -3
  13. package/ansible/ubuntu/tailscale.yml +8 -5
  14. package/ansible/ubuntu/tasks/resources.yml +61 -1
  15. package/ansible/uv.lock +190 -0
  16. package/bootstrap/bootstrap.sh +25 -18
  17. package/bootstrap/doctor.ts +46 -1
  18. package/bootstrap/fleet-manager-run.sh +42 -15
  19. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  20. package/bootstrap/global-config.ts +64 -6
  21. package/bootstrap/install-launcher.sh +3 -0
  22. package/bootstrap/npm.ts +10 -2
  23. package/bootstrap/release-version.ts +157 -0
  24. package/docs/consumer-setup.md +408 -0
  25. package/docs/declared.md +89 -0
  26. package/docs/machine-setup.md +399 -0
  27. package/docs/operations.md +276 -0
  28. package/docs/releasing.md +103 -0
  29. package/docs/state.md +254 -0
  30. package/docs/topology.md +67 -0
  31. package/docs/ubuntu-sync.md +351 -0
  32. package/docs/worker-contract.md +67 -0
  33. package/examples/fleet.json +5 -0
  34. package/llms.txt +35 -0
  35. package/mise.lock +29 -0
  36. package/mise.toml +12 -1
  37. package/package.json +11 -2
  38. package/schema/fleet.schema.json +71 -15
  39. package/src/agents/fleet-verify.ts +9 -0
  40. package/src/agents/native-verify.ts +167 -22
  41. package/src/agents/provider-errors.ts +9 -2
  42. package/src/agents/registry.ts +7 -1
  43. package/src/cli/agent-guide.ts +39 -5
  44. package/src/cli/app.ts +11 -4
  45. package/src/cli/cli.ts +7 -0
  46. package/src/cli/command-index.ts +7 -0
  47. package/src/cli/fleet-state.ts +499 -0
  48. package/src/cli/fleet-sync-checkout.ts +56 -17
  49. package/src/cli/fleet-sync-enrollment.ts +19 -23
  50. package/src/cli/fleet-sync-forward.ts +1 -1
  51. package/src/cli/fleet-sync-manager.ts +64 -8
  52. package/src/cli/fleet-sync-monitoring.ts +5 -5
  53. package/src/cli/fleet-sync-output.ts +4 -1
  54. package/src/cli/fleet-sync-remote.ts +11 -5
  55. package/src/cli/fleet-sync-state.ts +86 -0
  56. package/src/cli/fleet-sync.ts +123 -57
  57. package/src/cli/fleet.ts +4 -2
  58. package/src/cli/managed-health.ts +4 -4
  59. package/src/cli/node-sync.ts +7 -0
  60. package/src/cli/program.ts +20 -4
  61. package/src/cli/project.ts +51 -0
  62. package/src/cli/register-fleet.ts +51 -1
  63. package/src/cli/registry.ts +28 -1
  64. package/src/cli/roster.ts +46 -30
  65. package/src/cli/settings.ts +15 -12
  66. package/src/cli/source-operations.ts +27 -3
  67. package/src/cli/tailnet.ts +2 -2
  68. package/src/definition.ts +31 -1
  69. package/src/diagnostics.ts +1 -1
  70. package/src/fleet-config.ts +105 -19
  71. package/src/machine/source.ts +3 -3
  72. package/src/machine/state.ts +15 -27
  73. package/src/manager-sync.ts +8 -3
  74. package/src/monitoring/agent-evidence.ts +146 -0
  75. package/src/monitoring/apply.ts +15 -3
  76. package/src/monitoring/beszel.ts +11 -4
  77. package/src/monitoring/enroll.ts +2 -1
  78. package/src/monitoring/hub-access.ts +4 -3
  79. package/src/monitoring/hub-backup.ts +121 -0
  80. package/src/monitoring/hub-viewer.ts +8 -8
  81. package/src/monitoring/hub.ts +3 -3
  82. package/src/monitoring/manager-hub.ts +30 -14
  83. package/src/node-converge-linux.ts +2 -0
  84. package/src/package-info.ts +45 -0
  85. package/src/platform/ansible.ts +19 -15
  86. package/src/platform/resource-verify.ts +8 -4
  87. package/src/platform/ubuntu-audit.ts +5 -2
  88. package/src/remote-bootstrap.ts +17 -19
  89. package/src/repair-catalog.ts +1 -1
  90. package/src/runner.ts +1 -1
  91. package/src/state/bucket.ts +208 -0
  92. package/src/state/check.ts +74 -0
  93. package/src/state/connection.ts +92 -0
  94. package/src/state/enrollment.ts +45 -0
  95. package/src/state/legacy.ts +89 -0
  96. package/src/state/litestream.ts +143 -0
  97. package/src/state/operator.ts +20 -0
  98. package/src/state/paths.ts +40 -0
  99. package/src/state/roster.ts +62 -0
  100. package/src/state/store.ts +169 -0
  101. package/src/state/sync.ts +352 -0
  102. package/src/t3-endpoint.ts +64 -8
  103. package/src/t3-linked-check.ts +23 -1
  104. package/src/toolchain-checks.ts +25 -0
  105. package/src/types.ts +4 -0
  106. package/src/worker-access-checks.ts +56 -3
  107. package/src/worker-host-checks.ts +5 -0
  108. package/src/workstation-checks.ts +1 -1
  109. package/src/index.ts +0 -6
  110. 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 a
3
- # base64 Git bundle. All executable content remains in this tracked source.
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":"Inspect prerequisites and the remote operation lock, then retry the approved revision. No success receipt advanced."}}]}\n' "$phase" "$failure_code"
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 >/dev/null 2>&1 || die APT_UPDATE_FAILED
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 >/dev/null 2>&1 || die APT_INSTALL_FAILED
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/checkout can leave an empty initialized repository.
111
- # Resume only that state; an existing commit or untracked work is never reset.
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
- git -C "$deployment" checkout --detach "$revision" >/dev/null 2>&1 || exit 1
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
- done: state.tagRemote,
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
+ }