Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/consumer-leg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions docs/guide/connection-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Globalization;
using System.IO.Abstractions;
using System.Text.RegularExpressions;

namespace OpenCode.Sdk.Tools.Tests.RepositoryGates;

/// <summary>Protects consumer-facing pin mentions from going stale when spec/SNAPSHOT.md's pinned version changes.</summary>
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<IReadOnlyList<string>> 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<string>();
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>" + Version + @")` \|\r?$",
RegexOptions.Multiline, matchTimeoutMilliseconds: Timeout.Infinite)]
private static partial Regex ReleaseTag();

[GeneratedRegex("""(?:\bnpm install -g (?<quote>["'])?@opencode/cli@|at the pin \(`v|(?<placeholder>^\s*placeholder:\s*)(?<quote>["'])?)(?<version>""" + Version + @")(?(quote)\k<quote>)",
RegexOptions.None, matchTimeoutMilliseconds: Timeout.Infinite)]
private static partial Regex PinMention();
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading