Skip to content
Open
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
92 changes: 50 additions & 42 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,57 @@ env:
IMAGE_NAME: ${{ github.repository }}

jobs:
test:
name: Build image and run HTTP-level tests
build-test-push:
name: Build image, run tests, then push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4

# A step output, not an env var: a job-wide `VERSION` env var is imported by MSBuild as the
# $(Version) property and breaks `dotnet test` ('v<date>' is not a valid version string).
- name: Set version tag
id: version
run: echo "value=v$(date --rfc-3339=date)" >> "${GITHUB_OUTPUT}"

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# For a pull request this produces the tag `pr-<number>`, so the image can be pulled and
# tested in a real environment: `docker pull ghcr.io/<repo>:pr-<number>`.
tags: |
type=ref,event=branch
type=ref,event=pr
type=raw,value=${{ steps.version.outputs.value }},enable=${{ github.event_name != 'pull_request' }}

- name: Build image for tests (amd64, loaded into docker)
uses: docker/build-push-action@v6
# Build the amd64 image and load it into the local Docker daemon so the test fixtures can run it
# as a container (a multi-arch image can't be loaded/run directly). The multi-arch push step below
# reuses this build's cache, so amd64 is not compiled twice.
- name: Build and export to Docker (amd64)
uses: docker/build-push-action@v7
with:
context: csharp
file: csharp/Dockerfile
load: true
platforms: linux/amd64
tags: hgresume-csharp:test
cache-to: type=gha,mode=max
cache-from: type=gha

- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

Expand All @@ -48,55 +78,33 @@ jobs:
HGRESUME_PORT: '8034'
run: dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal"

build:
name: Build and push docker image to GitHub packages
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set Version
run: |
echo "VERSION=v$(date --rfc-3339=date)" >> ${GITHUB_ENV}

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Run send/receive tests (real Chorus client) against the image
working-directory: csharp
env:
HGRESUME_PODMAN: docker
HGRESUME_IMAGE: hgresume-csharp:test
HGRESUME_SKIP_BUILD: '1'
HGRESUME_PORT: '8041'
run: dotnet test test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj --logger "console;verbosity=normal"

- name: Log in to the Container registry
# Fork PRs get a read-only GITHUB_TOKEN; skip login/push so the job still builds without a 403.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# For a pull request this produces the tag `pr-<number>`, so the image can be pulled and
# tested in a real environment: `docker pull ghcr.io/<repo>:pr-<number>`.
tags: |
type=ref,event=branch
type=ref,event=pr
type=raw,value=${{ env.VERSION }},enable=${{ github.event_name != 'pull_request' }}

# Multi-arch build + push. amd64 layers come from the cache populated above (no recompile); only
# arm64 is built here. Push is gated the same way as login.
- name: Build and push Docker image (C#)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: csharp
file: csharp/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
30 changes: 17 additions & 13 deletions csharp/src/HgResume.Api/AsyncRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,15 @@ public void Run(string workingDir, string program, params string[] args)
sb.Append($"\nCommand exited with non-zero status {proc.ExitCode}\n");
}
sb.Append($"\n{CompletedMarker}: done\n");
File.WriteAllText(lockFile, sb.ToString());
// CancellationToken.None: this background write must survive the originating request.
await File.WriteAllTextAsync(lockFile, sb.ToString(), CancellationToken.None).ConfigureAwait(false);
}
catch (Exception e)
{
// Ensure a completion marker is always written so pollers do not hang forever.
File.WriteAllText(lockFile, $"AsyncRunner error: {e.Message}\n{CompletedMarker}: error\n");
await File.WriteAllTextAsync(lockFile,
$"AsyncRunner error: {e.Message}\n{CompletedMarker}: error\n", CancellationToken.None)
.ConfigureAwait(false);
}
finally
{
Expand All @@ -95,22 +98,22 @@ public void Run(string workingDir, string program, params string[] args)

public bool IsRunning() => File.Exists(_lockFile);

public bool IsComplete()
public async Task<bool> IsCompleteAsync(CancellationToken ct = default)
{
if (!File.Exists(_lockFile))
{
throw new AsyncRunnerException($"Lock file '{_lockFile}' not found, process is not running");
}
return ReadLockFile().Contains(CompletedMarker);
return (await ReadLockFileAsync(ct)).Contains(CompletedMarker);
}

public string GetOutput()
public async Task<string> GetOutputAsync(CancellationToken ct = default)
{
if (!IsComplete())
if (!await IsCompleteAsync(ct))
{
throw new AsyncRunnerException($"Command on '{_lockFile}' not yet complete.");
}
return ReadLockFile();
return await ReadLockFileAsync(ct);
}

public void CleanUp()
Expand All @@ -119,21 +122,22 @@ public void CleanUp()
}

/// <summary>Waits up to ~5s for the runner to complete. Mirrors PHP waitForIsComplete().</summary>
public bool WaitForIsComplete()
public async Task<bool> WaitForIsCompleteAsync(CancellationToken ct = default)
{
for (int i = 0; i < 5; i++)
{
if (IsComplete()) return true;
Thread.Sleep(1000);
if (await IsCompleteAsync(ct)) return true;
await Task.Delay(1000, ct);
}
return false;
}

private string ReadLockFile()
private async Task<string> ReadLockFileAsync(CancellationToken ct = default)
{
// Share read/write so we never contend with the background writer.
using var fs = new FileStream(_lockFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await using var fs = new FileStream(_lockFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
bufferSize: 4096, FileOptions.Asynchronous);
using var reader = new StreamReader(fs, Encoding.UTF8);
return reader.ReadToEnd();
return await reader.ReadToEndAsync(ct);
}
}
Loading