From fbd0fb601ad7d04b30b7fd3398adc10eee9f1aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 18 Oct 2026 03:28:35 +0300 Subject: [PATCH] docs: consumer-facing pin mentions follow the 2.0.32 pin, and a repository guard keeps them on spec/SNAPSHOT.md's version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/consumer-leg.yml | 8 +- README.md | 2 +- docs/guide/connection-modes.md | 7 +- docs/guide/requests.md | 2 +- .../RepositoryGates/ConsumerPinGuard.cs | 69 +++++++++++++ .../RepositoryGates/ConsumerPinGuardTests.cs | 97 +++++++++++++++++++ 7 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 52ce95776..67a582360 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -57,7 +57,7 @@ body: attributes: label: opencode server version description: The `version` field `Server.GetInfoAsync` returns. - placeholder: 2.0.29 + placeholder: 2.0.32 validations: required: true diff --git a/.github/workflows/consumer-leg.yml b/.github/workflows/consumer-leg.yml index c44d63846..5b6483537 100644 --- a/.github/workflows/consumer-leg.yml +++ b/.github/workflows/consumer-leg.yml @@ -82,10 +82,10 @@ jobs: # spec/SNAPSHOT.md's identity table is the single owner of the pin, and its "Upstream release # tag" row is what `refresh-spec --apply` rewrites. The npm version is that tag without its - # leading "v" - SNAPSHOT.md says so in prose beside the table ("This pin is the release tag - # `v2.0.3`, so `npm install -g @opencode/cli@2.0.3` installs a server built from exactly this - # commit"). The dispatch input overrides it, which is how a candidate build is tried without - # editing the pin. + # leading "v" - SNAPSHOT.md says so in prose beside the table, as it read at the v2.0.3 pin + # ("This pin is the release tag `v2.0.3`, so `npm install -g @opencode/cli@2.0.3` installs a + # server built from exactly this commit"). The dispatch input overrides it, which is how a + # candidate build is tried without editing the pin. - name: Resolve the distributed build version id: cli shell: pwsh diff --git a/README.md b/README.md index 206bb0655..941424d27 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ You also need the `opencode` CLI, from the `@opencode/cli` npm scope. Install th repository pins — later releases usually work, but they are not what the tests run against: ```sh -npm install -g @opencode/cli@2.0.29 +npm install -g @opencode/cli@2.0.32 ``` You do not have to start it. `OpenCodeServer.StartAsync()` in the [quick start](#-quick-start) diff --git a/docs/guide/connection-modes.md b/docs/guide/connection-modes.md index e82139e94..004661990 100644 --- a/docs/guide/connection-modes.md +++ b/docs/guide/connection-modes.md @@ -210,9 +210,10 @@ Console.WriteLine($"opencode {info.ServerInfo.Version} (pid {info.ServerInfo.Pid ``` That bounded info call is the whole validation recipe for *reaching* the server, and it is -deliberately yours to write: the SDK carries no version comparand of its own and no network-timeout -knob yet, so a `CancellationTokenSource` is the honest timeout and your own expectation is the -honest version check. It is liveness only — a server answering status can still have an empty model catalog +deliberately yours to write: the SDK carries no version comparand of its own, and its `NetworkTimeout` +bounds a call that stops making progress (no response headers, or no new body bytes), not the whole +call, so a `CancellationTokenSource` is the honest deadline and your own expectation is the honest +version check. It is liveness only — a server answering status can still have an empty model catalog for a second or two while its plugins activate, which [choosing a model](getting-started.md#choosing-a-model) covers. diff --git a/docs/guide/requests.md b/docs/guide/requests.md index f9002c82a..103a97ac8 100644 --- a/docs/guide/requests.md +++ b/docs/guide/requests.md @@ -254,7 +254,7 @@ Both members take a value above zero and at most `int.MaxValue` milliseconds, or Most generated members say no more than "Gets the action value", because the pinned API document describes no more. Three families need more than their types to use correctly. Where a rule below -comes from upstream's implementation at the pin (`v2.0.29`) rather than from the pinned contract, +comes from upstream's implementation at the pin (`v2.0.32`) rather than from the pinned contract, it says so. ### Session permissions diff --git a/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs new file mode 100644 index 000000000..d8a05ed67 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs @@ -0,0 +1,69 @@ +using System.Globalization; +using System.IO.Abstractions; +using System.Text.RegularExpressions; + +namespace OpenCode.Sdk.Tools.Tests.RepositoryGates; + +/// Protects consumer-facing pin mentions from going stale when spec/SNAPSHOT.md's pinned version changes. +internal sealed partial class ConsumerPinGuard(IFileSystem fileSystem) +{ + private const string Version = @"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?"; + + public async Task> FindAsync(string root) + { + var snapshot = await fileSystem.File.ReadAllTextAsync(fileSystem.Path.Combine(root, "spec/SNAPSHOT.md")); + var tags = ReleaseTag().Matches(snapshot); + if (tags.Count != 1) + { + return [string.Create(CultureInfo.InvariantCulture, + $"spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found {tags.Count} matches.")]; + } + + var expected = tags[0].Groups["version"].Value; + var paths = new[] { fileSystem.Path.Combine(root, "README.md") } + .Concat(fileSystem.Directory.EnumerateFiles(fileSystem.Path.Combine(root, "docs/guide"), "*.md", SearchOption.AllDirectories)) + .Concat(fileSystem.Directory.EnumerateFiles(fileSystem.Path.Combine(root, ".github/ISSUE_TEMPLATE"), "*", SearchOption.AllDirectories)) + .Select(path => fileSystem.Path.GetRelativePath(root, path).Replace('\\', '/')) + .Order(StringComparer.Ordinal); + var stale = new List(); + foreach (var path in paths) + { + var lines = await fileSystem.File.ReadAllLinesAsync(fileSystem.Path.Combine(root, path)); + var serverVersion = false; + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index].Trim(); + if (line.StartsWith("id:", StringComparison.Ordinal)) + { + serverVersion = line is "id: server-version" or "id: \"server-version\"" or "id: 'server-version'"; + } + + foreach (var groups in PinMention().Matches(lines[index]).Select(static match => match.Groups)) + { + if (groups["placeholder"].Success && + (!serverVersion || !path.StartsWith(".github/ISSUE_TEMPLATE/", StringComparison.Ordinal))) + { + continue; + } + + var version = groups["version"].Value; + if (!string.Equals(version, expected, StringComparison.Ordinal)) + { + stale.Add(string.Create(CultureInfo.InvariantCulture, + $"{path}:{index + 1}: consumer pin {version}; expected {expected}.")); + } + } + } + } + + return stale; + } + + [GeneratedRegex(@"^\| Upstream release tag \| `v(?" + Version + @")` \|\r?$", + RegexOptions.Multiline, matchTimeoutMilliseconds: Timeout.Infinite)] + private static partial Regex ReleaseTag(); + + [GeneratedRegex("""(?:\bnpm install -g (?["'])?@opencode/cli@|at the pin \(`v|(?^\s*placeholder:\s*)(?["'])?)(?""" + Version + @")(?(quote)\k)", + RegexOptions.None, matchTimeoutMilliseconds: Timeout.Infinite)] + private static partial Regex PinMention(); +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs new file mode 100644 index 000000000..c01d840bb --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs @@ -0,0 +1,97 @@ +using OpenCode.Sdk.Tools.Tests.RepositoryGates.Support; +using OpenCode.Sdk.Tools.Tests.Support; +using Testably.Abstractions; +using TUnit.Assertions.Enums; + +namespace OpenCode.Sdk.Tools.Tests.RepositoryGates; + +public sealed class ConsumerPinGuardTests +{ + [Test] + public async Task ConsumerPinGuard_Should_Keep_Consumer_Pins_Aligned_With_The_Snapshot() + { + var fileSystem = new RealFileSystem(); + var stale = await new ConsumerPinGuard(fileSystem).FindAsync(new RepositoryRoot(fileSystem).Locate()); + + await Assert.That(stale).IsEmpty(); + } + + [Test] + [Arguments("8.7.6")] + [Arguments("9.8.7")] + public async Task ConsumerPinGuard_Should_Report_Every_Stale_Site_In_Path_Then_Line_Order(string pin) + { + var scenario = new RepositoryGateScenario() + .WithFile("spec/SNAPSHOT.md", $"| Upstream release tag | `v{pin}` |") + .WithFile("docs/guide/nested/Z.md", "at the pin (`v4.0.1`)\nnpm install -g @opencode/cli@3.0.1") + .WithFile("README.md", $"npm install -g @opencode/cli@{pin}\n\nnpm install -g @opencode/cli@2.0.1; npm install -g @opencode/cli@1.0.1") + .WithFile("docs/guide/A.md", $"at the pin (`v{pin}`)\n\n\nat the pin (`v5.0.1`)") + .WithFile(".github/ISSUE_TEMPLATE/nested/bug.yml", "id: package-version\nplaceholder: 7.0.1\nid: server-version\nplaceholder: 6.0.1\nid: os\nplaceholder: 8.0.1") + .WithFile("docs/guide/ignored.txt", "npm install -g @opencode/cli@0.0.1") + .WithFile("docs/adr/history.md", "npm install -g @opencode/cli@0.0.1"); + await scenario.BuildAsync(); + + var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root); + + await Assert.That(stale).IsEquivalentTo( + [$".github/ISSUE_TEMPLATE/nested/bug.yml:4: consumer pin 6.0.1; expected {pin}.", + $"README.md:3: consumer pin 2.0.1; expected {pin}.", + $"README.md:3: consumer pin 1.0.1; expected {pin}.", + $"docs/guide/A.md:4: consumer pin 5.0.1; expected {pin}.", + $"docs/guide/nested/Z.md:1: consumer pin 4.0.1; expected {pin}.", + $"docs/guide/nested/Z.md:2: consumer pin 3.0.1; expected {pin}."], CollectionOrdering.Matching); + } + + [Test] + [Arguments("Observed on `@opencode/cli@1.2.3`.")] + [Arguments("Verified on `@opencode/cli@1.2.3`.")] + [Arguments("confirmed at `v1.2.3`.")] + [Arguments("a 1.2.3 service re-applies its saved env")] + public async Task ConsumerPinGuard_Should_Preserve_Historical_Versions(string evidence) + { + var scenario = new RepositoryGateScenario() + .WithFile("spec/SNAPSHOT.md", "| Upstream release tag | `v8.7.6` |") + .WithFile("README.md", "npm install -g @opencode/cli@8.7.6") + .WithFile("docs/guide/evidence.md", evidence) + .WithFile(".github/ISSUE_TEMPLATE/bug.yml", "id: server-version\nplaceholder: 8.7.6"); + await scenario.BuildAsync(); + + var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root); + + await Assert.That(stale).IsEmpty(); + } + + [Test] + [Arguments("\"")] + [Arguments("'")] + public async Task ConsumerPinGuard_Should_Report_Quoted_Stale_Sites(string quote) + { + var scenario = new RepositoryGateScenario() + .WithFile("spec/SNAPSHOT.md", "| Upstream release tag | `v8.7.6` |") + .WithFile("README.md", $"npm install -g {quote}@opencode/cli@8.7.6{quote}\nnpm install -g {quote}@opencode/cli@2.0.1{quote}") + .WithFile("docs/guide/evidence.md", $"Observed on `@opencode/cli@2.0.15`; {quote}@opencode/cli@1.2.3{quote}.") + .WithFile(".github/ISSUE_TEMPLATE/bug.yml", $"id: {quote}server-version{quote}\nplaceholder: {quote}6.0.1{quote}\nplaceholder: 5.0.1\nid: {quote}package-version{quote}\nplaceholder: {quote}7.0.1{quote}"); + await scenario.BuildAsync(); + + var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root); + + await Assert.That(stale).IsEquivalentTo( + [".github/ISSUE_TEMPLATE/bug.yml:2: consumer pin 6.0.1; expected 8.7.6.", + ".github/ISSUE_TEMPLATE/bug.yml:3: consumer pin 5.0.1; expected 8.7.6.", + "README.md:2: consumer pin 2.0.1; expected 8.7.6."], CollectionOrdering.Matching); + } + + [Test] + [Arguments("", "spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found 0 matches.")] + [Arguments("| Upstream release tag | v8.7.6 |", "spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found 0 matches.")] + [Arguments("| Upstream release tag | `v8.7.6` |\n| Upstream release tag | `v9.8.7` |", "spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found 2 matches.")] + public async Task ConsumerPinGuard_Should_Explain_Invalid_Snapshot_Rows(string snapshot, string expected) + { + var scenario = new RepositoryGateScenario().WithFile("spec/SNAPSHOT.md", snapshot); + await scenario.BuildAsync(); + + var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root); + + await Assert.That(stale).IsEquivalentTo([expected], CollectionOrdering.Matching); + } +}