Skip to content

Commit 3adbdae

Browse files
committed
chore(fleet): cascade fleet-code from wheelhouse
1 parent 6255a96 commit 3adbdae

8 files changed

Lines changed: 263 additions & 26 deletions

File tree

docs/agents.md/fleet/version-bumps.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ Per the [Public-surface hygiene](public-surface-hygiene.md) rule (in
112112
CLAUDE.md), releases are user-triggered. Stop after the tag push;
113113
the user runs the publish workflow manually.
114114

115+
## The bump base is the last PUBLISHED version, never the manifest
116+
117+
`bump.mts` (and the cargo bump) compute the next version from `resolveBumpBase`
118+
— the max of the registry's `dist-tags.latest` and the last `vX.Y.Z` tag —
119+
NEVER from `package.json`/`Cargo.toml`. A manifest can sit ahead of what
120+
actually published (a hand pre-bump, or a stale `X.Y.Z-prerelease` hint), and
121+
bumping off an ahead manifest silently SKIPS a version: package.json was
122+
pre-bumped to 1.4.3, then the release bumped 1.4.3 → 1.4.4, so 1.4.3 was never
123+
published. A `-prerelease` hint that names an already-published (or lower)
124+
version fails loud rather than re-publishing.
125+
126+
The `version-is-not-ahead-of-published` check is the release-tier gate: it fails
127+
when the manifest is more than one valid bump ahead of the published latest, and
128+
fails open (no published version / registry unreachable) so offline lint lanes
129+
never trip it.
130+
115131
## Why this order
116132

117133
- **Bisecting from `main` past the tag must not land on a
@@ -132,4 +148,5 @@ the user runs the publish workflow manually.
132148

133149
- `.claude/hooks/fleet/version-bump-order-guard/`: enforces the bump-at-tip + tag-after-bump ordering.
134150
- `.claude/hooks/fleet/release-workflow-guard/`: blocks `gh workflow run` dispatches that aren't dry-run.
151+
- `scripts/fleet/check/version-is-not-ahead-of-published.mts`: release-tier gate that fails when package.json is bumped more than one release past the published latest (the skip-risk state).
135152
- [`immutable-releases.md`](immutable-releases.md): every GitHub Release that lands as a result of this sequence ships immutable (Sigstore release attestation, asset lock, tag protection). The release workflow MUST use the 3-step draft → upload → publish pattern; single-call `gh release create <tag> <files>` is forbidden.

scripts/fleet/_shared/check-steps-release.mts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,21 @@ export function buildReleaseAndDocsSteps(): CheckStep[] {
158158
// Catches the failure mode that shipped a CHANGELOG entry describing work that
159159
// landed after its tag. Published versions are historical and not re-checked.
160160
() => run('node', ['scripts/fleet/check/changelog-is-commit-derived.mts']),
161+
// A PENDING release's package.json version must be at most ONE bump ahead of
162+
// the registry's latest-published version. A manifest pre-bumped further
163+
// skips the versions between (package.json pre-bumped to 1.4.3, then the
164+
// workflow bumped 1.4.3 → 1.4.4, so 1.4.3 was never published). Network read
165+
// → release-tier; fail-open when no published version / registry unreachable.
166+
releaseStep(['scripts/fleet/check/version-is-not-ahead-of-published.mts']),
167+
// A publishable manifest's version must be an `X.Y.Z-prerelease` HINT on the
168+
// dev branch — the agent never hand-sets a bare release version; the publish
169+
// script owns the bare bump (strips the suffix). No-ops on non-publishable
170+
// manifests (private / no publishConfig) + fail-opens when git is unreadable.
171+
// Release-tier so it gates at publish time, not every dev commit.
172+
releaseStep([
173+
'scripts/fleet/check/publishable-version-is-prerelease-hint.mts',
174+
'--quiet',
175+
]),
161176
// No tracked symlink is self-referential or points at an absolute path
162177
// inside the repo (a `node_modules → /abs/<repo>/node_modules` self-loop
163178
// bricked fresh clones fleet-wide with ELOOP; git kept it tracked despite

scripts/fleet/bump.mts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import process from 'node:process'
2525

2626
import { parseArgs } from '@socketsecurity/lib-stable/argv/parse'
2727
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
28+
import { gt } from '@socketsecurity/lib-stable/versions/compare'
2829

2930
import {
3031
bumpLevelFor,
@@ -35,12 +36,14 @@ import {
3536
parseConventionalCommits,
3637
promoteUnreleased,
3738
repoBaseUrl,
39+
resolveBumpBase,
3840
sectionHasEntries,
3941
UNRELEASED_HEADING,
4042
versionHintFrom,
4143
withChangelogEntry,
4244
} from './lib/changelog.mts'
4345
import { loadSocketWheelhouseConfig, REPO_ROOT } from './paths.mts'
46+
import { fetchLatestPublishedVersion } from './publish-infra/npm/registry.mts'
4447
import { runCapture } from './publish-infra/shared.mts'
4548

4649
import type { BumpLevel } from './lib/changelog.mts'
@@ -159,6 +162,18 @@ async function main(): Promise<void> {
159162

160163
const fromTag = await lastReleaseTag()
161164
const commits = parseConventionalCommits(await readCommitStream(fromTag))
165+
// Anchor the bump base to what actually RELEASED (registry latest + last
166+
// tag), NEVER the manifest — a pre-bumped package.json would otherwise skip a
167+
// version (package.json pre-bumped to 1.4.3, then bumped 1.4.3 → 1.4.4, so
168+
// 1.4.3 was never published).
169+
const publishedVersion = pkg.name
170+
? await fetchLatestPublishedVersion(pkg.name)
171+
: undefined
172+
const base = resolveBumpBase({
173+
manifestVersion: pkg.version,
174+
publishedVersion,
175+
tagVersion: fromTag ?? undefined,
176+
})
162177
// Version resolution, most-explicit first: the --release-as flag, then a
163178
// committed version HINT (package.json version carrying a prerelease
164179
// suffix, e.g. `6.0.10-prerelease` → release 6.0.10), then the commit-type
@@ -200,6 +215,17 @@ async function main(): Promise<void> {
200215
process.exitCode = 1
201216
return
202217
}
218+
// The hint must advance PAST the last released version — a hint naming an
219+
// already-published (or lower) version would re-publish or move backward.
220+
if (!gt(hinted, base)) {
221+
logger.fail(
222+
`Version hint ${pkg.version} names ${hinted}, which is not ahead of the ` +
223+
`last released version ${base} — it would re-publish or move backward. ` +
224+
`Name a version greater than ${base}.`,
225+
)
226+
process.exitCode = 1
227+
return
228+
}
203229
hintedVersion = hinted
204230
level = 'patch'
205231
logger.log(
@@ -245,7 +271,7 @@ async function main(): Promise<void> {
245271
return
246272
}
247273

248-
const nextVersion = hintedVersion ?? computeNextVersion(pkg.version, level)
274+
const nextVersion = hintedVersion ?? computeNextVersion(base, level)
249275
const repositoryUrl =
250276
typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url
251277
// ISO date (YYYY-MM-DD). bump.mts is a normal node script (not a workflow
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @file Guard against a package.json version pre-bumped MORE than one release
3+
* ahead of what actually published — the skip-risk state that shipped the
4+
* 1.4.3 → 1.4.4 gap (package.json pre-bumped to 1.4.3, then the release
5+
* bumped 1.4.3 → 1.4.4, so 1.4.3 was never published). The release workflow
6+
* OWNS the bump; the manifest should sit at the last published version (or at
7+
* most one pending bump / a `-prerelease` hint above it). Reads the
8+
* registry's `dist-tags.latest` and fails only when the manifest is ahead by
9+
* more than a single valid bump. Fail-OPEN: no published version (first
10+
* release / registry unreachable), a private package, or any crash skips
11+
* rather than false-fails, so a lint/type CI lane offline never trips it.
12+
* Usage: node scripts/fleet/check/version-is-not-ahead-of-published.mts.
13+
*/
14+
15+
import { existsSync, readFileSync } from 'node:fs'
16+
import path from 'node:path'
17+
import process from 'node:process'
18+
19+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
20+
import { lte } from '@socketsecurity/lib-stable/versions/compare'
21+
22+
import { computeNextVersion } from '../lib/changelog.mts'
23+
import { REPO_ROOT } from '../paths.mts'
24+
import { fetchLatestPublishedVersion } from '../publish-infra/npm/registry.mts'
25+
import { isMainModule } from '../_shared/is-main-module.mts'
26+
27+
const logger = getDefaultLogger()
28+
29+
interface PackageJsonShape {
30+
name?: string | undefined
31+
private?: boolean | undefined
32+
version?: string | undefined
33+
}
34+
35+
export interface VersionAheadInput {
36+
manifestVersion: string
37+
publishedVersion: string | undefined
38+
}
39+
40+
export interface VersionAheadResult {
41+
ok: boolean
42+
reason: string
43+
}
44+
45+
/**
46+
* Decide whether the manifest version is safely close to the published version.
47+
* Pure — the test drives it directly. OK when: nothing published yet
48+
* (fail-open), the manifest is at or behind published (equal/behind is not a
49+
* skip), or the manifest is EXACTLY one valid bump (patch/minor/major) above
50+
* published (a single pending release or a `-prerelease` hint). FAIL only when
51+
* the manifest is ahead by MORE than one release — the versions between it and
52+
* published get skipped.
53+
*/
54+
export function evaluateVersionAhead(
55+
input: VersionAheadInput,
56+
): VersionAheadResult {
57+
const opts = { __proto__: null, ...input } as VersionAheadInput
58+
const published = opts.publishedVersion
59+
if (!published) {
60+
return {
61+
ok: true,
62+
reason:
63+
'no published version (first release or registry unreachable) — nothing to compare',
64+
}
65+
}
66+
const core = opts.manifestVersion.split('-')[0]!.split('+')[0]!
67+
if (lte(core, published)) {
68+
return {
69+
ok: true,
70+
reason: `manifest ${core} is at or behind published ${published}`,
71+
}
72+
}
73+
const pending = [
74+
computeNextVersion(published, 'patch'),
75+
computeNextVersion(published, 'minor'),
76+
computeNextVersion(published, 'major'),
77+
]
78+
if (pending.includes(core)) {
79+
return {
80+
ok: true,
81+
reason: `manifest ${core} is a single pending bump above published ${published}`,
82+
}
83+
}
84+
return {
85+
ok: false,
86+
reason:
87+
`manifest ${core} is more than one release ahead of published ${published} — ` +
88+
`the version(s) between get skipped (a pre-bump). The release workflow owns ` +
89+
`the bump; restore package.json to ${published} (or a single pending bump / ` +
90+
`a ${published}-derived X.Y.Z-prerelease hint).`,
91+
}
92+
}
93+
94+
async function main(): Promise<void> {
95+
const pkgPath = path.join(REPO_ROOT, 'package.json')
96+
if (!existsSync(pkgPath)) {
97+
return
98+
}
99+
let pkg: PackageJsonShape
100+
try {
101+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJsonShape
102+
} catch {
103+
return
104+
}
105+
if (!pkg.version || !pkg.name || pkg.private === true) {
106+
return
107+
}
108+
const published = await fetchLatestPublishedVersion(pkg.name)
109+
const result = evaluateVersionAhead({
110+
manifestVersion: pkg.version,
111+
publishedVersion: published,
112+
})
113+
if (!result.ok) {
114+
logger.fail(`version-is-not-ahead-of-published: ${result.reason}`)
115+
process.exitCode = 1
116+
}
117+
}
118+
119+
if (isMainModule(import.meta.url)) {
120+
main().catch((e: unknown) => {
121+
logger.error(e)
122+
// Fail-open: a crash in the check must not block an otherwise-valid push.
123+
process.exitCode = 0
124+
})
125+
}

scripts/fleet/lib/changelog.mts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* not internal churn.
1414
*/
1515

16+
import { maxVersion } from '@socketsecurity/lib-stable/versions/range'
17+
1618
// Record separator between commits, unit separator between fields — both
1719
// control chars that never appear in a commit subject/body, so a `git log
1820
// --format=%H%x1f%s%x1f%b%x1e` stream parses unambiguously.
@@ -164,6 +166,36 @@ export function computeNextVersion(current: string, level: BumpLevel): string {
164166
return `${major}.${minor}.${patch + 1}`
165167
}
166168

169+
export interface ResolveBumpBaseOptions {
170+
manifestVersion: string
171+
publishedVersion?: string | undefined
172+
tagVersion?: string | undefined
173+
}
174+
175+
/**
176+
* The version a release bumps FROM. Anchored to already-RELEASED authorities —
177+
* the registry's latest-published version and the last release tag — NEVER to
178+
* the manifest, which can sit ahead (a hand pre-bump, or a stale
179+
* `X.Y.Z-prerelease` hint) and would silently SKIP a version: package.json was
180+
* pre-bumped to 1.4.3, then the release bumped 1.4.3 → 1.4.4, so 1.4.3 was
181+
* never published. Excluding the manifest from the base means an ahead manifest
182+
* can never inflate it. Falls back to the manifest core ONLY for a genuine
183+
* first release (no published version, no tag).
184+
*/
185+
export function resolveBumpBase(options: ResolveBumpBaseOptions): string {
186+
const opts = { __proto__: null, ...options } as ResolveBumpBaseOptions
187+
const released: string[] = []
188+
if (opts.publishedVersion) {
189+
released.push(opts.publishedVersion)
190+
}
191+
if (opts.tagVersion) {
192+
released.push(opts.tagVersion.replace(/^v/, ''))
193+
}
194+
return (
195+
maxVersion(released) ?? opts.manifestVersion.split('-')[0]!.split('+')[0]!
196+
)
197+
}
198+
167199
/**
168200
* Normalize a package.json `repository.url`
169201
* (`git+https://github.com/Org/Repo.git`, `git@github.com:Org/Repo.git`, …) to

scripts/fleet/publish-infra/cargo/bump.mts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseConventionalCommits,
2525
promoteUnreleased,
2626
repoBaseUrl,
27+
resolveBumpBase,
2728
sectionHasEntries,
2829
} from '../../lib/changelog.mts'
2930
import { commitViaGithubApi } from '../../lib/commit-via-github-api.mts'
@@ -33,6 +34,7 @@ import {
3334
resolveReleaseEnv,
3435
} from '../release-branch.mts'
3536
import { logger, rootPath, runCapture } from '../shared.mts'
37+
import { fetchPublishedVersion } from './registry.mts'
3638
import { readCargoPackage } from './shared.mts'
3739

3840
import type { BumpLevel } from '../../lib/changelog.mts'
@@ -164,6 +166,15 @@ export async function runBump(options: {
164166

165167
const fromTag = await lastReleaseTag()
166168
const commits = parseConventionalCommits(await readCommitStream(fromTag))
169+
// Anchor the bump base to what actually RELEASED (crates.io latest + last
170+
// tag), NEVER the Cargo.toml version — a pre-bumped manifest would otherwise
171+
// skip a version.
172+
const publishedVersion = await fetchPublishedVersion(pkg.name)
173+
const base = resolveBumpBase({
174+
manifestVersion: pkg.version,
175+
publishedVersion,
176+
tagVersion: fromTag ?? undefined,
177+
})
167178
// Version resolution: the --release-as flag wins, else the commit-type
168179
// heuristic. MAJOR is never derived — it needs the explicit --release-as
169180
// major signal (agent runs are hook-gated on the user's typed authorization;
@@ -205,7 +216,7 @@ export async function runBump(options: {
205216
return
206217
}
207218

208-
const nextVersion = computeNextVersion(pkg.version, level)
219+
const nextVersion = computeNextVersion(base, level)
209220
const repoUrl = repoBaseUrl(pkg.repository)
210221
const date = new Date().toISOString().slice(0, 10)
211222
const changelogPath = path.join(rootPath, 'CHANGELOG.md')

scripts/fleet/publish-infra/npm/registry.mts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@ import { httpJson } from '@socketsecurity/lib-stable/http-request'
99
import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts'
1010
import { runCapture } from '../shared.mts'
1111

12+
/**
13+
* The registry `dist-tags.latest` for a package — the currently-published
14+
* version — or undefined on any failure/unpublished. Reads the packument (not
15+
* `npm view`, which trips this repo's pnpm devEngines). The tolerant twin of
16+
* reconcile's throwing reader: the bump uses it to anchor the base version to
17+
* what actually published (never a possibly-ahead manifest), so it must NOT
18+
* throw on a first-publish / offline registry — it returns undefined and the
19+
* caller falls back.
20+
*/
21+
export async function fetchLatestPublishedVersion(
22+
name: string,
23+
): Promise<string | undefined> {
24+
const url = `${NPM_REGISTRY_URL}/${encodeURIComponent(name).replace('%40', '@')}`
25+
try {
26+
const json = await httpJson<{
27+
'dist-tags'?: { latest?: string | undefined } | undefined
28+
}>(url, {
29+
headers: { accept: 'application/vnd.npm.install-v1+json' },
30+
timeout: 15_000,
31+
})
32+
return json['dist-tags']?.latest
33+
} catch {
34+
return undefined
35+
}
36+
}
37+
1238
/**
1339
* `npm view <name>@<version> version` exits 0 iff the version exists on the
1440
* registry. Faster than fetching the full packument for a yes/no check.

0 commit comments

Comments
 (0)