Skip to content

Commit c5569fc

Browse files
committed
Stabilize exact CLI release qualification
1 parent bebf488 commit c5569fc

4 files changed

Lines changed: 65 additions & 34 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,17 @@ jobs:
3636
dotnet-version: ${{ env.DOTNET_VERSION }}
3737

3838
- name: Install Claude Code CLI
39-
run: npm install --global @anthropic-ai/claude-code
39+
shell: bash
40+
run: |
41+
set -euo pipefail
42+
target_version="$(sed -nE 's/^[[:space:]]*public const string TargetVersion = "([0-9]+\.[0-9]+\.[0-9]+)";$/\1/p' ClaudeCodeSharpSDK/Models/ClaudeCliCompatibility.cs)"
43+
[[ -n "$target_version" && "$target_version" != *$'\n'* ]] || { echo "Expected exactly one three-part Claude CLI TargetVersion."; exit 1; }
44+
npm install --global "@anthropic-ai/claude-code@$target_version"
45+
version="$(claude --version)"
46+
printf '%s\n' "$version"
47+
actual_version="$(printf '%s\n' "$version" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sed -n '1p')"
48+
printf 'Installed Claude CLI version: %s\n' "$actual_version"
49+
[[ "$actual_version" == "$target_version" ]]
4050
4151
- name: Verify Claude Code CLI
4252
run: claude --version

‎.github/workflows/claude-cli-smoke.yml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,17 @@ jobs:
3838
dotnet-version: ${{ env.DOTNET_VERSION }}
3939

4040
- name: Install Claude Code CLI
41-
run: npm install --global @anthropic-ai/claude-code
41+
shell: bash
42+
run: |
43+
set -euo pipefail
44+
target_version="$(sed -nE 's/^[[:space:]]*public const string TargetVersion = "([0-9]+\.[0-9]+\.[0-9]+)";$/\1/p' ClaudeCodeSharpSDK/Models/ClaudeCliCompatibility.cs)"
45+
[[ -n "$target_version" && "$target_version" != *$'\n'* ]] || { echo "Expected exactly one three-part Claude CLI TargetVersion."; exit 1; }
46+
npm install --global "@anthropic-ai/claude-code@$target_version"
47+
version="$(claude --version)"
48+
printf '%s\n' "$version"
49+
actual_version="$(printf '%s\n' "$version" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sed -n '1p')"
50+
printf 'Installed Claude CLI version: %s\n' "$actual_version"
51+
[[ "$actual_version" == "$target_version" ]]
4252
4353
- name: Verify Claude Code CLI
4454
run: claude --version

‎.github/workflows/release.yml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,17 @@ jobs:
8787
node-version: ${{ env.NODE_VERSION }}
8888

8989
- name: Install Claude Code CLI
90-
run: npm install --global @anthropic-ai/claude-code
90+
shell: bash
91+
run: |
92+
set -euo pipefail
93+
target_version="$(sed -nE 's/^[[:space:]]*public const string TargetVersion = "([0-9]+\.[0-9]+\.[0-9]+)";$/\1/p' ClaudeCodeSharpSDK/Models/ClaudeCliCompatibility.cs)"
94+
[[ -n "$target_version" && "$target_version" != *$'\n'* ]] || { echo "Expected exactly one three-part Claude CLI TargetVersion."; exit 1; }
95+
npm install --global "@anthropic-ai/claude-code@$target_version"
96+
version="$(claude --version)"
97+
printf '%s\n' "$version"
98+
actual_version="$(printf '%s\n' "$version" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sed -n '1p')"
99+
printf 'Installed Claude CLI version: %s\n' "$actual_version"
100+
[[ "$actual_version" == "$target_version" ]]
91101
92102
- name: Verify Claude Code CLI
93103
run: claude --version

‎ClaudeCodeSharpSDK.Tests/Unit/CliInstallationTests.cs‎

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,29 @@ public sealed class CliInstallationTests
8585
fs.writeFileSync(shimPath, shim);
8686
if (process.platform !== 'win32') fs.chmodSync(shimPath, 0o755);
8787
if (fs.existsSync(path.join(__dirname, 'pipe-holder'))) {
88-
const { spawnSync } = require('node:child_process');
89-
const shellCommand = `setsid /bin/sh -c 'echo $$ > ${shellQuote(path.join(__dirname, 'pipe-holder.pid'))}; exec /bin/sleep 60' holder &`;
90-
spawnSync('/bin/sh', ['-c', shellCommand], { stdio: 'inherit' });
88+
const { spawn } = require('node:child_process');
89+
const childScript = 'printf "%s" "$$" > "$1.tmp" && mv "$1.tmp" "$1" && exec /bin/sleep 60';
90+
const holder = spawn('setsid', ['/bin/sh', '-c', childScript, 'holder',
91+
path.join(__dirname, 'pipe-holder.pid')], { detached: true, stdio: 'inherit' });
92+
holder.unref();
9193
}
9294
if (fs.existsSync(path.join(__dirname, 'hang'))) {
93-
fs.writeFileSync(path.join(__dirname, 'child.pid'), String(process.pid));
95+
writeProcessId(path.join(__dirname, 'child.pid'));
9496
setInterval(() => {}, 1000);
9597
}
9698
if (fs.existsSync(path.join(__dirname, 'overflow'))) {
9799
process.stdout.write('x'.repeat(8192));
98100
setInterval(() => {}, 1000);
99101
}
100-
fs.writeFileSync(path.join(__dirname, 'child.pid'), String(process.pid));
102+
writeProcessId(path.join(__dirname, 'child.pid'));
101103
console.log('installation output');
102104
console.error('installation diagnostics');
105+
106+
function writeProcessId(filePath) {
107+
const temporaryPath = filePath + '.tmp';
108+
fs.writeFileSync(temporaryPath, String(process.pid));
109+
fs.renameSync(temporaryPath, filePath);
110+
}
103111
""";
104112

105113
[Test]
@@ -185,9 +193,7 @@ public async Task InstallOrUpdate_ReportsFinalOutputWhenConsumerResumesAfterProc
185193
await Assert.That(updates.Current.Stage).IsEqualTo(CliInstallationStage.PackageManagerStarted);
186194
var childPidPath = Path.Combine(fixture.NpmRoot, BinDirectoryName, ChildPidFileName);
187195
using var markerTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
188-
await WaitForFileAsync(childPidPath, markerTimeout.Token);
189-
var childPid = int.Parse(await File.ReadAllTextAsync(childPidPath),
190-
System.Globalization.CultureInfo.InvariantCulture);
196+
var childPid = await WaitForProcessIdAsync(childPidPath, markerTimeout.Token);
191197
await WaitForProcessExitAsync(childPid, markerTimeout.Token);
192198

193199
var sawFinalOutput = false;
@@ -228,9 +234,7 @@ public async Task InstallOrUpdate_EnforcesTimeoutWhileConsumerIsPausedAfterStart
228234
await Assert.That(updates.Current.Stage).IsEqualTo(CliInstallationStage.PackageManagerStarted);
229235

230236
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
231-
await WaitForFileAsync(childPidPath, deadline.Token);
232-
var childPid = int.Parse(await File.ReadAllTextAsync(childPidPath),
233-
System.Globalization.CultureInfo.InvariantCulture);
237+
var childPid = await WaitForProcessIdAsync(childPidPath, deadline.Token);
234238
await WaitForProcessExitAsync(childPid, deadline.Token);
235239
await Assert.That(IsProcessRunning(childPid)).IsFalse();
236240

@@ -275,12 +279,8 @@ await File.WriteAllTextAsync(Path.Combine(fixture.NpmRoot, BinDirectoryName, Pip
275279
fixture.LocalApplicationDataRoot, CancellationToken.None).GetAsyncEnumerator();
276280
await Assert.That(await updates.MoveNextAsync()).IsTrue();
277281
using var markerTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
278-
await WaitForFileAsync(rootPidFile, markerTimeout.Token);
279-
await WaitForFileAsync(holderPidFile, markerTimeout.Token);
280-
var rootPid = int.Parse(await File.ReadAllTextAsync(rootPidFile),
281-
System.Globalization.CultureInfo.InvariantCulture);
282-
holderPid = int.Parse(await File.ReadAllTextAsync(holderPidFile),
283-
System.Globalization.CultureInfo.InvariantCulture);
282+
var rootPid = await WaitForProcessIdAsync(rootPidFile, markerTimeout.Token);
283+
holderPid = await WaitForProcessIdAsync(holderPidFile, markerTimeout.Token);
284284
await WaitForProcessExitAsync(rootPid, markerTimeout.Token);
285285
await Assert.That(IsProcessRunning(rootPid)).IsFalse();
286286
await Assert.That(IsProcessRunning(holderPid)).IsTrue();
@@ -354,12 +354,7 @@ public async Task InstallOrUpdate_CancellationStopsAndJoinsPackageManager()
354354
await Assert.That(updates.Current.Stage).IsEqualTo(CliInstallationStage.PackageManagerStarted);
355355
var childPidPath = Path.Combine(fixture.NpmRoot, BinDirectoryName, ChildPidFileName);
356356
using var markerTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
357-
while (!File.Exists(childPidPath))
358-
{
359-
await Task.Delay(TimeSpan.FromMilliseconds(10), markerTimeout.Token);
360-
}
361-
362-
var childPid = int.Parse(await File.ReadAllTextAsync(childPidPath), System.Globalization.CultureInfo.InvariantCulture);
357+
var childPid = await WaitForProcessIdAsync(childPidPath, markerTimeout.Token);
363358
var competingOptions = fixture.Options with { InstallationLockTimeout = TimeSpan.FromMilliseconds(100) };
364359
var lockException = await Assert.That(async () =>
365360
await RunInstallationAsync(fixture, competingOptions)).ThrowsException();
@@ -413,12 +408,8 @@ public async Task CancellationWithDetachedPipeHolderSurfacesUnconfirmedCleanup()
413408
fixture.LocalApplicationDataRoot, cancellation.Token).GetAsyncEnumerator();
414409
await Assert.That(await updates.MoveNextAsync()).IsTrue();
415410
using var markerTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
416-
await WaitForFileAsync(packageManagerPidFile, markerTimeout.Token);
417-
await WaitForFileAsync(holderPidFile, markerTimeout.Token);
418-
var packageManagerPid = int.Parse(await File.ReadAllTextAsync(packageManagerPidFile),
419-
System.Globalization.CultureInfo.InvariantCulture);
420-
holderProcessId = int.Parse(await File.ReadAllTextAsync(holderPidFile),
421-
System.Globalization.CultureInfo.InvariantCulture);
411+
var packageManagerPid = await WaitForProcessIdAsync(packageManagerPidFile, markerTimeout.Token);
412+
holderProcessId = await WaitForProcessIdAsync(holderPidFile, markerTimeout.Token);
422413
await Assert.That(IsProcessRunning(packageManagerPid)).IsTrue();
423414
await Assert.That(IsProcessRunning(holderProcessId)).IsTrue();
424415

@@ -519,10 +510,20 @@ private static string FindExecutablePath(string executableName)
519510
return Path.GetFullPath(executable ?? throw new InvalidOperationException(SetsidRequiredMessage));
520511
}
521512

522-
private static async Task WaitForFileAsync(string path, CancellationToken cancellationToken)
513+
private static async Task<int> WaitForProcessIdAsync(string path, CancellationToken cancellationToken)
523514
{
524-
while (!File.Exists(path))
515+
while (true)
525516
{
517+
if (File.Exists(path))
518+
{
519+
var contents = await File.ReadAllTextAsync(path, cancellationToken);
520+
if (int.TryParse(contents, System.Globalization.CultureInfo.InvariantCulture, out var processId) &&
521+
processId > 0)
522+
{
523+
return processId;
524+
}
525+
}
526+
526527
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken);
527528
}
528529
}

0 commit comments

Comments
 (0)