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
166 changes: 166 additions & 0 deletions .github/scripts/install-zig.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail

if [ "$#" -ne 1 ]; then
echo "usage: $0 <zig-version>" >&2
exit 1
fi

version="$1"

python_bin="${PYTHON:-python3}"
if ! command -v "$python_bin" >/dev/null 2>&1; then
python_bin="python"
fi
if ! command -v "$python_bin" >/dev/null 2>&1; then
echo "python is required to install Zig" >&2
exit 1
fi

runner_os="${RUNNER_OS:-$(uname -s)}"
runner_arch="${RUNNER_ARCH:-$(uname -m)}"

case "$runner_os" in
Linux | linux)
zig_os="linux"
;;
Darwin | macOS)
zig_os="macos"
;;
Windows | MINGW* | MSYS* | CYGWIN*)
zig_os="windows"
;;
*)
echo "unsupported runner OS: $runner_os" >&2
exit 1
;;
esac

case "$runner_arch" in
X64 | x86_64 | amd64)
zig_arch="x86_64"
;;
ARM64 | arm64 | aarch64)
zig_arch="aarch64"
;;
*)
echo "unsupported runner architecture: $runner_arch" >&2
exit 1
;;
esac

host_key="${zig_arch}-${zig_os}"
tool_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nullwatch-zig"
install_dir="${tool_root}/${version}/${host_key}"
zig_bin="zig"
if [ "$zig_os" = "windows" ]; then
zig_bin="zig.exe"
fi

if [ ! -x "${install_dir}/${zig_bin}" ]; then
mkdir -p "$(dirname "$install_dir")"

zig_metadata="$(
"$python_bin" - "$version" "$host_key" <<'PY'
import json
import sys
import urllib.request

version = sys.argv[1]
host_key = sys.argv[2]

with urllib.request.urlopen("https://ziglang.org/download/index.json") as response:
data = json.load(response)

host = data.get(version, {}).get(host_key)
if not host:
raise SystemExit(f"missing Zig download metadata for version={version!r} host={host_key!r}")

archive_url = host.get("tarball") or host.get("zip")
checksum = host.get("shasum") or ""
if not archive_url:
raise SystemExit(f"missing archive URL for version={version!r} host={host_key!r}")

print(archive_url)
print(checksum)
PY
)"

archive_url="$(printf '%s\n' "$zig_metadata" | sed -n '1p')"
expected_sha="$(printf '%s\n' "$zig_metadata" | sed -n '2p')"
if [ -z "$archive_url" ]; then
echo "failed to resolve Zig download URL" >&2
exit 1
fi

archive_name="${archive_url##*/}"
archive_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/zig-archive.XXXXXX")"
archive_path="${archive_dir}/${archive_name}"
extract_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/zig-extract.XXXXXX")"
trap 'rm -rf "$archive_dir"; rm -rf "$extract_dir"' EXIT

curl -fsSL --retry 3 --retry-all-errors "$archive_url" -o "$archive_path"

"$python_bin" - "$archive_path" "$expected_sha" <<'PY'
import hashlib
import sys

path = sys.argv[1]
expected = sys.argv[2].strip().lower()
if not expected:
raise SystemExit(0)

digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)

actual = digest.hexdigest().lower()
if actual != expected:
raise SystemExit(f"checksum mismatch for {path}: expected {expected}, got {actual}")
PY

"$python_bin" - "$archive_path" "$extract_dir" <<'PY'
import pathlib
import sys
import tarfile
import zipfile

archive = pathlib.Path(sys.argv[1])
destination = pathlib.Path(sys.argv[2])
destination.mkdir(parents=True, exist_ok=True)

def ensure_within_destination(relative_name: str) -> None:
target = (destination / relative_name).resolve()
if destination.resolve() not in target.parents and target != destination.resolve():
raise SystemExit(f"archive entry escapes destination: {relative_name}")

if archive.suffix == ".zip":
with zipfile.ZipFile(archive) as handle:
for member in handle.namelist():
ensure_within_destination(member)
handle.extractall(destination)
else:
with tarfile.open(archive, "r:*") as handle:
for member in handle.getnames():
ensure_within_destination(member)
handle.extractall(destination)
PY

extracted_dir="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [ -z "$extracted_dir" ]; then
echo "failed to extract Zig archive: $archive_url" >&2
exit 1
fi

rm -rf "$install_dir"
mv "$extracted_dir" "$install_dir"
fi

if [ -n "${GITHUB_PATH:-}" ]; then
printf '%s\n' "$install_dir" >> "$GITHUB_PATH"
else
echo "GITHUB_PATH is not set; add this directory to PATH manually: $install_dir" >&2
fi

"${install_dir}/${zig_bin}" version
16 changes: 9 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
name: CI

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
ZIG_VERSION: "0.16.0"

on:
push:
branches: [main]
Expand Down Expand Up @@ -28,15 +32,13 @@ jobs:
zig_target: x86_64-windows

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Install Zig 0.15.2
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Zig 0.16.0
run: bash .github/scripts/install-zig.sh "${ZIG_VERSION}"

- name: Cache Zig build outputs
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
.zig-cache
Expand Down Expand Up @@ -84,7 +86,7 @@ jobs:

- name: Upload binary
if: success()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: nullwatch-${{ matrix.target }}
path: ci-artifacts/nullwatch${{ runner.os == 'Windows' && '.exe' || '' }}
20 changes: 11 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
name: Release

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
ZIG_VERSION: "0.16.0"

on:
push:
tags: ['v*']
Expand Down Expand Up @@ -45,12 +49,10 @@ jobs:
ext: ".exe"

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Install Zig 0.15.2
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Zig 0.16.0
run: bash .github/scripts/install-zig.sh "${ZIG_VERSION}"

- name: Resolve build version
id: version
Expand All @@ -65,7 +67,7 @@ jobs:
run: zig build -Doptimize=ReleaseSmall -Dversion=${{ steps.version.outputs.value }} -Dtarget=${{ matrix.zig_target }}

- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: nullwatch-${{ matrix.target }}
path: zig-out/bin/nullwatch${{ matrix.ext }}
Expand All @@ -78,7 +80,7 @@ jobs:
shell: bash

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Create source archive
run: |
Expand All @@ -92,7 +94,7 @@ jobs:
echo "ARCHIVE_NAME=${archive_name}" >> "$GITHUB_ENV"

- name: Upload source archive
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: nullwatch-source
path: ${{ env.ARCHIVE_NAME }}
Expand All @@ -105,7 +107,7 @@ jobs:
contents: write

steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8

- name: Rename binaries
run: |
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.name = .nullwatch,
.version = "2026.3.16",
.fingerprint = 0x331185e774b4ae7a,
.minimum_zig_version = "0.15.2",
.minimum_zig_version = "0.16.0",
.paths = .{
".github",
"build.zig",
Expand Down
12 changes: 5 additions & 7 deletions src/api.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const std = @import("std");
const std_compat = @import("compat.zig");
const domain = @import("domain.zig");
const Store = @import("store.zig").Store;
const version = @import("version.zig");
Expand Down Expand Up @@ -365,14 +366,14 @@ fn ingestOtlpScopeSpans(
const run_id = (try firstOtlpAttributeText(ctx.allocator, attrs, &.{ "nullwatch.run_id", "nulltickets.run_id", "run_id" })) orelse
(try firstOtlpAttributeText(ctx.allocator, resource_attributes, &.{ "nullwatch.run_id", "nulltickets.run_id", "run_id" })) orelse
trace_id;
const source = (try firstOtlpAttributeText(ctx.allocator, resource_attributes, &.{ "service.name" })) orelse
const source = (try firstOtlpAttributeText(ctx.allocator, resource_attributes, &.{"service.name"})) orelse
scope_name orelse
"otlp";
const operation = span.name orelse "unnamed";
const started_at_ms = parseUnixNanoMs(span.startTimeUnixNano) orelse std.time.milliTimestamp();
const started_at_ms = parseUnixNanoMs(span.startTimeUnixNano) orelse std_compat.time.milliTimestamp();
const ended_at_ms = parseUnixNanoMs(span.endTimeUnixNano);
const attributes_json = try otlpAttributesJson(ctx.allocator, attrs);
const success_text = try firstOtlpAttributeText(ctx.allocator, attrs, &.{ "success" });
const success_text = try firstOtlpAttributeText(ctx.allocator, attrs, &.{"success"});
const status = determineOtlpStatus(span, success_text);
const error_message = if (span.status) |status_payload|
status_payload.message orelse (try firstOtlpAttributeText(ctx.allocator, attrs, &.{ "error_message", "message", "detail" }))
Expand Down Expand Up @@ -626,10 +627,7 @@ fn jsonResponse(allocator: std.mem.Allocator, status_code: u16, value: anytype)
}

fn encodeJson(allocator: std.mem.Allocator, value: anytype) ![]u8 {
var out = std.io.Writer.Allocating.init(allocator);
defer out.deinit();
try std.json.Stringify.value(value, .{}, &out.writer);
return try out.toOwnedSlice();
return try std.json.Stringify.valueAlloc(allocator, value, .{});
}

fn statusTextFromCode(status_code: u16) []const u8 {
Expand Down
Loading