From b3fad9c58eb68f37fb316b7d6cdb4238f3b3bc2e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 20:53:30 -0400 Subject: [PATCH 1/8] Add scripts/release.py: release-train stamp, versions, CHANGELOG, blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of the weekly release train (docs/release-train/DESIGN.md §7). One stdlib-only Python file with argparse subcommands: - stamp [--check]: offline, byte-deterministic version stamp of Cargo.toml (workspace version + =V core pin), Cargo.lock (source-less workspace-member entries, so --locked builds), the 15 npm manifests and npm/socket-patch/package-lock.json (JSON edit; platform entries for any other version are dropped instead of re-resolved over the network, which ends the #233/#235 lock-drift class). - semver: X.Y.Z and X.Y.Z-rc.N only, semver precedence. - next-version: from git tags + burned release/* branches + the [Unreleased] headings of sync-main(C) computed in memory, never from main's Cargo version. New majors need APPROVED_MAJORS (5 is pre-approved) and are never skipped; otherwise refused with an error. - changelog cut|promote|sync-main|check: rc sections reach main on every rc; promotion folds rc.1..rc.K into one [X.Y.Z] section and returns later abandoned rc blocks to [Unreleased]. Exact-match, deterministic, idempotent; newer [Unreleased] entries are never touched. - notes: release notes with a link to the open-P1 query, never titles. - blockers --base : the §3.5 release-blocker rule over REST (injectable transport); any API error blocks. Tests: scripts/tests/test_release.py with temp git repos driven through the train timeline and recorded REST shapes under scripts/tests/fixtures/release/. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/release.py | 1226 +++++++++++++++++ .../release/CHANGELOG.main-045d7ec7.md | 1089 +++++++++++++++ .../tests/fixtures/release/CHANGELOG.train.md | 34 + scripts/tests/fixtures/release/blockers.json | 883 ++++++++++++ scripts/tests/test_release.py | 685 +++++++++ 5 files changed, 3917 insertions(+) create mode 100644 scripts/release.py create mode 100644 scripts/tests/fixtures/release/CHANGELOG.main-045d7ec7.md create mode 100644 scripts/tests/fixtures/release/CHANGELOG.train.md create mode 100644 scripts/tests/fixtures/release/blockers.json create mode 100644 scripts/tests/test_release.py diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 000000000..3fab429af --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,1226 @@ +#!/usr/bin/env python3 +"""socket-patch release-train tooling (docs/release-train/DESIGN.md). + +Stdlib-only, one file, so every workflow and routine can run it with a bare +`python3`. Subcommands (PR 1 of the train): + + semver validate|compare|core version grammar: X.Y.Z or X.Y.Z-rc.N only + stamp [--check] offline, byte-deterministic version stamp + (Cargo.toml, Cargo.lock, 15 npm manifests, + the npm wrapper lockfile) + next-version the version the next rc cut would get, + derived from git tags + burned release/* + branches + the [Unreleased] headings + changelog cut|promote|sync-main|check + CHANGELOG.md transforms (see "CHANGELOG + model" below) + sync-main [--check] the rolling release-sync PR's deterministic + part: CHANGELOG sync + stamp of the newest tag + notes --version V GitHub release notes for one section + blockers --base the release-blocker gate (DESIGN.md §3.5) + +Exit status: 0 ok, 1 refused/blocked/drift, 2 usage error. + +Version rules +------------- +* Grammar: `X.Y.Z` (stable) or `X.Y.Z-rc.N` (N >= 1, no leading zeros). No + other prerelease or build-metadata forms exist in this project. + Precedence is semver's: X.Y.Z-rc.N < X.Y.Z-rc.(N+1) < X.Y.Z. +* L = the newest stable tag. The bump level comes from the `### ` headings of + [Unreleased] that still hold entries *after* `changelog sync-main` has been + applied in memory (so an unmerged release-sync PR changes nothing): + a heading containing "breaking" or starting "Removed" -> major; + starting "Added" / "Changed" / "Deprecated" -> minor; anything else -> patch. +* core = max(bump(L, level), every pending rc core above L). N = 1 + the + highest rc number of that core over tags AND release/v-rc.* branches + (a number is burned when its branch is created, even if it never ships). +* Major rule: a core whose major is above L's major may only be cut when + that major is listed in APPROVED_MAJORS below, and only L.major + 1 (a major + is never skipped). Adding a major there is a reviewed PR on main, i.e. a + human decision. Once a major's train is in flight (an rc tag M.0.0-rc.N + exists) further breaking entries are absorbed into M.0.0. A breaking + heading that would open an unapproved major is refused with an error + rather than silently shipped as a minor. +* Main's own Cargo/npm version is never read for any of this. + +CHANGELOG model +--------------- +Sections are `## [Unreleased]` and `## [V] — YYYY-MM-DD`; subsections are +`### Name`. A *block* is a top-level bullet with its continuation lines, a +paragraph, or a fenced code block. Block identity is (subsection name, text +with trailing whitespace stripped); all matching is exact. + +* cut V: apply sync-main in memory, then move every [Unreleased] block into a + new `## [V] — date` section directly below an empty [Unreleased]. +* promote rc.K: fold every `[X-rc.N]` section with X-rc.N <= rc.K that is + newer than the newest stable section into one `## [core] — date` section + (oldest rc first; within a subsection later rcs append, exact duplicates + dropped; rc headers removed). Later rc sections of the same core (rc.K+1..) + are abandoned: their blocks move back into [Unreleased] (appended to the + matching subsection, exact duplicates skipped) and their headers dropped. +* sync-main (main's CHANGELOG := what it would be had every release-sync PR + merged). For train-era tags (version > TRAIN_FLOOR): + 1. each stable tag S whose section main lacks: insert the tag's own [S] + section and remove exactly those blocks from [Unreleased]; + 2. each rc section on main whose core already shipped (owner S = the + smallest stable tag >= its core): if it is <= the rc S was promoted + from (the newest same-core rc tag that is an ancestor of tag S) it + was folded into [S] and is deleted; otherwise it was abandoned, and + its blocks move back into [Unreleased] (skipping blocks already there + or in [S]); + 3. each pending rc tag (no owner yet) whose section main lacks: insert + the tag's section and remove exactly those blocks from [Unreleased]. + Only blocks of sections inserted *in this run* are removed from + [Unreleased], so entries added to [Unreleased] later are never touched and + a second run is a no-op. Section text always comes from the tag (the bytes + that shipped); rc-section edits made on main are dropped by the fold. +""" + +from __future__ import annotations + +import argparse +import copy +import datetime +import functools +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Majors a cut may open. Adding one is the human-reviewed approval of a new +# major line (DESIGN.md D4: 5.0.0 is pre-approved as the train's first release). +APPROVED_MAJORS = (5,) +# Tags at or below this predate the train; their sections are already on main +# and are never synced (v3.3.0, for one, has no CHANGELOG section at all). +TRAIN_FLOOR = "4.0.0" + +DEFAULT_REPO = "SocketDev/socket-patch" +BLOCKER_LABEL = "release-blocker" +P1_LABEL = "priority:p1" +API_ROOT = "https://api.github.com" + + +class ReleaseError(Exception): + """A refusal: printed as `release.py: error: ...`, exit status 1.""" + + +def _read(path): + """Exact text (no newline translation), so byte comparisons are exact.""" + with open(path, encoding="utf-8", newline="") as f: + return f.read() + + +def _write(path, text): + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(text) + + +# ── semver ────────────────────────────────────────────────────────────────── + +_VERSION_RE = re.compile( + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.([1-9][0-9]*))?$") + + +@functools.total_ordering +class Version: + __slots__ = ("major", "minor", "patch", "rc") + + def __init__(self, major, minor, patch, rc=None): + self.major, self.minor, self.patch, self.rc = major, minor, patch, rc + + @classmethod + def parse(cls, text): + m = _VERSION_RE.match(text or "") + if not m: + raise ReleaseError( + f"'{text}' is not a release version (X.Y.Z or X.Y.Z-rc.N, N >= 1, " + "no leading zeros, no other prerelease or build forms)") + rc = int(m.group(4)) if m.group(4) else None + return cls(int(m.group(1)), int(m.group(2)), int(m.group(3)), rc) + + @classmethod + def try_parse(cls, text): + try: + return cls.parse(text) + except ReleaseError: + return None + + @property + def is_rc(self): + return self.rc is not None + + @property + def core(self): + return Version(self.major, self.minor, self.patch) + + def with_rc(self, n): + return Version(self.major, self.minor, self.patch, n) + + def bump(self, level): + if level == "major": + return Version(self.major + 1, 0, 0) + if level == "minor": + return Version(self.major, self.minor + 1, 0) + if level == "patch": + return Version(self.major, self.minor, self.patch + 1) + raise ValueError(level) + + def _key(self): + # A prerelease sorts below its own core; rc numbers compare numerically. + return (self.major, self.minor, self.patch, + 0 if self.is_rc else 1, self.rc or 0) + + def __eq__(self, other): + return isinstance(other, Version) and self._key() == other._key() + + def __lt__(self, other): + return self._key() < other._key() + + def __hash__(self): + return hash(self._key()) + + def __str__(self): + s = f"{self.major}.{self.minor}.{self.patch}" + return f"{s}-rc.{self.rc}" if self.is_rc else s + + __repr__ = __str__ + + +def parse_tag(name): + """`v5.0.0-rc.1` -> Version; anything else (non-release tags) -> None.""" + if not name.startswith("v"): + return None + return Version.try_parse(name[1:]) + + +def parse_release_branch(name): + """`release/v5.0.0-rc.2` (any remote prefix) -> Version, else None.""" + m = re.search(r"(?:^|/)release/v([^/]+)$", name) + return Version.try_parse(m.group(1)) if m else None + + +# ── git ───────────────────────────────────────────────────────────────────── + +class Git: + def __init__(self, root): + self.root = Path(root) + + def run(self, *args, ok_codes=(0,)): + proc = subprocess.run(["git", *args], cwd=self.root, capture_output=True, + text=True, encoding="utf-8") + if proc.returncode not in ok_codes: + raise ReleaseError(f"git {' '.join(args)} failed: {proc.stderr.strip()}") + return proc + + def tags(self): + """{Version: tag name} for every v tag.""" + out = self.run("tag", "--list", "v*").stdout.split() + return {v: t for t in out for v in [parse_tag(t)] if v is not None} + + def release_branches(self, remote=None): + names = self.run("for-each-ref", "--format=%(refname)", + "refs/heads/release/", "refs/remotes/").stdout.split() + if remote: + ls = self.run("ls-remote", "--heads", remote, "refs/heads/release/*").stdout + names += [line.split("\t", 1)[1] for line in ls.splitlines() if "\t" in line] + return sorted({v for n in names for v in [parse_release_branch(n)] if v}) + + def show(self, rev, path): + return self.run("show", f"{rev}:{path}").stdout + + def is_ancestor(self, a, b): + return self.run("merge-base", "--is-ancestor", a, b, ok_codes=(0, 1)).returncode == 0 + + def commit_date(self, rev): + return self.run("log", "-1", "--format=%cI", f"{rev}^{{commit}}").stdout.strip() + + +class TagSource: + """What sync-main needs to know about tags: which exist, the CHANGELOG at + each, and which rc each stable was promoted from. Backed by git here; the + tests hand in the same three facts from temp repos.""" + + def __init__(self, git): + self.git = git + self._tags = git.tags() + self._changelogs = {} + + @property + def versions(self): + return sorted(self._tags) + + def changelog(self, v): + if v not in self._changelogs: + self._changelogs[v] = self.git.show(f"refs/tags/{self._tags[v]}", "CHANGELOG.md") + return self._changelogs[v] + + def promoted_from(self, stable): + """The newest same-core rc tag that is an ancestor of tag `stable`.""" + cands = sorted((v for v in self._tags if v.is_rc and v.core == stable), reverse=True) + for rc in cands: + if self.git.is_ancestor(f"refs/tags/{self._tags[rc]}", + f"refs/tags/{self._tags[stable]}"): + return rc + return None + + +# ── stamp ─────────────────────────────────────────────────────────────────── + +_TOML_HEADER = re.compile(r"^\[\[?([^\]]+)\]\]?\s*(#.*)?$") + + +def _stamp_cargo_toml(text, version): + out, table = [], None + hits = {"version": 0, "pin": 0} + for line in text.splitlines(keepends=True): + body = line.rstrip("\r\n") + m = _TOML_HEADER.match(body) + if m: + table = m.group(1).strip() + elif table == "workspace.package" and re.match(r'^version\s*=\s*"[^"]*"\s*$', body): + line = re.sub(r'"[^"]*"', f'"{version}"', line, count=1) + hits["version"] += 1 + elif table == "workspace.dependencies" and re.match(r"^socket-patch-core\s*=", body): + line, n = re.subn(r'(\bversion\s*=\s*")=?[^"]*(")', rf"\g<1>={version}\g<2>", line) + hits["pin"] += n + out.append(line) + if hits != {"version": 1, "pin": 1}: + raise ReleaseError( + "Cargo.toml: expected exactly one [workspace.package] version and one " + f"socket-patch-core version pin, found {hits}") + return "".join(out) + + +def _workspace_members(root, cargo_toml): + """Package names of workspace members whose version is inherited from + [workspace.package] (only those move with the release version).""" + m = re.search(r"^members\s*=\s*\[(.*?)\]", cargo_toml, re.S | re.M) + if not m: + raise ReleaseError("Cargo.toml: no [workspace] members list") + names = [] + for rel in re.findall(r'"([^"]+)"', m.group(1)): + manifest = _read(root / rel / "Cargo.toml") + name = re.search(r'^name\s*=\s*"([^"]+)"', manifest, re.M) + if name and re.search(r"^version\.workspace\s*=\s*true\s*$", manifest, re.M): + names.append(name.group(1)) + return names + + +def _stamp_cargo_lock(text, version, members): + chunks = re.split(r"(?m)^(?=\[\[package\]\]$)", text) + seen = [] + for i, chunk in enumerate(chunks): + if not chunk.startswith("[[package]]"): + continue + name = re.search(r'^name = "([^"]+)"$', chunk, re.M) + if not name or name.group(1) not in members or re.search(r"^source = ", chunk, re.M): + continue + chunks[i], n = re.subn(r'(?m)^version = "[^"]*"$', f'version = "{version}"', chunk, count=1) + if n != 1: + raise ReleaseError(f"Cargo.lock: package {name.group(1)} has no version line") + seen.append(name.group(1)) + if sorted(seen) != sorted(members): + raise ReleaseError( + f"Cargo.lock: expected one source-less entry per workspace member {sorted(members)}, " + f"found {sorted(seen)} (run `cargo metadata` once to refresh the lock)") + return "".join(chunks) + + +def _dump_json(obj): + return json.dumps(obj, indent=2, ensure_ascii=False) + "\n" + + +def _npm_manifests(root): + main = root / "npm" / "socket-patch" / "package.json" + platforms = sorted(root.glob("npm/socket-patch-*/package.json")) + if not main.is_file() or not platforms: + raise ReleaseError("npm/: expected npm/socket-patch and npm/socket-patch-*/ packages") + return [main] + platforms + + +_PLATFORM_LOCK_KEY = re.compile(r"^node_modules/@socketsecurity/socket-patch-[^/]+$") + + +def stamp_files(root, version): + """{relative path: new bytes} for every stamped file whose bytes change. + Pure: reads the tree, writes nothing.""" + version = str(Version.parse(str(version))) + root = Path(root) + changes = {} + + def put(path, new): + old = _read(path) + if new != old: + changes[path.relative_to(root).as_posix()] = new + + cargo_toml = root / "Cargo.toml" + toml_text = _read(cargo_toml) + put(cargo_toml, _stamp_cargo_toml(toml_text, version)) + lock = root / "Cargo.lock" + put(lock, _stamp_cargo_lock(_read(lock), version, + _workspace_members(root, toml_text))) + + for i, manifest in enumerate(_npm_manifests(root)): + pkg = json.loads(_read(manifest)) + pkg["version"] = version + if i == 0: + for dep in pkg.get("optionalDependencies", {}): + pkg["optionalDependencies"][dep] = version + put(manifest, _dump_json(pkg)) + + npm_lock = root / "npm" / "socket-patch" / "package-lock.json" + obj = json.loads(_read(npm_lock)) + obj["version"] = version + top = obj.get("packages", {}).get("") + if top is None: + raise ReleaseError('package-lock.json: no packages[""] entry') + top["version"] = version + for dep in top.get("optionalDependencies", {}): + top["optionalDependencies"][dep] = version + # Platform entries pin a registry tarball + integrity for one version; an + # entry for any other version is stale and would make `npm ci` refuse the + # lock. Dropping it (instead of re-resolving over the network) keeps the + # stamp offline and deterministic; `npm install` resolves it on demand. + obj["packages"] = {k: v for k, v in obj["packages"].items() + if not (_PLATFORM_LOCK_KEY.match(k) and v.get("version") != version)} + put(npm_lock, _dump_json(obj)) + return changes + + +def stamp(root, version, check=False): + changes = stamp_files(root, version) + if not check: + for rel, text in changes.items(): + _write(Path(root) / rel, text) + return sorted(changes) + + +# ── CHANGELOG ─────────────────────────────────────────────────────────────── + +_BULLET = re.compile(r"^(?:[-*+]|[0-9]+[.)])(?:\s|$)") +_FENCE = re.compile(r"^\s*(```|~~~)") +UNRELEASED = "Unreleased" + + +def _blank(line): + return not line.strip() + + +def _indented(line): + return line[:1] in (" ", "\t") + + +class Block: + __slots__ = ("lines", "gap") + + def __init__(self, lines, gap=0): + self.lines, self.gap = list(lines), gap + + @property + def is_bullet(self): + return bool(_BULLET.match(self.lines[0])) + + @property + def text(self): + return "\n".join(line.rstrip() for line in self.lines) + + +class Sub: + """A subsection (`### Name`), or a section's preamble when heading is None.""" + __slots__ = ("heading", "lead", "blocks", "trail") + + def __init__(self, heading, lead=0, blocks=(), trail=0): + self.heading, self.lead, self.blocks, self.trail = heading, lead, list(blocks), trail + + @property + def name(self): + return None if self.heading is None else self.heading[4:].strip() + + def render(self): + out = [] if self.heading is None else [self.heading] + if not self.blocks: + return out + [""] * max(self.lead, self.trail) + out += [""] * self.lead + for i, b in enumerate(self.blocks): + out += ([""] * b.gap if i else []) + b.lines + return out + [""] * self.trail + + def append(self, block): + if not self.blocks: + self.lead, self.trail = max(self.lead, 1), max(self.trail, 1) + self.blocks = [Block(block.lines, 0)] + return + prev = self.blocks[-1] + gap = block.gap if block.gap > 0 else (0 if prev.is_bullet and block.is_bullet else 1) + self.blocks.append(Block(block.lines, gap)) + + +def _parse_blocks(lines): + n, i, lead = len(lines), 0, 0 + while i < n and _blank(lines[i]): + lead, i = lead + 1, i + 1 + blocks, gap = [], 0 + while i < n: + start = i + fence = _FENCE.match(lines[i]) + if fence and not _indented(lines[i]): + i += 1 + while i < n and not lines[i].lstrip().startswith(fence.group(1)): + i += 1 + i = min(i + 1, n) + else: + bullet = bool(_BULLET.match(lines[i])) + in_fence = None + i += 1 + while i < n: + line = lines[i] + if in_fence: + if line.lstrip().startswith(in_fence): + in_fence = None + i += 1 + continue + if _blank(line): + if not bullet: + break + j = i + while j < n and _blank(lines[j]): + j += 1 + if j < n and _indented(lines[j]): # nested content of the bullet + i = j + continue + break + if _BULLET.match(line): + break + f = _FENCE.match(line) + if f: + if not _indented(line): + break + in_fence = f.group(1) + i += 1 + blocks.append(Block(lines[start:i], gap)) + gap = 0 + while i < n and _blank(lines[i]): + gap, i = gap + 1, i + 1 + return lead, blocks, (gap if blocks else 0) + + +def _section_version(heading): + m = re.match(r"^## \[([^\]]+)\]", heading) or re.match(r"^## (\S+)", heading) + if not m: + return None + if m.group(1).lower() == "unreleased": + return UNRELEASED + return Version.try_parse(m.group(1)) + + +class Section: + __slots__ = ("heading", "subs", "version") + + def __init__(self, heading, subs): + self.heading, self.subs = heading, subs + self.version = _section_version(heading) + + @property + def preamble(self): + return self.subs[0] + + def items(self): + return [(s.name, b) for s in self.subs for b in s.blocks] + + def keys(self): + return {(name, b.text) for name, b in self.items()} + + def sub(self, name, create=False): + for s in self.subs: + if s.name == name: + return s + if not create: + return None + s = Sub(f"### {name}", 1, [], 1) + self.subs[-1].trail = max(self.subs[-1].trail, 1) + self.subs.append(s) + return s + + def remove(self, keys): + """Drop blocks whose key is in `keys`; drop emptied subsections.""" + removed = 0 + for s in self.subs: + keep = [b for b in s.blocks if (s.name, b.text) not in keys] + removed += len(s.blocks) - len(keep) + if len(keep) != len(s.blocks): + s.blocks = keep + if not keep and s.heading is None: + s.lead, s.trail = 1, 0 + self.subs = [self.subs[0]] + [s for s in self.subs[1:] if s.blocks] + return removed + + def append_items(self, items, skip=frozenset()): + """Append (subsection, block) pairs, skipping exact duplicates of + `skip` or of blocks already here. Returns how many were added.""" + seen, added = set(skip) | self.keys(), 0 + for name, block in items: + if (name, block.text) in seen: + continue + seen.add((name, block.text)) + self.sub(name, create=True).append(block) + added += 1 + # The preamble needs a blank line before a following subsection. + if self.preamble.blocks and len(self.subs) > 1: + self.preamble.trail = max(self.preamble.trail, 1) + return added + + def render(self): + out = [self.heading] + for s in self.subs: + out += s.render() + return out + + +def _parse_section(heading, body): + groups, cur = [[None, []]], None + in_fence = None + for line in body: + f = _FENCE.match(line) + if in_fence: + if f and line.lstrip().startswith(in_fence): + in_fence = None + elif f: + in_fence = f.group(1) + elif line.startswith("### "): + groups.append([line, []]) + continue + groups[-1][1].append(line) + subs = [] + for h, lines in groups: + lead, blocks, trail = _parse_blocks(lines) + subs.append(Sub(h, lead, blocks, trail)) + return Section(heading, subs) + + +class Changelog: + def __init__(self, header, sections, ends_nl): + self.header, self.sections, self.ends_nl = header, sections, ends_nl + + @classmethod + def parse(cls, text): + ends_nl = text.endswith("\n") + lines = text.split("\n") + if ends_nl: + lines = lines[:-1] + header, raw, in_fence = [], [], None + for line in lines: + f = _FENCE.match(line) + if in_fence: + if f and line.lstrip().startswith(in_fence): + in_fence = None + elif f: + in_fence = f.group(1) + elif line.startswith("## "): + raw.append((line, [])) + continue + (raw[-1][1] if raw else header).append(line) + return cls(header, [_parse_section(h, body) for h, body in raw], ends_nl) + + def render(self): + out = list(self.header) + for s in self.sections: + out += s.render() + return "\n".join(out) + ("\n" if self.ends_nl else "") + + def section(self, version): + for s in self.sections: + if s.version == version: + return s + return None + + def unreleased(self): + found = [s for s in self.sections if s.version == UNRELEASED] + if len(found) != 1: + raise ReleaseError(f"CHANGELOG.md must have exactly one '## [Unreleased]' section, found {len(found)}") + return found[0] + + def versioned(self): + return [s for s in self.sections if isinstance(s.version, Version)] + + def newest_stable(self): + stables = [s.version for s in self.versioned() if not s.version.is_rc] + return max(stables) if stables else None + + def insert(self, section): + """Insert in descending precedence below [Unreleased].""" + if self.section(section.version) is not None: + raise ReleaseError(f"CHANGELOG.md already has a [{section.version}] section") + idx = len(self.sections) + for i, s in enumerate(self.sections): + if isinstance(s.version, Version) and s.version < section.version: + idx = i + break + else: + # Older than everything: after the last versioned section (or [Unreleased]). + for i, s in enumerate(self.sections): + if isinstance(s.version, Version) or s.version == UNRELEASED: + idx = i + 1 + unrel = self.sections.index(self.unreleased()) + idx = max(idx, unrel + 1) + if idx > 0: + self.sections[idx - 1].subs[-1].trail = max(self.sections[idx - 1].subs[-1].trail, 1) + if idx < len(self.sections): + section.subs[-1].trail = max(section.subs[-1].trail, 1) + self.sections.insert(idx, section) + return idx + + def drop(self, section): + self.sections.remove(section) + + +def _heading(version, date): + if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", date or ""): + raise ReleaseError(f"'{date}' is not a YYYY-MM-DD date") + return f"## [{version}] — {date}" + + +def heading_level(name): + name = name.lower() + if "breaking" in name or name.startswith("removed"): + return "major" + if name.startswith(("added", "changed", "deprecated")): + return "minor" + return "patch" + + +def bump_level(section): + """major / minor / patch from the subsection names that hold entries.""" + order = ("patch", "minor", "major") + levels = [heading_level(s.name) for s in section.subs[1:] if s.blocks] + return max(levels, key=order.index, default="patch") + + +def sync_main_changelog(text, tags): + """See the module docstring. `tags`: a TagSource-like object.""" + cl = Changelog.parse(text) + unrel = cl.unreleased() + floor = Version.parse(TRAIN_FLOOR) + versions = tags.versions + stables = sorted(v for v in versions if not v.is_rc) + train = [v for v in versions if v > floor] + report = {"inserted": [], "folded": [], "abandoned": [], "removedFromUnreleased": 0, + "returnedToUnreleased": 0} + + def tag_section(v): + sec = Changelog.parse(tags.changelog(v)).section(v) + if sec is None: + raise ReleaseError(f"tag v{v} has no [{v}] section in its CHANGELOG.md") + return copy.deepcopy(sec) + + def owner(v): + return next((s for s in stables if s >= v.core), None) + + for v in (v for v in train if not v.is_rc): + if cl.section(v) is None: + sec = tag_section(v) + cl.insert(sec) + report["removedFromUnreleased"] += unrel.remove(sec.keys()) + report["inserted"].append(str(v)) + + promoted = {} + for sec in [s for s in cl.versioned() if s.version.is_rc]: + stable = owner(sec.version) + if stable is None: + continue + if stable not in promoted: + promoted[stable] = tags.promoted_from(stable) + rc = promoted[stable] + if rc is not None and sec.version <= rc: + report["folded"].append(str(sec.version)) + else: + shipped = cl.section(stable) + report["returnedToUnreleased"] += unrel.append_items( + sec.items(), skip=shipped.keys() if shipped else ()) + report["abandoned"].append(str(sec.version)) + cl.drop(sec) + + for v in (v for v in train if v.is_rc and owner(v) is None): + if cl.section(v) is None: + sec = tag_section(v) + cl.insert(sec) + report["removedFromUnreleased"] += unrel.remove(sec.keys()) + report["inserted"].append(str(v)) + return cl.render(), report + + +def roll_unreleased(text, version, date): + """[Unreleased] -> `## [V] — date`, leaving an empty [Unreleased] above.""" + version = Version.parse(str(version)) + cl = Changelog.parse(text) + unrel = cl.unreleased() + if not unrel.items(): + raise ReleaseError("CHANGELOG.md's [Unreleased] section is empty — nothing to release") + newer = [s.version for s in cl.versioned() if s.version >= version] + if newer: + raise ReleaseError(f"CHANGELOG.md already has a section >= {version}: [{max(newer)}]") + sec = Section(_heading(version, date), unrel.subs) + unrel.subs = [Sub(None, 1, [], 0)] + cl.insert(sec) + return cl.render() + + +def cut_changelog(text, version, date, tags=None): + if tags is not None: + text, _ = sync_main_changelog(text, tags) + return roll_unreleased(text, version, date) + + +def promote_changelog(text, rc, date): + """Fold the rc sections into `## [core(rc)] — date` (module docstring).""" + rc = Version.parse(str(rc)) + if not rc.is_rc: + raise ReleaseError(f"promote needs an rc version, got {rc}") + cl = Changelog.parse(text) + if cl.section(rc.core) is not None: + raise ReleaseError(f"CHANGELOG.md already has a [{rc.core}] section") + floor = cl.newest_stable() + rcs = [s for s in cl.versioned() if s.version.is_rc] + fold = sorted((s for s in rcs if s.version <= rc and (floor is None or s.version > floor)), + key=lambda s: s.version) + if not fold or fold[-1].version != rc: + raise ReleaseError(f"CHANGELOG.md has no [{rc}] section to promote") + merged = copy.deepcopy(fold[0]) + merged.heading, merged.version = _heading(rc.core, date), rc.core + for s in fold[1:]: + merged.append_items(s.items()) + unrel = cl.unreleased() + for s in sorted((s for s in rcs if s.version.core == rc.core and s.version > rc), + key=lambda s: s.version): + unrel.append_items(s.items(), skip=merged.keys()) + cl.drop(s) + newest = fold[-1] + merged.subs[-1].trail = newest.subs[-1].trail + cl.sections[cl.sections.index(newest)] = merged + for s in fold[:-1]: + cl.drop(s) + return cl.render() + + +def check_section(text, version): + version = Version.parse(str(version)) + cl = Changelog.parse(text) + sec = cl.section(version) + if sec is None: + raise ReleaseError(f"CHANGELOG.md has no '## [{version}]' section") + if not sec.items(): + raise ReleaseError(f"CHANGELOG.md's [{version}] section is empty — a release needs written notes") + if not version.is_rc: + left = [str(s.version) for s in cl.versioned() if s.version.is_rc and s.version.core == version] + if left: + raise ReleaseError(f"CHANGELOG.md still has unfolded rc sections for {version}: {left}") + return len(sec.items()) + + +# ── version selection ─────────────────────────────────────────────────────── + +def select_version(tag_versions, branch_versions, unreleased_section): + """The next rc version (module docstring, "Version rules").""" + tags = sorted(tag_versions) + stables = [v for v in tags if not v.is_rc] + if not stables: + raise ReleaseError("no stable v tag found — fetch tags first") + latest = stables[-1] + level = bump_level(unreleased_section) + pending = sorted({v.core for v in tags if v.is_rc and v.core > latest}) + core = max([latest.bump(level)] + pending) + if core.major > latest.major: + if core.major != latest.major + 1: + raise ReleaseError(f"refusing to skip a major: latest stable is {latest}, candidate core {core}") + if core.major not in APPROVED_MAJORS: + heading = next((s.name for s in unreleased_section.subs[1:] + if s.blocks and heading_level(s.name) == "major"), "?") + raise ReleaseError( + f"[Unreleased] has '### {heading}' entries, which would open major {core.major} " + f"(latest stable {latest}), but {core.major} is not in APPROVED_MAJORS in " + "scripts/release.py. A new major needs a reviewed PR adding it there; otherwise " + "move those entries under a non-breaking heading.") + used = [v.rc for v in list(tags) + list(branch_versions) if v.is_rc and v.core == core] + version = core.with_rc(max(used, default=0) + 1) + if tags and version <= tags[-1]: + raise ReleaseError(f"{version} would not be above the newest tag v{tags[-1]}") + return {"version": str(version), "core": str(core), "level": level, + "latestStable": str(latest), "pendingCores": [str(p) for p in pending], + "burnedRcNumbers": sorted(set(used)), + "unreleasedEntries": len(unreleased_section.items())} + + +def next_version(git, ref="HEAD", remote=None): + tags = TagSource(git) + synced, _ = sync_main_changelog(git.show(ref, "CHANGELOG.md"), tags) + unrel = Changelog.parse(synced).unreleased() + return select_version(tags.versions, git.release_branches(remote), unrel) + + +# ── release notes ─────────────────────────────────────────────────────────── + +def _query_url(repo, query): + return f"https://github.com/{repo}/issues?q=" + urllib.parse.quote(query, safe="") + + +def render_notes(text, version, repo=DEFAULT_REPO, unlogged=None): + version = Version.parse(str(version)) + sec = Changelog.parse(text).section(version) + if sec is None: + raise ReleaseError(f"CHANGELOG.md has no [{version}] section") + body = [line for s in sec.subs for line in s.render()] + while body and _blank(body[0]): + body.pop(0) + while body and _blank(body[-1]): + body.pop() + out = [] + if version.is_rc: + out += [f"> **Prerelease.** Not served by `install.socket.dev/patch`, `socket-patch --update`, " + "`npm i @socketsecurity/socket-patch` (latest) or `cargo install` without `--version`. " + f"Try it with `npm i -g @socketsecurity/socket-patch@{version}` or " + f"`cargo install socket-patch-cli --locked --version {version}`.", ""] + out += body + ["", "---", ""] + # A link, never issue titles: no untrusted text reaches the notes. + out.append(f"Known issues: [open P1 issues]({_query_url(repo, f'is:issue is:open label:{P1_LABEL}')}) " + f"· [open release blockers]({_query_url(repo, f'is:open label:{BLOCKER_LABEL}')})") + if unlogged: + out += ["", f"_{unlogged} product commit(s) in this release have no CHANGELOG entry._"] + return "\n".join(out) + "\n" + + +# ── release-blocker gate (DESIGN.md §3.5) ─────────────────────────────────── + +class ApiError(Exception): + pass + + +def urllib_transport(method, url, headers): + req = urllib.request.Request(url, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, dict(resp.headers), resp.read() + except urllib.error.HTTPError as e: + return e.code, dict(e.headers or {}), e.read() + + +class GitHub: + """Minimal REST client; `transport(method, url, headers) -> (status, + headers, body bytes)` is injectable for tests.""" + + def __init__(self, repo, token, transport=urllib_transport, api_root=API_ROOT): + self.repo, self.token, self.transport, self.api_root = repo, token, transport, api_root + + def _get(self, url): + if not self.token: + raise ApiError("no GITHUB_TOKEN") + headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "socket-patch-release"} + try: + status, resp_headers, body = self.transport("GET", url, headers) + except Exception as e: # network, TLS, timeouts: all fail closed + raise ApiError(f"GET {url}: {e}") from e + if not 200 <= status < 300: + raise ApiError(f"GET {url}: HTTP {status}") + try: + data = json.loads(body) + except ValueError as e: + raise ApiError(f"GET {url}: invalid JSON") from e + link = {k.lower(): v for k, v in (resp_headers or {}).items()}.get("link", "") + nxt = re.search(r'<([^>]+)>;\s*rel="next"', link) + return data, nxt.group(1) if nxt else None + + def url(self, path, **params): + q = urllib.parse.urlencode(sorted((k, v) for k, v in params.items() if v is not None)) + return f"{self.api_root}/repos/{self.repo}{path}" + (f"?{q}" if q else "") + + def get(self, path, **params): + return self._get(self.url(path, **params))[0] + + def pages(self, path, max_pages=50, stop=None, **params): + url, items = self.url(path, per_page=100, **params), [] + for _ in range(max_pages): + data, url = self._get(url) + if not isinstance(data, list): + raise ApiError(f"{path}: expected a list") + items += data + if url is None or (stop and stop(data)): + return items + raise ApiError(f"{path}: more than {max_pages} pages") + + +def _ts(value): + """ISO-8601 (`Z` or any offset) -> aware UTC datetime; None stays None.""" + if not value: + return None + dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + raise ValueError(f"timestamp without a zone: {value}") + return dt.astimezone(datetime.timezone.utc) + + +def _iso(dt): + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _logins(env_value): + return {x.strip().lower() for x in (env_value or "").split(",") if x.strip()} + + +def _actor(event): + return ((event.get("actor") or {}).get("login") or "").lower() + + +def _is_blocker_event(e, kind): + return e.get("event") == kind and (e.get("label") or {}).get("name") == BLOCKER_LABEL + + +def evaluate_blockers(gh, base, since, approvers, routine_actors): + """{'blocked': bool, 'blockers': [...], 'error': str|None}. Any API error + blocks (fail closed).""" + def trusted(login): + return bool(login) and login in approvers and login not in routine_actors + + try: + since_dt = _ts(since) + t = _ts(gh.get(f"/commits/{base}")["commit"]["committer"]["date"]) + candidates = set() + for issue in gh.pages("/issues", state="open", labels=BLOCKER_LABEL): + candidates.add(issue["number"]) + # `since` filters on updated_at, a superset of "closed since L". + for issue in gh.pages("/issues", state="closed", labels=BLOCKER_LABEL, since=_iso(since_dt)): + candidates.add(issue["number"]) + # Unlabelled issues no longer match a label query; find them in the + # repo-wide event feed (newest first), back to the newest stable. + stop = lambda page: any(_ts(e["created_at"]) < since_dt for e in page) + for e in gh.pages("/issues/events", stop=stop): + if _ts(e["created_at"]) >= since_dt and _is_blocker_event(e, "unlabeled"): + candidates.add(e["issue"]["number"]) + + blockers = [] + for number in sorted(candidates): + issue = gh.get(f"/issues/{number}") + events = sorted(gh.pages(f"/issues/{number}/events"), + key=lambda e: (_ts(e["created_at"]), e.get("id") or 0)) + labelled = any(lbl.get("name") == BLOCKER_LABEL for lbl in issue.get("labels") or []) + unlabels = [e for e in events if _is_blocker_event(e, "unlabeled")] + if not labelled and not (unlabels and not trusted(_actor(unlabels[-1]))): + continue # never a blocker, or a trusted human unlabelled it last + why = "labelled" if labelled else f"unlabelled by untrusted @{_actor(unlabels[-1])}" + if issue.get("state") != "closed": + blockers.append({"number": number, "reason": f"open, {why}"}) + continue + closes = [e for e in events if e.get("event") == "closed"] + if not closes: + blockers.append({"number": number, "reason": f"closed without a close event, {why}"}) + continue + close = closes[-1] + if close.get("commit_id"): + status = gh.get(f"/compare/{close['commit_id']}...{base}").get("status") + if status in ("ahead", "identical"): + continue # the fix is in this tree + if trusted(_actor(close)) and _ts(close["created_at"]) <= t: + continue # a trusted human closed it before the base commit + fix = f"fix {close['commit_id'][:12]} not in base" if close.get("commit_id") else "no fix commit" + blockers.append({"number": number, + "reason": f"closed by @{_actor(close) or '?'} ({fix}), {why}"}) + return {"blocked": bool(blockers), "blockers": blockers, "error": None, + "base": base, "baseTime": _iso(t), "since": _iso(since_dt)} + except (ApiError, KeyError, TypeError, ValueError) as e: + return {"blocked": True, "blockers": [], "error": f"api-error: {e}", "base": base, + "since": since} + + +def latest_stable_date(git): + stables = [v for v in git.tags() if not v.is_rc] + if not stables: + raise ReleaseError("no stable tag found — fetch tags or pass --since") + return git.commit_date(f"refs/tags/v{max(stables)}") + + +# ── sync-main ─────────────────────────────────────────────────────────────── + +def sync_main(root, check=False): + """CHANGELOG sync + stamp of the newest tag version, on a working tree.""" + git = Git(root) + tags = TagSource(git) + path = Path(root) / "CHANGELOG.md" + old = _read(path) + new, report = sync_main_changelog(old, tags) + if not tags.versions: + raise ReleaseError("no release tags found — fetch tags first") + target = max(tags.versions) + changed = [] + if new != old: + changed.append("CHANGELOG.md") + if not check: + _write(path, new) + changed += stamp(root, target, check=check) + report.update({"version": str(target), "changed": changed}) + return report + + +# ── CLI ───────────────────────────────────────────────────────────────────── + +def cmd_semver(args): + if args.op == "validate": + v = Version.parse(args.versions[0]) + if args.kind == "stable" and v.is_rc: + raise ReleaseError(f"{v} is a prerelease; a stable X.Y.Z is required here") + if args.kind == "rc" and not v.is_rc: + raise ReleaseError(f"{v} is not an X.Y.Z-rc.N version") + print(v) + elif args.op == "compare": + a, b = (Version.parse(x) for x in args.versions[:2]) + print((a > b) - (a < b)) + elif args.op == "core": + print(Version.parse(args.versions[0]).core) + return 0 + + +def cmd_stamp(args): + changed = stamp(Path(args.root), args.version, check=args.check) + if args.check: + if changed: + print(f"stamp {args.version} is not a no-op — these files carry a different " + f"version: {' '.join(changed)}", file=sys.stderr) + return 1 + print(f"every stamped site already carries {args.version}") + return 0 + print(f"Synced version to {args.version}") + return 0 + + +def cmd_next_version(args): + result = next_version(Git(args.root), args.ref, args.remote) + print(json.dumps(result, indent=2) if args.json else result["version"]) + return 0 + + +def cmd_changelog(args): + path = Path(args.file or Path(args.root) / "CHANGELOG.md") + text = _read(path) + if args.op == "check": + n = check_section(text, args.version) + print(f"[{args.version}] section present with {n} entries") + return 0 + if args.op == "cut": + tags = None if args.no_sync else TagSource(Git(args.root)) + new = cut_changelog(text, args.version, args.date, tags) + elif args.op == "promote": + new = promote_changelog(text, args.rc, args.date) + else: + new, report = sync_main_changelog(text, TagSource(Git(args.root))) + print(json.dumps(report, indent=2), file=sys.stderr) + _write(path, new) + return 0 + + +def cmd_sync_main(args): + report = sync_main(Path(args.root), check=args.check) + print(json.dumps(report, indent=2)) + return 1 if args.check and report["changed"] else 0 + + +def cmd_notes(args): + text = Git(args.root).show(args.ref, "CHANGELOG.md") if args.ref else _read( + args.file or Path(args.root) / "CHANGELOG.md") + sys.stdout.write(render_notes(text, args.version, args.repo, args.unlogged)) + return 0 + + +def cmd_blockers(args, transport=urllib_transport): + since = args.since + if not since: + try: + since = latest_stable_date(Git(args.root)) + except ReleaseError as e: + result = {"blocked": True, "blockers": [], "error": f"since: {e}"} + print(json.dumps(result, indent=2)) + return 1 + gh = GitHub(args.repo, os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"), transport) + result = evaluate_blockers(gh, args.base, since, + _logins(os.environ.get("RELEASE_APPROVERS")), + _logins(os.environ.get("RELEASE_ROUTINE_ACTORS"))) + print(json.dumps(result, indent=2)) + for b in result["blockers"]: + print(f"release-blocker #{b['number']}: {b['reason']}", file=sys.stderr) + if result["error"]: + print(f"blocked: {result['error']}", file=sys.stderr) + return 1 if result["blocked"] else 0 + + +def build_parser(): + p = argparse.ArgumentParser(prog="release.py", description=__doc__.split("\n\n")[0]) + p.add_argument("--root", default=str(REPO_ROOT), help="repository root (default: this checkout)") + sub = p.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("semver", help="validate / compare release versions") + s.add_argument("op", choices=["validate", "compare", "core"]) + s.add_argument("versions", nargs="+") + s.add_argument("--kind", choices=["any", "stable", "rc"], default="any") + s.set_defaults(fn=cmd_semver) + + s = sub.add_parser("stamp", help="stamp a version into every packaging site (offline)") + s.add_argument("version") + s.add_argument("--check", action="store_true", help="write nothing; exit 1 if the stamp would change a file") + s.set_defaults(fn=cmd_stamp) + + s = sub.add_parser("next-version", help="the version the next rc cut gets") + s.add_argument("--ref", default="HEAD", help="candidate commit whose CHANGELOG is read") + s.add_argument("--remote", help="also count release/* branches on this remote (git ls-remote)") + s.add_argument("--json", action="store_true") + s.set_defaults(fn=cmd_next_version) + + s = sub.add_parser("changelog", help="CHANGELOG.md transforms") + s.add_argument("op", choices=["cut", "promote", "sync-main", "check"]) + s.add_argument("--file", help="CHANGELOG path (default: /CHANGELOG.md)") + s.add_argument("--version", help="cut/check: the section version") + s.add_argument("--rc", help="promote: the rc being promoted (X.Y.Z-rc.N)") + s.add_argument("--date", help="cut/promote: YYYY-MM-DD (UTC)") + s.add_argument("--no-sync", action="store_true", help="cut: skip the in-memory sync-main step") + s.set_defaults(fn=cmd_changelog) + + s = sub.add_parser("sync-main", help="sync CHANGELOG + version on main to the newest tag") + s.add_argument("--check", action="store_true", help="write nothing; exit 1 if main is behind") + s.set_defaults(fn=cmd_sync_main) + + s = sub.add_parser("notes", help="render GitHub release notes for one version") + s.add_argument("--version", required=True) + s.add_argument("--file") + s.add_argument("--ref", help="read CHANGELOG.md at this git ref instead of a file") + s.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY") or DEFAULT_REPO) + s.add_argument("--unlogged", type=int, help="product commits without a CHANGELOG entry") + s.set_defaults(fn=cmd_notes) + + s = sub.add_parser("blockers", help="the release-blocker gate; exit 1 when blocked") + s.add_argument("--base", required=True, help="the candidate tree's base commit") + s.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY") or DEFAULT_REPO) + s.add_argument("--since", help="ISO time bound for closed/unlabelled candidates " + "(default: the newest stable tag's commit date)") + s.set_defaults(fn=cmd_blockers) + return p + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + if args.cmd == "changelog": + need = {"cut": ("version", "date"), "promote": ("rc", "date"), "check": ("version",)} + for name in need.get(args.op, ()): + if not getattr(args, name): + parser.error(f"changelog {args.op} needs --{name}") + try: + return args.fn(args) + except ReleaseError as e: + print(f"release.py: error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/fixtures/release/CHANGELOG.main-045d7ec7.md b/scripts/tests/fixtures/release/CHANGELOG.main-045d7ec7.md new file mode 100644 index 000000000..3572a298c --- /dev/null +++ b/scripts/tests/fixtures/release/CHANGELOG.main-045d7ec7.md @@ -0,0 +1,1089 @@ +# Changelog + +All notable changes to socket-patch are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Pre-v3.0 entries are concise summaries derived from each tag's commit +history. For full per-release detail, see the +[GitHub releases page](https://github.com/SocketDev/socket-patch/releases). + +The `Release` workflow refuses to publish a version that does not appear +in this file — see `scripts/release-lint.sh` (run by the `version` job in +`.github/workflows/release.yml` and by CI on version-bump PRs). Bump PRs +are opened by `scripts/bump-version.sh`, which rolls `[Unreleased]` over +into the new version's section — see docs/releasing.md. + +## [Unreleased] + +v5 centers the workflow on `scan` (hosted patches), `vex` (OpenVEX attestations), +and `vendor` (committed patched packages), with `list` for inspection. See the +[v5 migration guide](docs/migrating-to-v5.md) before updating existing automation. + +### Breaking changes + +- `scan` and `get` default to hosted mode. `scan` never prompts, and hosted or + vendored `get` selects without an interactive menu. Explicit `--mode agent` + retains in-place patching; `get --save-only` and global targeting select agent + behavior by default. A mode-less global scan or `scan --prune` does not acquire + new patches, though `--prune` still cleans up obsolete state. +- Hosted mode writes no ledger. `list`, VEX, and update discovery read the live + dependency references. `rollback` and `remove` restore upstream registry entries + rather than replaying saved edits, and refuse where restoration is unavailable + (including offline operation and binary `bun.lockb`). Legacy hosted ledgers are + read only for compatibility and removed by full rollback. +- `rollback` restores dependencies and removes patch records and unused artifacts. + `--preserve-state`, also available on `remove`, keeps local state for reuse. + Hosted state has no local artifact to preserve. +- Vendored scans and targeted gets are manifest-free. The vendor ledger embeds + patch records; `repair` no longer reconstructs a missing ledger from lockfiles. + `vendor` can eject hosted pins when no agent manifest exists. Reverting an + ejected package restores upstream dependencies. +- Vendored Cargo patches use workspace-root `Cargo.toml` wiring and tagged + `+socket.` versions, visible to `CARGO_PKG_VERSION`. Re-running + vendoring or repair migrates older config-file wiring and untagged copies. +- `socket.yml` policy now constrains scans. Invalid patch policy fails before + requests or writes. Discovered test and fixture projects are excluded by + default; literal project targets skip those defaults. Hosted/vendored PATHs + outside the repository are rejected. +- Automatic patch selection prefers the highest severity, then the most advisories + fixed, then publication date. Existing patches change only when the new patch + outranks them; tier/UUID tie-breaking alone does not trigger replacement. +- `setup`, its publishing helpers, and the PyPI/RubyGems CLI distributions are + removed. Standalone binaries, Cargo, and npm remain supported; Python and Ruby + project support is unchanged. Remove old hooks using the migration guide. +- Removed `scan --redirect`, `scan --detached`, mode aliases `host`/`redirect`/ + `vendor`, the unimplemented `--one-off` flags, and the three legacy + `SOCKET_PATCH_*` environment aliases listed in the migration guide. + `.socket/packages/` archives are no longer consumed; cleanup removes leftovers. +- `list` on an empty project exits 0; `get` usage errors exit 2. Human help and + output are grouped by task, with diagnostic codes retained in JSON and verbose + output. Hosted JSON identifies lockfiles instead of a ledger; rollback's + `vendored` results contain vendor-owned entries only. See the + [CLI contract](crates/socket-patch-cli/CLI_CONTRACT.md) for exact schemas. +- VEX requires live hosted/vendored wiring and reports corrupt vendor ledgers. + Verified agent patches no longer require a `setup` hook for attestation. +- The core crate removes setup-related modules, obsolete public helpers, and the + unused `DepOverride::berry_zip_url` field. Patch references containing + `berryZipUrl` still parse. + +### Added + +- Vendored Maven reactors and Gradle builds, with committed repositories, + reversible wiring, repair, rollback, and VEX. Reactors use suffixed versions; + Gradle preserves coordinates and lockfiles, checks artifact hashes, and updates + existing verification metadata. `vendor --check` audits artifacts and wiring + offline; `--local-repo` checks Maven cache conflicts and `--maven-config=none` + selects the fallback file repository. Single-POM vendoring is unchanged. +- `socket.yml` patch policy for paths, ecosystems, packages, severity, and per-run + limits. `scan --package`, `--min-severity`, `--max-new-patches`, and + `--no-socket-yml` support targeted and gradual rollout. Already-patched packages + retain protection when excluded; updates do not spend the new-patch budget. + Disk scans and the in-memory hosted engine share these rules. +- Manifest-free VEX discovery from hosted and vendored references, including fresh + checkouts. Product inference covers Go, Composer, Maven, NuGet, and RubyGems in + addition to existing formats. Embedded VEX supports the same evidence checks. +- vlt support across agent, hosted, and vendored workflows, with native installer + coverage and explicit version/layout refusals. +- Native binary `bun.lockb` reading and rewriting, alongside text `bun.lock`, + without invoking Bun or converting binary locks to text. +- Expanded Python lockfile support for uv, Poetry, PDM, and Pipenv, including + lock-only inventory, supported multi-version/marker shapes, and stale-install + diagnostics. Unsupported installer formats are refused before writes. +- npm 12 hosted `allow-remote` configuration and dual-lock handling, plus pnpm + trust-lockfile configuration. Explicit user settings are respected. +- Path targeting on scan and rollback, hosted update detection from lockfiles, + and configurable API request concurrency. + +See [ecosystem support](docs/ecosystems.md) and the +[compatibility guides](docs/testing/README.md) for format boundaries, integrity +limits, and required install commands. + +### Fixed + +- Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on + Windows, where they install as `.cmd` / `.bat` shims, instead of reporting + an empty scan. The yarn and npm-family global lookups no longer run from the + scanned project, so a Yarn Berry project's `global` script can't run or pick + the directory treated as the global install. Composer's global home also + falls back to `%APPDATA%\Composer` and `$XDG_CONFIG_HOME/composer`. +- Agent-mode PyPI `apply` patches every installed copy of a release, not just + the first one found. A Pipenv project with both a WORKON_HOME venv and a + `./.venv`, or a global install with the same release in the user site and a + system dir, no longer keeps the copy Python imports unpatched while `vex` + attests it (#529, #501). +- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb` + twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer + leads to an edit of an ignored `Gemfile` that reports success and attests an + unpatched gem; unsupported layouts are refused before any write (#341, #390). +- Gem modes read Bundler settings in Bundler's own priority. A `BUNDLE_GEMFILE` + in `.bundle/config` now outranks the environment variable, so a dual-boot + project with an exported `BUNDLE_GEMFILE=Gemfile` is no longer wired through + the `Gemfile` Bundler ignores (#507). The hosted stale-install guard checks + the committed archive in Bundler's configured cache dir (`cache_path` / + `BUNDLE_CACHE_PATH`) instead of always `vendor/cache`, so a stale archive + there now warns and keeps the same run's VEX from attesting it (#483). + Both settings skip `.bundle/config` under `BUNDLE_IGNORE_CONFIG`, as Bundler + does. +- **npm dependencies installed from git, a URL or `file:` are no longer + reported patched.** npm installs such a dependency from the dependent's + spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the + lock entry's `resolved`, so `npm ci` kept installing the original bytes + after `scan --mode hosted` or `vendor` rewired the entry and `vex` + attested it. Both modes now skip such an entry with a loud + stays-UNPATCHED warning (`redirect_npm_non_registry_entry_skipped` / + `vendor_non_registry_entry_skipped`; vendoring refuses with + `vendor_lock_entry_not_rewritable` when no registry copy is left), and + `vex` attests nothing for a `name@version` while such a copy is in the + lock (#326). A dependency the project's `overrides` send back to a + registry version is not one of these: npm installs the override's + registry release, so hosted and vendored modes patch it again, and + `vex` attests it (#490). +- **Agent mode finds Poetry's virtualenv in more setups.** Three cases + missed the virtualenv Poetry installed into. Each fell back to the + wrong interpreter, skipped the patch as `package_not_installed` and + still exited 0: + - a nameless `package-mode = false` project (Poetry names its env + `non-package-mode-…`); + - a Poetry 2 project with both `[project] name` and + `[tool.poetry] name` (Poetry uses `[project] name`); + - an explicit `virtualenvs.in-project = false` next to a stray `./.venv`. + + Every Windows project missed it too, because the cwd hash included the + `\\?\` prefix that path canonicalization adds (#327, #329). +- Hosted Maven warns `redirect_maven_trusted_checksums_unenforced` when + `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4. Maven + 3.9.0–3.9.3 never enforce the Trusted Checksums pin that hosted mode writes; + the 4.0.0 notes and docs wrongly said every 3.9 release does. The version + suffix still fails closed. CI runs the real-Maven hosted capstone on 3.9.3 and + 3.9.4 (#258). +- Patch application, reversal, and cleanup handle missing files, release variants, + corrupt state, newer ledger formats, and unsafe manifest paths without silently + dropping protection. File ownership restoration failures produce warnings. +- Hosted Cargo handles v1 locks, CRLF files, and repeated declarations, and refuses + transitive dependencies its registry pin cannot reach. Vendored Cargo preserves + multiple versions and warns about old-toolchain limitations. +- Go preserves user-authored replacements, handles `+incompatible` versions, + restores checksums, and unwinds hosted references during vendoring. Registry + fetches honor `GOPROXY` and private-module settings. +- Yarn Berry preserves supported line endings and checksum spellings. Mode + preflights, including Bun's, run before discarding existing protection. +- Yarn Berry hosted references no longer send npm registry credentials to the + patch server. The old `npm:` locator made yarn attach `npmAuthToken` / + `YARN_NPM_AUTH_TOKEN` to scoped packages (and to every package under + `npmAlwaysAuth`). Hosted mode now pins the way yarn does for a root + `resolutions` entry: `package.json` routes the locked descriptor to the + hosted tarball and the lock entry is keyed by it, which also passes yarn's + hardened mode (on by default for public pull request CI). A user-authored + `resolutions` entry for the package is never overwritten. Locks pinned by + earlier releases are re-pinned on the next hosted `scan`. +- Composer hosted references remove upstream source fallbacks and mirrors; + RubyGems hosted locks preserve source order; NuGet edits use the active config + and survive `` entries. +- Python rewrites preserve supported markers, groups, extras, source metadata, and + integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled + consistently with each installer's supported behavior. +- Hosted Pipenv scans read the `Pipfile`, so a conflicting `Pipfile.lock` entry + refuses the patch project-wide instead of half-redirecting a sibling + `requirements.txt` (#333). +- Vendoring reuses valid committed artifacts during service outages. Updates do + not build from a previous patch's modified bytes. Verified service artifacts + keep their identity; integrity failures do not fall through to a local rebuild. + Repair rebuilds against recorded pins and reports unavailable inputs. +- VEX no longer attests an npm package that also ships a bundled, unpatched + copy of the same `name@version` (`inBundle: true`, or v1 `bundled: true`). + npm unpacks that copy from the parent's tarball, so no rewire reaches it; the + reference is now reported `patched_ref_unattributable`, naming the bundled + copy, in hosted and vendored mode (#325). +- API throttling uses bounded retries, failed queries appear in JSON diagnostics, + and hosted reference resolution handles batches larger than 500 patches. +- Transient apply locks are removed on normal command exit; no-op scans and full + reversal avoid leaving unused `.socket/` state. Terminal output, telemetry + timeouts, and update-check handling are more consistent. +- Agent mode finds transitive npm packages in npm's linked store + (`install-strategy=linked`, `node_modules/.store`) and in a relocated pnpm + `virtualStoreDir`, instead of reporting them `package_not_installed` (#359, + #362). A store outside the project, such as pnpm's global virtual store, is + shared with other projects and is still not patched in place. +- npm locks keep their own layout when edited. `scan --mode hosted`, + `scan --mode vendored`, `rollback` and `vendor --revert` + re-serialized `package-lock.json` / `npm-shrinkwrap.json` with LF line + endings (and, in hosted mode, a fixed 2-space indent), so a CRLF or + tab-indented lock got a whole-file diff and the undo did not restore its + bytes. A lock with a UTF-8 BOM, which npm installs from, was skipped as + unparseable (hosted) or refused as `vendor_lockfile_version_unsupported` + (vendored). The lock now keeps its BOM, indent and line endings, and the + undo is byte-exact (#324). +- Agent mode no longer patches other projects through a store they share. + PDM 2.0–2.12 with `install.cache` and `cache_method = symlink` links + `site-packages/` into its package cache, and pnpm's global virtual + store (`enableGlobalVirtualStore`) links `node_modules/` into + `/links`. `apply` (also `-g`) wrote the patch into that shared + directory, so every project using it was patched, and a `rollback` in one + project silently unpatched the rest. `apply` and `rollback` now fail on + such a package, naming the store and how to get a private copy + (#332, #361). +- `vendor` under `--global` / `--global-prefix` (or `SOCKET_GLOBAL` / + `SOCKET_GLOBAL_PREFIX`) is now a usage error (exit 2, + `global_scope_unsupported`), like `scan` and `get` with `--mode vendored`. + Run inside a project, `vendor -g` vendored the manifest's records into that + project and rewired its lockfile, and `vendor --revert -g` unwound the + project's vendoring, so its next frozen install was silently unpatched. + Global installs have no project lockfile to vendor into (#498). +- Patch API requests (`scan`, `get`, `apply` and `vex` lookups, and blob and + diff downloads) no longer hang forever on a stalled proxy, load balancer or + half-open connection. A connect now fails after 10 s, and a connection that + sends nothing for 60 s fails as a network error. Downloads that keep + streaming are not cut off (#570). + +### Maintenance + +- Shared format models, project snapshots, ledger views, and a vendored backend + consolidate discovery and lifecycle handling. Grouped writes and bounded + concurrency reduce repeated disk and network work. +- CI reuses compiled test binaries and splits broader compatibility matrices into + dedicated jobs. Release publishing uses separate Cargo and npm workflows. +- Documentation now separates usage, configuration, migration, compatibility, and + development guidance; completed plans, prototype research, and historical run + reports are removed from the maintained docs. + +## [4.0.0] — 2026-08-20 + +v4.0 is the three-modes release. What began as an agent-style tool that +patches installed packages in place now offers three deployment modes, +selected with `scan --mode ` (each mode's detailed +entries follow below; per-ecosystem mechanics live in `docs/ecosystems.md`): + +- **Agent mode** (the default, and the original behavior): `apply` patches + installed packages in place on the current machine — `node_modules/`, + site-packages, the cargo registry cache, and so on — tracked in the local + `.socket/` manifest and re-applied after installs by the hooks `setup` + configures. Requires socket-patch (and Socket API access, or pre-fetched + blobs) on every machine that installs dependencies. + +- **Vendored mode** (`vendor`, or `scan --mode vendored`): ejects each + patched package into a committed + `.socket/vendor///` and rewires the + ecosystem's lockfile so the project consumes the vendored copy. After + committing, a fresh checkout builds with the patched dependency on + machines with no socket-patch installed and no Socket API access — fully + offline/airgap-friendly, and the strictest install flags (`npm ci`, + `--frozen-lockfile`, `--locked`, `--deploy`, …) verify the vendored + artifact like any other. A committed ledger records the verbatim original + lockfile fragments, so `vendor --revert` restores them byte-exactly. + Covered in v4.0: the whole npm family (npm, yarn classic, yarn berry + node-modules, pnpm, bun), pypi (uv, requirements, poetry, pdm, pipenv), + cargo, go, composer, gem, maven, and nuget. + +- **Hosted mode** (`scan --mode hosted`, new in v4.0): rewrites lockfiles / + registry configs so ONLY the patched dependencies resolve to + Socket-hosted, integrity-pinned artifacts on patch.socket.dev — no + artifact bytes land in the repo and no CI changes are needed. The package + manager's own integrity checking pins the patched bytes (tamper fails the + native install), and re-runs are idempotent. Covered in v4.0: the npm + family (package-lock/shrinkwrap, pnpm — including Rush monorepos — yarn + classic, yarn berry, bun), pypi (requirements, uv), cargo, composer, gem, + nuget, maven (fail-closed version suffixing + Trusted Checksums), and + golang for references carrying a `goproxy` registry override (free tier; + otherwise Go stays vendored — see the documented NO-GO analysis). + +All three modes feed VEX attestation, with a provenance marker per mode: +plain (agent), `(vendored)`, and `(redirected)` (hosted). + +### Removed (BREAKING) + +- **The `unlock` subcommand.** Folded into `repair`, which now deletes the + leftover `<.socket>/apply.lock` file as its final housekeeping step (skipped + under `--dry-run`, refused with `lock_held` while another live socket-patch + process holds the lock). Rationale: a leftover lock file from a crashed run + never blocked acquisition in the first place — the OS releases a dead + holder's advisory lock along with its file handle — so `unlock`'s inspect + path had no recovery scenario, and its `--release` file deletion is now + automatic. Migration: `unlock --release` → `repair`; the probe-style + "is anything holding the lock?" check → run the mutating command (optionally + with `--lock-timeout`) and branch on `errorCode: lock_held`. + `SOCKET_UNLOCK_RELEASE` is gone with the subcommand, and the + `patch_unlocked` / `patch_unlock_failed` telemetry events are retired. +- **The global `--break-lock` flag and `SOCKET_BREAK_LOCK` env var.** It never + stole a live holder's lock (deliberately, since that defeats mutual + exclusion) and a stale file never contends, so all it did was emit a + `lock_broken` audit event for a reclaim that plain acquisition performs + anyway. The `lock_broken` warning event and rollback's `warnings[]` + `lock_broken` entry are no longer emitted (`warnings` stays present, now + always empty). The `lock_held` stderr hint now advises waiting / + `--lock-timeout` instead of pointing at the removed commands. + +### Changed (BREAKING) + +- **`--help` command order** is now workflow-first: `scan`, `apply`, `vex`, + `vendor`, `setup`, then `rollback`, `get`, `list`, `remove`, `repair`. + +### Added + +- **`get --mode ` — per-advisory hosted/vendored + patching.** `get` (fetch/apply a single patch by CVE/GHSA id, patch UUID, + or package name) now honors the same mode selector as `scan`: hosted + rewrites lockfiles for just the selected patches through scan's redirect + engine verbatim; vendored routes through scan's vendor step with scan's + save-only download posture. CVE/GHSA fan-outs are narrowed to versions + actually installed (disk ∪ manifest, plus the lockfile inventory and + vendor ledger in hosted/vendored modes); when narrowing leaves nothing, + the new additive `not_installed` status reports it at exit 0. Exempt from + narrowing: UUID ids, exact-versioned purls, `--save-only`, + `--all-releases`, and the package-name path. +- **Hosted mode for Go (free tier).** `scan --mode hosted` now redirects + golang dependencies when the reference carries a `goproxy` registry + override: a fork-style + `replace => patch.socket.dev/gopatch/ -socketpatch.` + in `go.mod` plus the socket module's two `h1:` lines in `go.sum` (and the + replaced original's lines pruned — the tidy-stable state). Day-2 machines + need no configuration: go consults the checksum database only for modules + absent from `go.sum`, so the committed pair is the whole redirect — + validated end-to-end in `e2e_golang_hosted_build.rs` (fresh caches, bogus + `GOSUMDB` tripwire, `go mod tidy` byte-level no-op, tampered-hash + `SECURITY ERROR`). Fails closed (per-dep `redirect_golang_*` warnings, no + partial writes) on missing hashes, an out-of-namespace module path, a + require-version mismatch, or a user-authored replace conflict; references + without the override keep the historical `redirect_golang_unsupported` + warning (paid tier stays vendored — see `docs/ecosystems.md#go-directory-replaces-and-gosum`). + Wire schema gains `integrity.goModH1` and + `registryOverride.identifiers.goModuleVersion` (additive). Requires + server-side publication of the grant-free `gopatch` artifact flavor — + production publishes no golang hosted modules yet, so behavior is unchanged + until it does. +- **Version-bump automation + release-readiness gate.** + `scripts/bump-version.sh --pr` performs the whole bump chore — + stamps every packaging site via `version-sync.sh`, rolls `[Unreleased]` + into a dated `## [X.Y.Z]` CHANGELOG section, and opens the `release/vX.Y.Z` + PR (also dispatchable from the Actions tab as the **Version Bump** + workflow). A new `release-readiness` CI job runs `scripts/release-lint.sh` + on every PR: version-coherence always (version-sync must be a no-op, so a + hand-edited version in any one packaging site fails CI), plus the full + gate — non-empty CHANGELOG section, no pre-existing tag — on PRs that bump + the workspace version. The `Release` workflow's `version` job now runs the + same script, so the publish gate and the PR gate cannot drift. Playbook: + docs/releasing.md. +- **`socket-patch --update` — self-update.** Downloads the release for the + compiled target from GitHub Releases, verifies it against the published + `SHA256SUMS` before extraction, sanity-execs the staged binary, and + atomically swaps it in place (Windows uses the rename-dance via + `self-replace`; a setuid/setgid install is refused). `--update 3.4.0` + (or `SOCKET_PATCH_VERSION`) pins a version, up or down; bare `--update` + never downgrades; `--force` reinstalls. `--dry-run` is a check-only + probe (zero downloads, `updateAvailable` in the `--json` details). + Package-manager-managed installs (npm, pip, cargo, the gem launcher + cache, Homebrew) are detected from the canonicalized executable + path and refused with that manager's own upgrade command; `--force` + overrides. `--offline` refuses up front and `--force` cannot bypass it. + Concurrent updates are single-flighted via an advisory lock; every + failure path leaves the installed binary untouched. +- **Passive update notice.** Interactive runs mention a newer release at + most once a day, on stderr only, after the command's own output: + suppressed under `--json`/`--silent`/`--offline`, in CI, when stderr is + not a terminal, or with `SOCKET_NO_UPDATE_CHECK=1` (suppressed means + zero network I/O). The background check can never alter a command's + exit code, stdout, or add more than ~500 ms; state corruption degrades + to "never checked". An explicit `--update` refreshes the notice's cache. +- **`shellcheck scripts/install.sh` in CI** (and a fix for the SC2144 + glob-with-`-e` musl-loader probe it found). +- **`socket login` now configures socket-patch.** The JS Socket CLI's + persisted config (`/socket/settings/config.json`) is read — + never written — as a fallback layer below env vars for `apiToken`, + `defaultOrg`, and `apiBaseUrl`: precedence per key is CLI flag > env var + > socket-cli config > built-in default. Four `SOCKET_CLI_*` env names + are accepted as silent peer aliases (`SOCKET_CLI_API_TOKEN`, + `SOCKET_CLI_ORG_SLUG`, `SOCKET_CLI_API_BASE_URL`, + `SOCKET_CLI_NO_API_TOKEN`); the canonical `SOCKET_*` names win. Two new + env-only toggles: `SOCKET_NO_API_TOKEN` ignores ambient tokens (env + + config; an explicit `--api-token` still authenticates) and + `SOCKET_NO_CONFIG` disables the config layer. A corrupt config file + warns once on stderr and is ignored; `--json` stdout is unaffected. The + telemetry endpoint now resolves the API base through the same chain as + client construction, so a config-supplied `apiBaseUrl` applies to both. + Design notes: `docs/configuration.md`. + + +- **Hosted patch mode: `scan --mode hosted` (a.k.a. the hidden `--redirect`).** + The third patch-application mode: instead of applying in place (agent) or + committing artifacts (vendored), `scan` rewrites lockfiles / registry + configs so ONLY the patched dependencies resolve to Socket-hosted, + integrity-pinned packages on patch.socket.dev — no artifact bytes land in + the repo and no CI changes are needed. Per ecosystem: npm rewrites + `package-lock.json`/`npm-shrinkwrap.json` `resolved`+`integrity` (v2 legacy + `dependencies` mirror included), `pnpm-lock.yaml` inline resolutions, and + yarn classic `resolved`/`integrity` blocks; pypi rewrites `requirements.txt` + pins to `name @ --hash=sha256:…` (pip-compile continuation lines are + refused rather than corrupted) and `uv.lock` wheel entries; cargo defines a + per-patch sparse registry in `.cargo/config.toml` plus `Cargo.toml` + `registry =` keys and Cargo.lock `source`/`checksum` surgery; composer + rewrites the lock entry's `dist` url/shasum; nuget adds a `nuget.config` + source + `packageSourceMapping` and repins `packages.lock.json` + `contentHash`; gem adds a per-dep `source` block + a `CHECKSUMS` pin + (bundler ≥ 2.6). A dep counts as redirected only when its hosted URL (or + per-dep registry index) actually landed in a project file; re-runs are + idempotent (zero new edits over already-rewritten output). The Rust + rewriters are held byte-identical to the depscan backend's TS twins (the + GitHub-app hosted PR flow) by shared golden fixtures under + `tests/fixtures/redirect/`. JSON output gains a `redirect` sub-object with + `mode: "hosted"`, `redirected`, `rewrittenFiles`, `skipped`, `warnings`. +- **`scan --mode `: the documented mode selector.** One + value-enum flag replaces the boolean spellings (`--redirect` == hosted, + `--vendor` == vendored, `--apply`/`--sync` == agent), which remain supported + as aliases. Combining `--mode` with a boolean of a DIFFERENT mode is a + usage error (exit 2); the same mode spelled both ways is accepted, and + `--detached` now requires vendored mode in either spelling. +- **VEX support for hosted mode: the `(redirected)` provenance marker + the + redirect ledger.** `scan --mode hosted` persists its recorded file edits and + the full patch records (file hashes + vulnerabilities) into + `.socket/vendor/redirect-state.json` (merge-on-rewrite, append-only edits — + the pre-redirect originals a future revert needs are never clobbered). + Redirected patches carry the impact-statement marker "Patched via Socket + patch `` (redirected)", completing the provenance trio (plain = + agent, `(vendored)`, `(redirected)`). In-run `scan --mode hosted --vex` + attests confirmed redirects from the ledger WITHOUT hash verification (the + bytes are fetched at install time; the JSON `vex` summary carries + `verified: false`), while a post-install `socket-patch vex` reads the ledger + back and hash-verifies the redirected patches against the installed tree. + A confirmed redirect whose record fetch failed surfaces a + `record_fetch_failed` warning (the patch is missing from VEX until a + re-run). +- **NuGet + Maven vendor backends (`vendor` / `scan --mode vendored`).** + NuGet: the uuid dir is a committed *folder feed* holding a deterministically + rebuilt `.nupkg` (embedded signature dropped; unsigned is accepted under + NuGet's default validation), wired via a `nuget.config` source + + `packageSourceMapping` and a `packages.lock.json` `contentHash` repin — + `dotnet restore --locked-mode` then fails NU1403 on tamper. Maven: the uuid + dir is a committed *maven2 `file://` repository* (rebuilt `.jar` + the + verbatim upstream pom so transitives survive + `.sha1` sidecars), wired via + a `pom.xml` `` with `checksumPolicy=fail`; multi-module + aggregator poms (`vendor_maven_multimodule_unsupported`) and gradle-only + projects (`vendor_gradle_unsupported`) are refused fail-closed, and the + always-on `vendor_maven_local_cache_shadow` advisory carries the + `mvn dependency:purge-local-repository` one-liner (a warm `~/.m2` copy + silently shadows any repository). Both are proven by docker capstones + against the real .NET SDK / Apache Maven (cold-cache, `--network none`, + RED + TAMPER probes). `nuget` and `maven` are now DEFAULT compile features; + the `SOCKET_EXPERIMENTAL_NUGET` / `SOCKET_EXPERIMENTAL_MAVEN` runtime + opt-ins that briefly gated in-place agent apply were retired later in this + cycle (see the "promoted to fully available" entry under Changed). The vendored + path convention + uuid recovery rule now covers `nuget` and `maven` dirs, + and `--vendor-source` prebuilt downloads cover nuget. +- **Maven hosted rewriter (pom projects) — fail-closed version suffixing + + Trusted Checksums.** Hosted mode's maven leg pins the patched jar the only + way a lockfile-less ecosystem can: the serve route exposes the patch under a + Socket-only `-socket.` suffix (existing ONLY on the injected + `socket-patch-` repository), and the rewriter pins that version + explicitly — it rewrites the literal ``, or (for a transitive / + managed dependency with no literal version) adds a `` + entry — alongside the `` insert (releases enabled, + `checksumPolicy=fail`, snapshots disabled). An outage or tamper on the Socket + repo then HARD-FAILS the build: the suffixed version resolves nowhere else, + so there is no silent fall-through to Central (the base version 404s). A + `${property}` version is refused (`redirect_maven_dep_unpinned` — a literal + edit would break the reference and a depMgmt pin could strand sibling + artifacts); a literal version matching neither the base nor the suffixed + value is skipped (`redirect_maven_dep_version_mismatch`); a non-jar `` + is skipped (`redirect_maven_unsupported_packaging`). When the serve route + supplies both the jar and pom sha256, the rewriter also emits Maven 3.9+ + Trusted Checksums files — `.mvn/maven.config` resolver args (`originAware=false`, + `failIfMissing=false`) + `.mvn/checksums/checksums.sha256` entries pinning + both artifacts under the suffixed version's local-repo path, merging into any + pre-existing user config / checksum set (a conflicting value is never + overridden — `redirect_maven_trusted_checksums_conflict`). The `.mvn/*` files + are silently inert below Maven 3.9 (the version suffixing is still fail-closed + on its own); on 3.9.0–3.9.8 a mismatch is enforced but reported unclearly + (readability fixed in 3.9.9, MNG-8182). When the upstream pom is unavailable / + unsuffixable the rewriter falls back to the legacy same-GAV repository + injection with a `redirect_maven_same_gav_fallback` warning (NOT fail-closed: + a Socket-repo failure falls back to the unpatched artifact). Gradle build + scripts are never edited: a present `build.gradle*` / `settings.gradle*` + emits a paste-able `exclusiveContent` snippet carrying the suffixed version + (`redirect_gradle_manual_snippet`) plus a reminder to bump the dependency + declaration — fail-closed by repository exclusivity. +- **Hosted mode now rewrites yarn-berry and bun lockfiles.** The hosted npm + family gains two flavors beyond package-lock / pnpm / yarn-classic. **yarn + berry** (`__metadata:` v2+ lock): the rewriter edits ONLY the lock entry — + `resolution:` gains yarn's own `::__archiveUrl=` + binding and `checksum:` becomes the precomputed `yarnBerry10c0` cache-zip + sha512 — leaving the descriptor key and `package.json` untouched, so `yarn + install --immutable --check-cache` passes and tamper fails YN0018. Whole-file + gates refuse a `cacheKey ≠ 10c0` or a `.yarnrc.yml compressionLevel ≠ 0` + (`redirect_yarn_berry_cache_unsupported`) — no offline-reproducible checksum. + Validated e2e against real `corepack yarn@4.12.0` on the node-modules linker; + PnP is not exercised for hosted (the lock rewrite fires, but PnP's + `.yarn/cache` resolution is untested). **bun** (text `bun.lock` v1): the + packages-entry registry 4-tuple `["name@ver","",{deps},"sha512-…"]` is + rewritten to a URL 3-tuple `["name@",{deps},"sha512-…"]`, fail-closed on + any grammar deviation; `bun install --frozen-lockfile` then installs the + hosted bytes and tamper fails the integrity check. A binary `bun.lockb` with + no text lock is auto-migrated first via the user's own `bun install + --save-text-lockfile --frozen-lockfile --lockfile-only` (deletes `bun.lockb`, + recorded as a `removed` ledger edit, offline, fails closed; + `redirect_bun_lockb_would_migrate` on `--dry-run`, + `redirect_bun_lockb_unsupported` if the migration is unavailable). The Rust + rewriters are byte-identical to the depscan backend's TS twins via shared + golden fixtures. +- **Hosted mode supports Rush monorepos.** A Rush repo has no root + `package.json`/lockfile pair — its pnpm source-of-truth lock lives at + `common/config/rush/pnpm-lock.yaml` (plus one per subspace under + `common/config/subspaces//`). `scan --mode hosted` discovers those + locks when `rush.json` is present and repoints them in place (the pnpm + rewriter is now basename-generalized, so nested locks rewrite path-generically). + Editing a Rush lock outside `rush update` desyncs the `pnpmShrinkwrapHash` in + `common/config/rush/repo-state.json`, so a `redirect_rush_repo_state_stale` + warning fires when a lock was touched and that file exists — `rush install` + fails under `preventManualShrinkwrapChanges` until `rush update` refreshes it, + but the redirect survives the refresh (pnpm keeps locked resolutions for + unchanged specifiers). Agent mode already works through Rush's generated + project symlink farm; vendored mode is refused (`vendor_rush_unsupported`) + because `rush install` copies the lock into `common/temp`, so vendor's + relative `file:` specs can't survive — the refusal routes to hosted mode. +- **pnpm hosted rewriter generalized to nested lockfiles.** The + `pnpm-lock.yaml` rewriter now matches any `pnpm-lock.yaml` at the project + root OR at any nested path (`*/pnpm-lock.yaml`), so Rush subspace locks and + other nested-lock layouts are rewritten in place under their repo-relative + keys. Write-back and confirmed-redirect gating are path-generic. +- **Golang hosted mode is a documented NO-GO.** Hosted redirect for Go is + deliberately unsupported — sumdb hard-fails the patched pseudo-version on + every day-2 machine and the only escapes are uncommittable machine-local + config; Go's module-path identity would force per-grant artifacts against + the build-once converter; and the default `GOPROXY` chain would leak + licensed bytes / tokened URLs to the public mirror. The full analysis lives + in `docs/ecosystems.md#go-directory-replaces-and-gosum`; both the CLI rewriter and the + depscan backend twin emit `redirect_golang_unsupported` naming the remedy + (use vendored mode, which gives Go everything hosted promises elsewhere). + The one sanctioned exception — an ephemeral-CI GOPROXY recipe — is + documentation-only and never written into a repository. + +- **`vendor` now supports every major npm and pypi package manager.** The npm + ecosystem gained four lockfile flavors beyond `package-lock.json` — yarn + classic (`yarn.lock` v1), yarn berry with the node-modules linker + (`resolutions` + a cache-zip `10c0` checksum reproduced offline from the + vendored tarball), pnpm (`pnpm.overrides` + `pnpm-lock.yaml` surgery, pnpm 9 + & 10), and bun (`bun.lock`) — all sharing the one vendored tarball and + selected by a content-sniffing probe (yarn-berry PnP and bun's binary + `bun.lockb` are refused with pointers to the native flow). The pypi + ecosystem gained poetry, pdm, and pipenv (lock-only `[[package]]` / entry + splices, like the existing uv/requirements flavors). Every lockfile + checksum/reference field for a vendored package is now recomputed + coherently (the v2 "update checksums and references" directive); the gem + backend handles bundler ≥ 2.6's optional `CHECKSUMS` section; composer's + `dist.reference` carries the patch UUID into `installed.json`. Each flavor + has a real-package-manager build-proof capstone (fresh-checkout, cold-cache, + strictest-install — `--frozen`/`--immutable`/`--deploy`/`--locked` — with + byte-identical revert). `vendor --force`/`--revert` accept empty env vars + (`SOCKET_FORCE=`) as false, matching the global-flag contract. + +- **New `vendor` subcommand: committable vendoring of patched dependencies.** + Where `apply` patches installed packages in place (machine-local state), + `socket-patch vendor` ejects each patched package into a committed + `.socket/vendor///` and rewires the + ecosystem's lockfile so the project consumes the vendored copy — after + committing, a fresh checkout builds with the patched dependency on machines + with no socket-patch installed and no Socket API access. Per ecosystem + (each mechanism validated against the real package manager): npm rewrites + `package-lock.json` only (deterministic patched tarball, recomputed + integrity, `npm ci`-verified); cargo writes a `[patch.crates-io]` entry in + `.cargo/config.toml` plus surgical Cargo.lock edits so `cargo build + --locked --offline` works; golang reuses the `replace`-directive engine + pointed at the vendor tree; composer rewrites the lock entry to a + `dist: path` copy; gem edits the Gemfile + Gemfile.lock pair in bundler's + canonical form; pypi rebuilds a valid wheel (regenerated RECORD) wired + through uv's `pyproject.toml`/`uv.lock` pair (uv-first) or + requirements.txt (`pip` / `uv pip`). The patch UUID is recoverable from the + lockfile path string alone (a documented convention for external tools), a + committed `.socket/vendor/state.json` ledger records the verbatim original + lockfile fragments, and `vendor --revert` restores them byte-exactly. + `vendor --vex` mirrors `apply --vex`; VEX generation attests vendored + patches by hashing the committed artifacts, and `apply` yields ownership of + vendored packages (`vendored` skip reason). + + +- **Cargo support (`cargo` is now a default feature).** `apply` patches a Rust + dependency **in place** wherever the crawler finds it — the project `vendor/` + directory or the shared `$CARGO_HOME` registry cache — rewriting the crate's + `.cargo-checksum.json` sidecar so `cargo build` accepts the modified files. + `rollback` restores the original bytes from the `beforeHash` blobs, like + npm/PyPI/gem. `cargo` ships on by default (alongside the always-on npm + PyPI + + Ruby gems support), so released binaries and a plain `cargo install + socket-patch-cli` patch Rust dependencies out of the box; + `maven`/`composer`/`nuget`/`deno` remain opt-in. +- **Project-local Go `replace`-redirect backend (`golang`, default feature).** + The Go module cache is shared, read-only and checksum-verified, so in-place + patching would fail `go.sum` at build time. Instead `apply` writes a + project-local patched **copy** under `.socket/go-patches/@/` + and a managed `replace` directive in the project `go.mod`, so the patch is + project-scoped and the cache stays pristine for sibling projects. `rollback` + cleanly drops the `replace` directive + copy. `apply --check` is a read-only, + lock-free, offline auditor that verifies the committed redirects match the + manifest, exiting non-zero on drift (for CI / GitHub-App use). +- **Inline OpenVEX generation on `apply` and `scan` via `--vex `.** A + single successful `apply`/`scan` can now both patch and emit the OpenVEX + 0.2.0 attestation, instead of requiring a separate `socket-patch vex` step. + The `--vex-product` / `--vex-no-verify` / `--vex-doc-id` / `--vex-compact` + flags mirror the standalone `vex` knobs (and reuse the `SOCKET_VEX_*` env + vars). The document is always written to the given path (never stdout, so it + never races `--json`), built from the post-run manifest and verified against + on-disk state. JSON output gains a top-level `vex` summary + (`{ path, statements, format }`). A requested-but-failed VEX makes the + command exit non-zero even when the apply/scan itself succeeded, surfacing a + stable error code in the envelope. + +### Changed + +- **`install.sh` can install without reaching github.com.** New + `SOCKET_PATCH_BASE_URL` points the archive downloads at any releases base that + answers GitHub's two asset paths — notably + `https://install.socket.dev/patch/SocketDev/socket-patch/releases`, which relays them + from the GitHub release, so one URL template covers either origin. A new + release needs no publish for this: the origin resolves "latest" per request. + `socket-patch --update` can use the same host today through the + `SOCKET_UPDATE_BASE_URL` override it already has. Also new: + `SOCKET_PATCH_INSTALL_DIR` to choose the install directory explicitly instead + of taking `/usr/local/bin` or `~/.local/bin`. The default download origin is + still GitHub — see `docs/installer-hosting.md`. + +- **The documented one-liner installs from `https://install.socket.dev/patch`.** + The previous URL was `raw.githubusercontent.com`, which asks users to trust a + third-party CDN for a script they pipe into a shell and is the first URL a + locked-down egress policy blocks. The hosted copy is byte-for-byte + `scripts/install.sh`, with its SHA-256 published at + `install.socket.dev/patch.sha256`; the GitHub raw URL keeps working and serves + the same bytes. Binaries are still downloaded from the GitHub release and + verified against its `SHA256SUMS` — the trust model is unchanged, only the + script's origin moved. New: `docs/installer-hosting.md` (how the copy is + published), a CI step that runs the installer end to end instead of only + linting it, and an `installer-drift` workflow that checks the hosted copy + against this repository weekly. + +- **Maven and NuGet promoted to fully available — the + `SOCKET_EXPERIMENTAL_MAVEN` / `SOCKET_EXPERIMENTAL_NUGET` runtime gates + are retired.** Every flow (`scan` in all modes, `apply`, `get`, + `rollback`, `vendor`, `repair`, `vex`, `setup`) now discovers and + patches installed Maven and NuGet packages unconditionally; the + "N patch(es) skipped — support is experimental" warnings are gone, and + the previously `#[ignore]`d maven/nuget dispatch e2e tests now gate CI. + Setting the old env vars is harmless but does nothing. Behavior notes: + a default `scan` now walks the local Maven repository (`~/.m2` / + `MAVEN_REPO_LOCAL`) and the NuGet caches, and `scan --prune`/`--sync` + now judges maven/nuget manifest entries like any other ecosystem's + (previously they were exempt from pruning while the gate was closed). + The in-place sidecar caveat is unchanged and now documented per mode in + `docs/ecosystems.md`: agent-mode patching leaves Maven's + `.jar.sha1`/`.jar.md5` stale and NuGet's fixup deletes + `.nupkg.metadata` + advises on `.nupkg.sha512`; the vendored/hosted + modes never touch the caches. + +- **Release workflow consolidated into a single `release.yml`.** One + dispatch now publishes every package — crates.io, npm, PyPI, and + RubyGems (both gems), all via OIDC trusted publishing — with the + launcher-gem job gated on the GitHub release existing. The separate + `release-ecosystems.yml` workflow is removed (its `release: published` + trigger never fired: the release is created with `GITHUB_TOKEN`, which + suppresses downstream workflow events). The CLI is distributed via + GitHub releases, npm, PyPI, crates.io, and RubyGems only — the + Composer/Packagist, Maven Central, and NuGet launcher channels drafted + earlier in this cycle were dropped before ever shipping in a release. +- `--api-url` / `--proxy-url` no longer carry clap-level defaults: with + neither flag nor env var set they parse as unset and the documented + default URLs are applied at API-client construction (after the + socket-cli config layer). Observable behavior is unchanged unless a + socket-cli login exists. +- **All ecosystem feature flags removed — every ecosystem is always compiled + in.** The `cargo`, `golang`, `maven`, `composer`, `nuget`, and `deno` Cargo + features are gone from both crates; npm, PyPI, Ruby gems, Go, Cargo, NuGet, + Maven, Composer, and Deno support is now unconditional. Builds that passed + `--features ` will get an "unknown feature" error and should simply + drop the flag; `--no-default-features` no longer produces a minimal binary + (there is nothing left to strip). The `SOCKET_EXPERIMENTAL_MAVEN` / + `SOCKET_EXPERIMENTAL_NUGET` runtime gates outlived this entry only briefly — + they are retired in the same release (see the "promoted to fully available" + entry under Changed). The only remaining features are the + test-suite gates `docker-e2e` and `setup-e2e` on `socket-patch-cli`. (MAJOR + for anyone scripting `--features`; no behavior change for default builds + beyond composer/deno support now being present.) + +- **Token-less `scan` now batch-queries the public proxy.** Proxy-mode scans + POST `{proxy}/patch/batch` (one request per `--batch-size` chunk, mirroring + the authenticated `/v0/orgs/{slug}/patches/batch` endpoint) instead of + issuing one `GET /patch/by-package/:purl` per package. The client + transparently degrades to the legacy per-package GET path against proxies + that predate the batch endpoint, and when the all-or-nothing batch + validation rejects a chunk (e.g. a crawled PURL type the server doesn't + recognize, such as `pkg:jsr/…` — per-package queries tolerate those + individually, so one exotic package can't fail a whole scan). Rate limits + and over-capacity 503s still surface instead of silently degrading. (MINOR) + +### Fixed + +- **bun 1.4 lockfiles are accepted again.** bun 1.4.0 bumped `bun.lock` + `lockfileVersion` to 2 while leaving the emitted grammar unchanged; the + shared version gate refused everything but 1, so hosted and vendored + modes refused every lock written by bun ≥ 1.4 + (`redirect_bun_lock_unsupported` / `vendor_lockfile_version_unsupported`). + The gate now accepts versions 1 and 2 and keeps failing closed on + anything else; new shared golden fixture `npm/bun/lock-v2` (the depscan + TS twin needs the matching acceptance + fixture sync). +- **gem: every coexisting installed copy is patched.** Bundler's scoped + `//gems` and flat `gems/` stores can coexist under one + `BUNDLE_PATH` root, each holding a real copy of the same gem@version; + first-wins resolution patched one store and reported success while the + other bundler loaded pristine (vulnerable) bytes. `apply` now fans out + per copy (per-copy `Applied` events, each counted in `summary.applied`), + bundle-path roots are crawled in bundler precedence order, and + config-sourced roots are contained. The `gem_bundle_config_path_ignored` + warning also prints the skipped path verbatim instead of + backslash-escaped. +- **npm `@socketsecurity/socket-patch`: the `./schema` export is now built + at publish.** The subpath pointed at a gitignored `dist/` directory that + nothing built during release, so it shipped broken; a `prepack` script + now compiles it as part of `npm publish`. +- **Release workflow tag-guard and idempotency fixes.** The + tag-already-exists guard never fired (it ran `git rev-parse` in a + shallow, tagless checkout) — it is now a stateless `git ls-remote` check + that still permits same-commit retries; the GitHub-release step re-runs + cleanly instead of hard-failing when the release already exists; and the + cargo/PyPI/gem publish jobs skip already-published versions, so + "Re-run failed jobs" can resume a partial release safely. +- **NuGet hosted rewriter: creating a `packageSourceMapping` from scratch now + emits a catch-all for pre-existing sources.** `packageSourceMapping` is + exclusive — once ANY mapping exists, every package must match some source's + pattern or restore hard-fails NU1100. A redirect into a `nuget.config` with + no prior mapping previously routed only the patched id, breaking every + OTHER package's restore; the rewriter now fans a `` + mapping out to each pre-existing package source (longest-prefix match still + routes the patched id to the Socket source). Golden fixtures updated on + both the Rust and TS sides. + +- **VEX now attests Go `replace`-redirect patches.** `socket-patch vex` + previously verified golang patches against the pristine module cache + instead of the patched `.socket/go-patches/` copy, so redirect-applied + patches were silently omitted from the document (reported `not_applied`, + or `package_not_found` on cache-less CI). Verification now follows the + managed `replace` directive to the committed copy. + +- **`repair` on a hosted-only project is an informational no-op.** Hosted + (`--mode hosted`) mode leaves no local artifacts to repair — the lockfiles + point at `patch.socket.dev` URLs, and there is no manifest or vendor ledger. + A project whose only `.socket/` trace is `redirect-state.json` (no manifest, + no vendor ledger, no vendored lockfile references) previously errored with + `manifest_not_found` (exit 1); it now exits 0 with a `redirect_only_project` + skip pointing at `scan --mode hosted`. Repair still errors on a bare + directory with no traces at all. + +## [3.2.0] — 2026-05-29 + +A repo-wide correctness, security, and filesystem-safety hardening pass: every +source file in both crates was reviewed line by line, the bugs found were fixed, +and regression tests were added throughout (the lib + integration suites grow by +~10k lines of mostly tests). The audit harness used to drive the review lives in +`scripts/study-crates.ts`. + +### Security + +- **Path-traversal in archive extraction.** `read_archive_to_map` + (`patch/package.rs`) validated the raw tar entry path but returned the + `package/`-stripped path, so an entry like `package//etc/passwd` passed every + check and then resolved to an absolute `/etc/passwd` that `Path::join` + writes outside the package tree. Validation now runs on the normalized path + actually written to disk. +- **Unbounded preallocation from an untrusted delta header.** `apply_diff` + (`patch/diff.rs`) reserved a `Vec` sized from the bsdiff target-size header, + which qbsdiff never validates — a tiny hostile delta could claim up to + `i64::MAX` and abort the process. The hint is now clamped to 64 MiB. +- **Evidence-free VEX attestation.** `verify_patch_record` (`vex/verify.rs`) + returned `applied` for a patch touching zero files, producing a + `not_affected` statement with no on-disk evidence; zero-file records are now + omitted (`no_files`). + +### Fixed — filesystem safety, atomicity & rollback + +- **`apply` could not write into read-only directories** (Go module cache marks + dirs `0o555`); added a `DirWriteGuard` that temporarily grants write on the + parent dir around the CoW-break + atomic rename and restores its exact mode. +- **`apply` stripped setuid/setgid bits** on every patched file because `chown` + ran after `chmod`; reordered to chown-before-chmod, plus a parent-dir `fsync` + so the rename survives a crash. +- **Non-atomic symlink break** (`patch/cow.rs`) removed the file before staging + its replacement, destroying it with no rollback on a failed write; now + rename-over the link, matching the hardlink path. Stage files are cleaned up + on every error arm. +- **`rollback` used an unsafe in-place write**; it now delegates to the hardened + `apply_file_patch` (atomic, CoW-safe, validate-before-write, permission + restore). Also: a GC'd before-blob no longer shadows the already-original + short-circuit, and new-file deletion works inside read-only directories. +- **Hash integrity:** `compute_file_git_sha256` (`patch/file_hash.rs`) opened + and stat'd the path separately (TOCTOU) and never checked the target was a + regular file (a directory hashed as the empty blob); now opens once, fstats + the descriptor, and rejects non-regular files. `compute_git_sha256_from_reader` + now errors when the streamed byte count disagrees with the declared size. +- **Sidecar writes in read-only caches:** the cargo `.cargo-checksum.json` + rewrite and the NuGet `.nupkg.metadata` delete used bare, non-atomic I/O that + failed `EACCES` in the locked-down registry trees they exist to serve; both + now go through the hardened write/`DirWriteGuard` paths. +- **Blob cleanup** (`utils/cleanup_blobs.rs`) aborted the whole sweep on one + dangling symlink and inflated the "checked" count with subdirs/dotfiles; now + uses `symlink_metadata`, skips stat errors, and counts only real blobs. +- **Lock acquisition** (`patch/apply_lock.rs`) mapped every `flock` error to + `Held` (masking `ENOLCK`/`EACCES`/unsupported-FS and busy-waiting through the + whole timeout) and overshot sub-100 ms waits; genuine faults now surface + immediately and the sleep is clamped to the remaining budget. + +### Fixed — crawlers (on-disk layout & metadata) + +- **Composer:** normalize the `v`-prefixed `installed.json` version against bare + PURLs, tolerate a single malformed entry instead of dropping the file, and + skip packages absent on disk. +- **Go:** only skip `cache/` at the module-cache root (not at any depth), + decode/encode case-escaped versions (`v1.0.0-RC1` ↔ `…-!r!c1`), treat `GOPATH` + as a path list, and reject malformed/empty `module` directives. +- **npm:** follow symlinked directories during the global-fallback walk + (`DirEntry::metadata()` doesn't follow links) and guard nested recursion so it + doesn't descend through symlinked packages. +- **NuGet:** lowercase the version directory (not just the id) when resolving the + global packages folder, so prerelease-cased versions resolve. +- **Python:** the macOS framework `Versions/` layout uses bare `3.11` dirs, and a + package with missing/malformed `METADATA` now falls back to its + `-.dist-info` directory name instead of vanishing. +- **Deno:** correct the macOS cache path (`~/Library/Caches/deno`), honor + `XDG_CACHE_HOME` on Linux, and treat an empty `DENO_DIR` as unset. +- **Maven:** strip XML comments before tag matching and handle self-closing / + inline skip-sections so a commented or oddly-formatted POM can't leak a + plugin's coordinates as the project's. +- **Cargo:** tolerate `[package]` headers with comments/whitespace and split + `-` dirs at the dotted version (handles numeric pre-releases). +- **Shared:** `utils/fs::entry_is_dir` now follows symlinks, fixing symlinked + package-dir discovery across every dir-walking crawler at once. + +### Fixed — API client, commands & misc + +- **API client:** honor a `--proxy-url` override on binary downloads (was + re-derived from env), and make org selection, patch titles, and the + individual-query batch capability flag deterministic / order-independent; + hash comparison is now case-insensitive. +- **Version reporting:** `USER_AGENT` and telemetry `context.version` were + hardcoded to `1.0`/`1.0.0`; both now derive from `CARGO_PKG_VERSION`. +- **`apply`** no longer emits a spurious `Failed` envelope event for a + release-variant whose first file is `NotFound`. +- **UTF-8 safety:** `get`/`scan`/`remove` truncated display strings with raw + byte slices that panic on multi-byte API text; all use char-safe truncation. +- **Exit codes:** `setup` now exits non-zero (not `already_configured`) when a + `package.json` fails to parse, and `repair` exits non-zero and fires failure + telemetry on a partial download failure (also gates the offline dry-run + "would download" event and threads through `bytes_freed`). +- **`rollback`** no longer miscounts zero-file records as already-original or + double-counts no-ops in dry-run; **`unlock`** reports `released` from a + pre-`acquire` snapshot so a probe-created lock file isn't reported as removed. +- **`vex`** resolves qualified PyPI/Gem/Maven PURLs via the rollback-aware + resolver so those patches are no longer dropped as `package_not_found`. +- **`package.json` handling:** no longer panics on a non-object root or + non-object `scripts`, de-dups overlapping workspace patterns, handles bare + `*`/`**`/deep globs, strips inline YAML comments, and preserves top-level key + order (enabled `serde_json`'s `preserve_order`). +- Smaller fixes: deterministic `list` output ordering, case-insensitive + `fuzzy_match` tie-break, `json_envelope` status-invariant enforcement + + `oldUuid` field, `lock_cli` sub-second timeout message, blob-fetcher + all-skipped formatting, VEX `Statement.timestamp` made optional per OpenVEX + 0.2.0, and VEX git-remote `url` parsing. + +### Tests & tooling + +- Hundreds of regression tests added across the patch engine, crawlers, API + client, manifest, `package.json`, VEX, and CLI command layers; the stale + `repair`/`python_crawler` e2e expectations were updated to the corrected + contracts. Full suite green (`--features cargo`). +- Added the `scripts/study-crates.ts` per-file audit harness (with an example + prompt config) used to drive this review. + +## [3.1.0] — 2026-05-26 + +### Added + +- **Telemetry coverage for read-side + housekeeping + attestation commands.** + `scan`, `get`, `list`, `setup`, `repair`, `unlock`, and the new `vex` + command each emit a `patch_` (and matching `*_failed`) event + through the existing send path, joining the apply/remove/rollback + trio that already shipped. The `scan` event carries per-tier counts + (`free_patches`/`paid_patches`/`can_access_paid`), the ecosystems + filter, and a `fallback_to_proxy` flag; `get` carries + `uuid`/`tier`/`ecosystem`/`download_mode`/`fallback_to_proxy`. + +- **`scan` + `get` automatically fall back to the public proxy on + 401/403** from the authenticated endpoint. A stale or revoked + token no longer blocks access to free patches — the CLI logs a + warning to stderr, swaps to the proxy, retries once, and tags the + resulting telemetry event with `fallback_to_proxy: true`. The + classifier is deliberately narrow: 404, 5xx, network, and rate-limit + errors do NOT trigger fallback so backend issues stay visible. + `apply`/`remove`/`rollback`/`vex` keep their fail-loud semantics. + +- **`SOCKET_OFFLINE` (airgap mode) now disables telemetry universally.** + `is_telemetry_disabled()` honors the same `SOCKET_OFFLINE=1|true` + signal `--offline` uses for network suppression, so apply (and + every future command) no longer attempts a 5-second telemetry POST + against `https://api.socket.dev` when the operator explicitly + requested airgap. + +### Tests + +- New `tests/cli/telemetry_e2e.rs` end-to-end behavioral coverage: + apply/scan/get/list emit telemetry against a wiremock recorder; + `SOCKET_OFFLINE=1` produces zero telemetry POSTs across all four; + scan falls back on 401 + tags the resulting event; scan does NOT + fall back on 500 (conservative classifier). +- New `scan_invariants` cases for the patch-management lifecycle: + withdrawn patches keep their entry when the package is still + installed but API is silent; entries for uninstalled packages get + pruned; `scan` without `--apply` is read-only against the manifest + and blobs even when an update is detected. + +## [3.0.0] — 2026-05-22 + +### Breaking + +- **`--offline` semantics unified** to strict airgap on every subcommand. + Previously meant three different things across `apply` (strict airgap), + `repair` (skip downloads / cleanup-only), and `rollback` (fail when blobs + missing). All three now mean the same thing: never contact the network, + fail loudly when a required local source is missing. +- **`repair --download-mode` default** changed from `file` to `diff` to + match every other subcommand. Users who need the legacy per-file blob + behavior must now opt in with `--download-mode file`. +- **`repair --offline` is mutually exclusive with `--download-only`** — + passing both exits with code 2. +- **Env vars renamed.** The three remaining `SOCKET_PATCH_*` env vars now + use the `SOCKET_*` prefix: + - `SOCKET_PATCH_PROXY_URL` → `SOCKET_PROXY_URL` + - `SOCKET_PATCH_DEBUG` → `SOCKET_DEBUG` + - `SOCKET_PATCH_TELEMETRY_DISABLED` → `SOCKET_TELEMETRY_DISABLED` + + The legacy names are still honored at runtime but emit a one-shot + deprecation warning to stderr (the warning fires even under `--silent` + and `--json` because the transition signal must reach scripts and CI + logs). Legacy names will be removed in v4. + +### Added + +- Shared `GlobalArgs` clap struct `#[command(flatten)]`-ed into every + subcommand. Every flag is now accepted on every subcommand (silently + no-op'd where the subcommand doesn't consume it). Every flag has a + matching `SOCKET_*` env-var binding with precedence + `CLI arg > env var > default`. See `CLI_CONTRACT.md` for the full + global-arguments table. +- `apply` and `repair` accept `--api-url`, `--api-token`, `--org` via the + global flatten (previously env-var only — telemetry would silently fall + back to the public proxy when the CLI was the only way to set these). +- New global flags `--debug` and `--no-telemetry`, promoted from env-only + toggles. +- `--proxy-url` (env: `SOCKET_PROXY_URL`) as an explicit CLI knob for the + public patch proxy. +- New CI guard in the `Release` workflow: the workflow fails before tag + creation if `CHANGELOG.md` lacks an entry for the version in + `Cargo.toml`. Blocks every downstream publish (cargo, npm, pypi). + +### Changed + +- Garbage collection moved out of `apply`. Use `scan --prune`, + `scan --sync`, or `repair` / `gc` instead. `apply` is now strictly + non-mutating against `.socket/`: when blobs need to be fetched they go + to a temp overlay; the persistent cache is never written to. +- Unified JSON envelope (`command` / `status` / `events` / `summary`) for + `apply`, `list`, `remove`, `repair`. Other subcommands keep their + pre-v3 ad-hoc shapes for now; see `CLI_CONTRACT.md` for migration status. + +## [2.1.4] — 2026-04-09 + +- Release workflow tolerates already-published npm packages so a partial + publish can be retried without re-tagging. + +## [2.1.3] — 2026-04-08 + +- Pin Node `22.22.1` in the release workflow to dodge a broken + upstream npm. + +## [2.1.2] — 2026-04-08 + +- Harden core error handling, blob verification, and `--force` reporting. +- Surface `find_by_purls` errors instead of silently swallowing them. +- Add diagnostics to `apply` for silent no-op failures in CI. +- Add explicit Node typings for TypeScript 6 compatibility in the npm + wrapper. + +## [2.1.1] — 2026-04-02 + +- Simplify release to `workflow_dispatch` only (no bot commits). +- Split release into PR-based version prep + auto-publish on dispatch. +- Prioritize `pnpm-workspace.yaml` detection and restrict `setup` to root + `package.json` for pnpm monorepos. +- Harden GitHub Actions workflows per `zizmor` audit. +- Unflag Ruby gem (`gem`) support and add e2e bundler tests. +- Use `npx @socketsecurity/socket-patch` for the generated postinstall + command. + +## [2.1.0] — 2026-03-10 + +- Full glibc/musl support across all Linux architectures (16 platform + combinations now published per release). + +## [2.0.0] — 2026-03-06 + +- Interactive prompts and smart patch selection when multiple patches + match a query. + +## [1.7.1] — 2026-03-06 + +- Ensure the binary has execute permission in the PyPI wrapper. +- Restore `bin` and `optionalDependencies` to the npm wrapper + `package.json`. + +## [1.7.0] — 2026-03-06 + +- Expand ecosystem support: rough-in for composer, go, maven, nuget, ruby. +- Add a TypeScript schema library to the npm wrapper. +- Treat empty `SOCKET_API_TOKEN` as unset. + +## [1.6.3] — 2026-03-05 + +- Maintenance release. + +## [1.6.2] — 2026-03-05 + +- Maintenance release (version sync). + +## [1.6.1] — 2026-03-05 + +- Switch to per-platform `optionalDependencies` for the npm package. +- Add macOS global-package crawling fallbacks and pyenv support. + +## [1.6.0] — 2026-03-04 + +- Add support for more platforms; fix pypi and npm publish flows. + +## [1.5.0] — 2026-03-04 + +- Fix trusted publishing setup for npm and PyPI. + +## [1.4.0] — 2026-03-04 + +- Update PyPI publish action and add npm provenance permissions. + +## [1.3.1] — 2026-03-04 + +- Fix action image references in the publish workflow. + +## [1.3.0] — 2026-03-04 + +- Add `apply --force`; rename `--no-apply` to `--save-only` (the old name + remains as a hidden alias). +- Cargo/Rust crate patching support behind a feature flag. +- Auto-resolve org slug from API token when `SOCKET_ORG_SLUG` is unset. + +## [1.2.0] — 2026-01-10 + +- Fix publish workflow to checkout the bumped version. + +## [1.1.0] — 2026-01-10 + +- Pin GitHub Actions to full commit SHAs and wire up version-bump + support in the publish workflow. diff --git a/scripts/tests/fixtures/release/CHANGELOG.train.md b/scripts/tests/fixtures/release/CHANGELOG.train.md new file mode 100644 index 000000000..553106fb3 --- /dev/null +++ b/scripts/tests/fixtures/release/CHANGELOG.train.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to socket-patch are documented here. + +## [Unreleased] + +v5 centers the workflow on `scan`, `vex` and `vendor`. + +### Breaking changes + +- `scan` and `get` default to hosted mode (#277). +- `setup` and its publishing helpers are removed (#231). + +### Added + +- Vendored Maven reactors and Gradle builds, with committed + repositories and reversible wiring (#500). + +### Fixed + +- Bound patch API connects and stalled reads (#581). +- Stop npm oracle trees symlinking into a cycle (#582). + + The cycle only appeared with nested workspaces. + +## [4.0.0] — 2026-08-20 + +### Added + +- `get --mode hosted` and `get --mode vendored` (#226). + +## [3.2.0] — 2026-05-29 + +- Filesystem safety fixes. diff --git a/scripts/tests/fixtures/release/blockers.json b/scripts/tests/fixtures/release/blockers.json new file mode 100644 index 000000000..cdb6dd12c --- /dev/null +++ b/scripts/tests/fixtures/release/blockers.json @@ -0,0 +1,883 @@ +{ + "_comment": "GitHub REST shapes (issues, issue events, compare) for release.py blockers; each issue's `events` is served at /issues/{n}/events and merged newest-first into /issues/events. Logins: approvers alice,bob,mikolalysenko; routine actors mikolalysenko,claude-routine-bot.", + "repo": "o/r", + "base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "baseTime": "2026-10-12T11:00:00Z", + "since": "2026-08-20T00:00:00Z", + "approvers": "alice, bob, MikolaLysenko", + "routineActors": "mikolalysenko,claude-routine-bot", + "scenarios": { + "open_untouched_200_days": { + "issues": [ + { + "number": 101, + "title": "Untrusted title #101