Compare commits

..

4 Commits

Author SHA1 Message Date
localai-org-maint-bot
6e485bb251 Merge branch 'master' into feat/audio-cpp-backend-v2 2026-07-29 15:04:21 +02:00
localai-org-maint-bot
7f97c234ac fix(audio-cpp): keep runtime test out of standalone suite
The runtime test needs the audio runtime library and is built by its own CMake target. Avoid the generic *_test.cpp discovery contract, which only supports pure standalone translation units.

Assisted-by: Codex:gpt-5 [systematic-debugging]
2026-07-29 09:06:44 +00:00
Ettore Di Giacinto
bc1965ad0b feat(audio-cpp): implement runtime lifecycle
Add protocol-neutral model configuration and runtime ownership against the pinned audio.cpp interfaces. Validate task capabilities, preserve the active model on replacement failures, serialize inference calls, and guarantee session-first teardown.

Assisted-by: Codex:gpt-5
2026-07-29 09:06:44 +00:00
Ettore Di Giacinto
034ed4223c feat(audio-cpp): scaffold native build contract
Pin and fetch audio.cpp, generate the LocalAI gRPC protocol sources, and propagate the upstream accelerator switches into the engine_runtime-linked server target.

Add a fixture-only contract test covering CPU, CUDA, Vulkan, Metal, legacy option rejection, and uname-based Darwin selection.

Assisted-by: Codex:gpt-5
2026-07-29 09:06:44 +00:00
14 changed files with 1257 additions and 832 deletions

View File

@@ -195,24 +195,6 @@ concurrency:
- **PR events** group by PR number → newer pushes to the same PR cancel old runs (intended).
- **Push events** group by `github.sha` → each master commit gets its own run; rapid-fire merges don't cancel each other (this was a real issue prior — two master pushes 11 seconds apart would cancel the first's CI).
### Consequence: builds finish out of commit order
Because no master run supersedes another, and because the backend queue routinely runs hours deep (measured 259.7 min average queue wait against 18.6 min average execution), **completion order does not track commit order**. A build of an older commit can finish long after a newer one.
That used to move mutable tags backwards. On 19 Jul 2026 a build of commit `10211948b` (pushed 06:45 UTC) finished at 15:40 UTC and overwrote `master-nvidia-l4t-cuda-13-arm64-longcat-video`, which a 10:41 UTC build of `626ae4d51` had already advanced to a commit containing a merged cuDNN packaging fix. Everyone pulling `master-*` got the pre-fix image for two days, hitting `CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH` at inference.
## Mutable tag ordering guard
`scripts/tag-guard.mjs` (logic in `scripts/lib/tag-guard.mjs`, tests in `scripts/lib/tag-guard_test.mjs`, run by `make test-ci-scripts`) refuses to advance a mutable tag unless the incoming commit descends from the one the tag currently points at.
- **Tag classes**: `sha-<commit>-<suffix>` is immutable and *always* publishes: it is how a human pins a known-good artifact, and it is how the incident above was worked around. `master-*`, `latest-*` and `v<version>-*` are mutable and go through the guard.
- **How it decides**: reads `org.opencontainers.image.revision` off whatever the mutable tag currently points at (docker/metadata-action already stamps this at build time on the Linux path), then asks the GitHub compare API how the incoming commit relates to it. `ahead`/`identical` publish; `behind`/`diverged` are withheld.
- **Why the compare API and not `git merge-base --is-ancestor`**: the merge and darwin-publish jobs use shallow sparse checkouts. Fetching full history into each of the ~200 merge jobs per push to answer one ancestry question is not worth it.
- **Fail-open, loudly**: a tag that doesn't exist yet, an image with no revision label, an unreachable commit, or a registry/API error all publish anyway with a `::warning::` annotation and a job-summary line. A guard that failed closed on an API blip would itself stop shipping merged fixes. Every non-trivial decision is annotated; silence is the bug being fixed.
- **Where it runs**: `backend_merge.yml` (both the quay and Docker Hub `imagetools create` steps, which are the multi-arch manifest merge *and* the tagging step for single-arch backends, since `backend_build.yml` pushes by digest only) and `backend_build_darwin.yml`'s publish job.
- **Darwin caveat**: darwin images are `crane push`ed from a raw OCI tarball and carried no labels at all, so the publish job now stamps the revision with `crane mutate` after pushing. Until each darwin tag has gone through that step once, the guard has nothing to compare and fails open with a warning.
- **Release tags**: `v*` tags go through the guard too. A fresh version tag has never been published, so it hits the "tag does not exist yet" path and always publishes. The guard costs nothing there, and it does protect `latest-*` if an old release build is ever re-run.
## Self-warming, no separate populator
There is no cron job that pre-warms the BuildKit cache for individual backends. The production builds *are* the populators. The first master build of a given matrix entry pays the cold cost; subsequent same-entry master builds reuse everything that hasn't changed (apt installs, gRPC compile in the variant `builder-fromsource` stage or skipped entirely when consuming `base-grpc-*`, Python wheel installs, etc.). The base-images workflow's weekly cron is the closest thing to a populator and only refreshes the prebuilt builder bases.

View File

@@ -281,15 +281,6 @@ jobs:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
# Sparse checkout: the publish job needs `scripts/` for the mutable-tag
# ordering guard, nothing else from the source tree.
- name: Checkout (scripts only)
uses: actions/checkout@v7
with:
sparse-checkout: |
scripts
sparse-checkout-cone-mode: false
- name: Download ${{ inputs.backend }}.tar
uses: actions/download-artifact@v8
with:
@@ -337,47 +328,14 @@ jobs:
latest=auto
suffix=${{ inputs.tag-suffix }},onlatest=true
# Mutable tags (master-*, latest-*, v*-*) must never move backwards: a
# straggler build of an older commit finishing after a newer one would
# silently un-ship whatever the newer one fixed. See
# scripts/lib/tag-guard.mjs. Immutable sha-* tags always publish.
#
# Darwin images are pushed as a raw OCI tarball, so unlike the Linux path
# (where docker/metadata-action bakes its labels in at build time) they
# carry no org.opencontainers.image.revision label for the guard to read.
# `crane mutate` stamps it after the push. Until each tag has been
# republished once through this step the guard has nothing to compare and
# fails open with a warning, which is the pre-existing behaviour.
#
# DOCKER_METADATA_OUTPUT_JSON is set per step rather than inherited: this
# job runs docker/metadata-action twice, so the ambient env var only ever
# holds the last one's output.
- name: Push Docker image (DockerHub)
env:
REGISTRY_PREFIX: 'localai/'
GITHUB_TOKEN: ${{ github.token }}
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
run: |
set -euo pipefail
node scripts/tag-guard.mjs > "${RUNNER_TEMP}/allowed-hub-tags.txt"
while read -r tag; do
[ -n "$tag" ] || continue
crane push "${{ inputs.backend }}.tar" "$tag"
crane mutate "$tag" -t "$tag" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
done < "${RUNNER_TEMP}/allowed-hub-tags.txt"
for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr ',' '\n'); do
crane push ${{ inputs.backend }}.tar $tag
done
- name: Push Docker image (Quay)
env:
REGISTRY_PREFIX: 'quay.io/'
GITHUB_TOKEN: ${{ github.token }}
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.quaymeta.outputs.json }}
run: |
set -euo pipefail
node scripts/tag-guard.mjs > "${RUNNER_TEMP}/allowed-quay-tags.txt"
while read -r tag; do
[ -n "$tag" ] || continue
crane push "${{ inputs.backend }}.tar" "$tag"
crane mutate "$tag" -t "$tag" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
done < "${RUNNER_TEMP}/allowed-quay-tags.txt"
for tag in $(echo "${{ steps.quaymeta.outputs.tags }}" | tr ',' '\n'); do
crane push ${{ inputs.backend }}.tar $tag
done

View File

@@ -47,14 +47,12 @@ jobs:
COSIGN_EXPERIMENTAL: '1'
steps:
# Sparse checkout: the merge job needs `.github/scripts/` (for the
# keepalive cleanup script) and `scripts/` (for the mutable-tag ordering
# guard) but none of the source tree.
- name: Checkout (scripts only)
# keepalive cleanup script) but none of the source tree.
- name: Checkout (.github/scripts only)
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts
scripts
sparse-checkout-cone-mode: false
# `--` separator anchors the glob so we don't over-match sibling
@@ -131,44 +129,30 @@ jobs:
# manifest list with the user-facing tags. The resulting manifest
# list is fully self-contained in local-ai-backends — child digests
# only, no embedded references to ci-cache.
#
# Mutable tags (master-*, latest-*, v*-*) must never move backwards.
# Backend CI queues run hours deep and master pushes get a concurrency
# group keyed by github.sha, so a straggler build of an older commit can
# finish after a newer one and silently un-ship a merged fix (measured on
# 19 Jul 2026, see scripts/lib/tag-guard.mjs). The guard drops any mutable
# tag whose published image was built from a commit this one does not
# descend from; immutable sha-* tags always survive it.
- name: Create manifest list and push (quay)
if: github.event_name != 'pull_request'
working-directory: /tmp/digests
env:
REGISTRY_PREFIX: 'quay.io/'
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Via a file, not `mapfile < <(...)`: process substitution hides the
# exit status, so a crashing guard would look like "no tags" and
# skip the merge silently.
node "$GITHUB_WORKSPACE/scripts/tag-guard.mjs" > "${RUNNER_TEMP}/allowed-quay-tags.txt"
mapfile -t allowed < "${RUNNER_TEMP}/allowed-quay-tags.txt"
if [ "${#allowed[@]}" -eq 0 ] || [ -z "${allowed[0]}" ]; then
echo "No publishable quay.io tags; skipping quay merge"
tags=$(jq -cr '
.tags
| map(select(startswith("quay.io/")))
| map("-t " + .)
| join(" ")
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
if [ -z "$tags" ]; then
echo "No quay.io tags from docker/metadata-action; skipping quay merge"
exit 0
fi
tags=()
for t in "${allowed[@]}"; do
tags+=(-t "$t")
done
# shellcheck disable=SC2046
docker buildx imagetools create "${tags[@]}" \
# shellcheck disable=SC2086
docker buildx imagetools create $tags \
$(printf 'quay.io/go-skynet/ci-cache@sha256:%s ' *)
# Resolve the manifest-list digest (any tag points at it) so
# cosign can sign by digest. Signing by tag would leave the
# signature orphaned the next time the tag moves. The guard emits
# immutable sha-* tags first, so this is the per-commit tag whenever
# one exists.
first_tag="${allowed[0]}"
# signature orphaned the next time the tag moves.
first_tag=$(jq -cr '
.tags | map(select(startswith("quay.io/"))) | .[0]
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')
# --recursive walks the list and signs every per-arch entry
# too — clients that resolve a tag to a platform-specific
@@ -181,25 +165,24 @@ jobs:
- name: Create manifest list and push (dockerhub)
if: github.event_name != 'pull_request'
working-directory: /tmp/digests
env:
REGISTRY_PREFIX: 'localai/'
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
node "$GITHUB_WORKSPACE/scripts/tag-guard.mjs" > "${RUNNER_TEMP}/allowed-hub-tags.txt"
mapfile -t allowed < "${RUNNER_TEMP}/allowed-hub-tags.txt"
if [ "${#allowed[@]}" -eq 0 ] || [ -z "${allowed[0]}" ]; then
echo "No publishable dockerhub tags; skipping dockerhub merge"
tags=$(jq -cr '
.tags
| map(select(startswith("localai/")))
| map("-t " + .)
| join(" ")
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
if [ -z "$tags" ]; then
echo "No dockerhub tags from docker/metadata-action; skipping dockerhub merge"
exit 0
fi
tags=()
for t in "${allowed[@]}"; do
tags+=(-t "$t")
done
# shellcheck disable=SC2046
docker buildx imagetools create "${tags[@]}" \
# shellcheck disable=SC2086
docker buildx imagetools create $tags \
$(printf 'localai/localai-backends@sha256:%s ' *)
first_tag="${allowed[0]}"
first_tag=$(jq -cr '
.tags | map(select(startswith("localai/"))) | .[0]
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')
cosign sign --yes --recursive \
--registry-referrers-mode=oci-1-1 \

View File

@@ -0,0 +1,95 @@
# SPDX-License-Identifier: MIT
cmake_minimum_required(VERSION 3.20)
project(audio-cpp-grpc-server LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(AUDIO_CPP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/audio.cpp"
CACHE PATH "Path to the audio.cpp source tree")
set(LOCALAI_BACKEND_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/../../backend.proto"
CACHE FILEPATH "Path to the LocalAI backend protocol")
option(ENGINE_ENABLE_CUDA "Build audio.cpp with CUDA support" OFF)
option(ENGINE_ENABLE_VULKAN "Build audio.cpp with Vulkan support" OFF)
option(ENGINE_ENABLE_METAL "Build audio.cpp with Metal support" OFF)
option(AUDIO_CPP_BUILD_TESTS "Build LocalAI audio.cpp unit tests" OFF)
option(AUDIO_CPP_BUILD_GRPC "Build the LocalAI gRPC server" ON)
find_package(Threads REQUIRED)
if(NOT EXISTS "${AUDIO_CPP_DIR}/CMakeLists.txt")
message(FATAL_ERROR
"AUDIO_CPP_DIR does not point to an audio.cpp source tree: ${AUDIO_CPP_DIR}")
endif()
add_subdirectory("${AUDIO_CPP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/audio.cpp")
add_library(localai_audio_cpp_runtime STATIC
audio_cpp_runtime.cpp
model_config.cpp)
target_include_directories(localai_audio_cpp_runtime
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(localai_audio_cpp_runtime PUBLIC engine_runtime)
if(AUDIO_CPP_BUILD_GRPC)
find_package(Protobuf CONFIG REQUIRED)
find_package(gRPC CONFIG REQUIRED)
find_program(PROTOC_EXECUTABLE NAMES protoc REQUIRED)
find_program(GRPC_CPP_PLUGIN_EXECUTABLE NAMES grpc_cpp_plugin REQUIRED)
get_filename_component(LOCALAI_BACKEND_PROTO_DIR
"${LOCALAI_BACKEND_PROTO}" DIRECTORY)
set(LOCALAI_PROTO_SOURCES
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc"
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
set(LOCALAI_PROTO_HEADERS
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h"
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
add_custom_command(
OUTPUT ${LOCALAI_PROTO_SOURCES} ${LOCALAI_PROTO_HEADERS}
COMMAND "${PROTOC_EXECUTABLE}"
ARGS
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
--grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
-I "${LOCALAI_BACKEND_PROTO_DIR}"
--plugin=protoc-gen-grpc="${GRPC_CPP_PLUGIN_EXECUTABLE}"
"${LOCALAI_BACKEND_PROTO}"
DEPENDS "${LOCALAI_BACKEND_PROTO}"
VERBATIM)
add_library(localai_backend_proto STATIC
${LOCALAI_PROTO_SOURCES}
${LOCALAI_PROTO_HEADERS})
target_include_directories(localai_backend_proto
PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(localai_backend_proto
PUBLIC protobuf::libprotobuf gRPC::grpc++)
# Task 2 replaces this generated entry point with the LocalAI service.
set(AUDIO_CPP_SERVER_PLACEHOLDER
"${CMAKE_CURRENT_BINARY_DIR}/audio-cpp-grpc-server-placeholder.cpp")
file(GENERATE OUTPUT "${AUDIO_CPP_SERVER_PLACEHOLDER}"
CONTENT "int main() { return 0; }\n")
add_executable(audio-cpp-grpc-server "${AUDIO_CPP_SERVER_PLACEHOLDER}")
target_link_libraries(audio-cpp-grpc-server PRIVATE
localai_audio_cpp_runtime
engine_runtime
localai_backend_proto
gRPC::grpc++
gRPC::grpc++_reflection)
endif()
if(AUDIO_CPP_BUILD_TESTS)
enable_testing()
add_executable(audio-cpp-runtime-test tests/runtime_tests.cpp)
target_link_libraries(audio-cpp-runtime-test PRIVATE
localai_audio_cpp_runtime
Threads::Threads)
add_test(
NAME audio-cpp-runtime-test
COMMAND audio-cpp-runtime-test "${AUDIO_CPP_DIR}")
endif()

View File

@@ -0,0 +1,67 @@
# SPDX-License-Identifier: MIT
AUDIO_CPP_VERSION?=f8fb0c19739193adfad0d9e58da99f25eda65256
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
AUDIO_CPP_SRC?=
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
BUILD_DIR := build
BUILD_TYPE ?=
JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
UNAME_S := $(shell uname -s)
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=OFF
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=OFF
CMAKE_ARGS += -DENGINE_ENABLE_METAL=OFF
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=ON
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=ON
else ifeq ($(UNAME_S),Darwin)
CMAKE_ARGS += -DENGINE_ENABLE_METAL=ON
endif
.PHONY: all grpc-server test test-unit clean purge
all: grpc-server
audio.cpp:
ifneq ($(AUDIO_CPP_SRC),)
ln -sfn $(abspath $(AUDIO_CPP_SRC)) audio.cpp
else
mkdir -p audio.cpp
cd audio.cpp && \
git init -q && \
git remote add origin $(AUDIO_CPP_REPO) && \
git fetch --depth 1 origin $(AUDIO_CPP_VERSION) && \
git checkout --detach FETCH_HEAD && \
git submodule update --init --recursive --depth 1
endif
grpc-server: audio.cpp
mkdir -p $(BUILD_DIR)
cd $(BUILD_DIR) && cmake $(CMAKE_ARGS) $(CURRENT_MAKEFILE_DIR)
cmake --build $(BUILD_DIR) --config Release \
--target audio-cpp-grpc-server -j $(JOBS)
cp $(BUILD_DIR)/audio-cpp-grpc-server grpc-server
test:
bash tests/build_contract_test.sh
test-unit: audio.cpp
mkdir -p $(BUILD_DIR)-unit
cd $(BUILD_DIR)-unit && cmake $(CMAKE_ARGS) \
-DAUDIO_CPP_BUILD_TESTS=ON -DAUDIO_CPP_BUILD_GRPC=OFF \
$(CURRENT_MAKEFILE_DIR)
cmake --build $(BUILD_DIR)-unit --config Release \
--target audio-cpp-runtime-test -j $(JOBS)
ctest --test-dir $(BUILD_DIR)-unit --output-on-failure
clean:
rm -rf $(BUILD_DIR) $(BUILD_DIR)-unit grpc-server
purge: clean
rm -rf audio.cpp

View File

@@ -0,0 +1,178 @@
// SPDX-License-Identifier: MIT
#include "audio_cpp_runtime.h"
#include <algorithm>
#include <stdexcept>
#include <string>
#include <utility>
namespace audio_cpp {
namespace {
using engine::runtime::CapabilitySet;
using engine::runtime::RunMode;
using engine::runtime::TaskSpec;
const engine::runtime::TaskCapability * find_task_capability(
const CapabilitySet & capabilities,
const TaskSpec & task) {
const auto it = std::find_if(
capabilities.supported_tasks.begin(),
capabilities.supported_tasks.end(),
[&](const engine::runtime::TaskCapability & capability) {
return capability.task == task.task;
});
return it == capabilities.supported_tasks.end() ? nullptr : &*it;
}
void validate_capability(
const engine::runtime::ILoadedVoiceModel & model,
const AudioCppModelConfig & config) {
if (model.metadata().family != config.family) {
throw std::runtime_error(
"loaded audio.cpp model family '" + model.metadata().family +
"' does not match requested family '" + config.family + "'");
}
const auto * capability = find_task_capability(
model.capabilities(),
config.task);
if (capability == nullptr) {
throw std::runtime_error(
"loaded audio.cpp model does not support requested task '" +
std::string(engine::runtime::to_string(config.task.task)) + "'");
}
if (std::find(
capability->modes.begin(),
capability->modes.end(),
config.task.mode) == capability->modes.end()) {
throw std::runtime_error(
"loaded audio.cpp model does not support requested mode '" +
std::string(engine::runtime::to_string(config.task.mode)) +
"' for task '" +
std::string(engine::runtime::to_string(config.task.task)) + "'");
}
}
void validate_session(
const engine::runtime::IVoiceTaskSession & session,
const AudioCppModelConfig & config) {
if (session.family() != config.family) {
throw std::runtime_error("audio.cpp session returned the wrong family");
}
if (session.task_kind() != config.task.task) {
throw std::runtime_error("audio.cpp session returned the wrong task");
}
if (session.run_mode() != config.task.mode) {
throw std::runtime_error("audio.cpp session returned the wrong mode");
}
if (config.task.mode == RunMode::Offline &&
dynamic_cast<const engine::runtime::IOfflineVoiceTaskSession *>(&session) == nullptr) {
throw std::runtime_error("audio.cpp session does not implement offline execution");
}
if (config.task.mode == RunMode::Streaming &&
dynamic_cast<const engine::runtime::IStreamingVoiceTaskSession *>(&session) == nullptr) {
throw std::runtime_error("audio.cpp session does not implement streaming execution");
}
}
} // namespace
AudioCppRuntime::AudioCppRuntime()
: registry_(engine::runtime::make_default_registry()) {}
AudioCppRuntime::AudioCppRuntime(engine::runtime::ModelRegistry registry)
: registry_(std::move(registry)) {}
AudioCppRuntime::~AudioCppRuntime() {
free();
}
void AudioCppRuntime::load(const AudioCppModelConfig & config) {
std::lock_guard<std::mutex> lock(mutex_);
auto candidate_model = registry_.load(config.load);
if (candidate_model == nullptr) {
throw std::runtime_error("audio.cpp registry returned a null model");
}
validate_capability(*candidate_model, config);
auto candidate_session = candidate_model->create_task_session(
config.task,
config.session);
if (candidate_session == nullptr) {
throw std::runtime_error("audio.cpp model returned a null session");
}
validate_session(*candidate_session, config);
free_locked();
model_ = std::move(candidate_model);
session_ = std::move(candidate_session);
}
void AudioCppRuntime::free() {
std::lock_guard<std::mutex> lock(mutex_);
free_locked();
}
engine::runtime::TaskResult AudioCppRuntime::run(
const engine::runtime::TaskRequest & request) {
std::lock_guard<std::mutex> lock(mutex_);
auto & session = require_session_locked();
session.prepare(engine::runtime::build_preparation_request(request));
return require_offline_locked().run(request);
}
void AudioCppRuntime::start_stream(
const engine::runtime::TaskRequest & request) {
std::lock_guard<std::mutex> lock(mutex_);
auto & session = require_session_locked();
session.prepare(engine::runtime::build_preparation_request(request));
require_streaming_locked().start_stream(request);
}
engine::runtime::StreamEvent AudioCppRuntime::process_audio_chunk(
const engine::runtime::AudioChunk & chunk) {
std::lock_guard<std::mutex> lock(mutex_);
return require_streaming_locked().process_audio_chunk(chunk);
}
engine::runtime::TaskResult AudioCppRuntime::finish_stream() {
std::lock_guard<std::mutex> lock(mutex_);
return require_streaming_locked().finish_stream();
}
engine::runtime::IVoiceTaskSession & AudioCppRuntime::require_session_locked() {
if (session_ == nullptr) {
throw std::runtime_error("audio.cpp runtime has no loaded session");
}
return *session_;
}
engine::runtime::IOfflineVoiceTaskSession &
AudioCppRuntime::require_offline_locked() {
auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(
&require_session_locked());
if (offline == nullptr) {
throw std::runtime_error("loaded audio.cpp session is not offline");
}
return *offline;
}
engine::runtime::IStreamingVoiceTaskSession &
AudioCppRuntime::require_streaming_locked() {
auto * streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(
&require_session_locked());
if (streaming == nullptr) {
throw std::runtime_error("loaded audio.cpp session is not streaming");
}
return *streaming;
}
void AudioCppRuntime::free_locked() {
session_.reset();
model_.reset();
}
} // namespace audio_cpp

View File

@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "model_config.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include <memory>
#include <mutex>
namespace audio_cpp {
class AudioCppRuntime {
public:
AudioCppRuntime();
explicit AudioCppRuntime(engine::runtime::ModelRegistry registry);
~AudioCppRuntime();
AudioCppRuntime(const AudioCppRuntime &) = delete;
AudioCppRuntime & operator=(const AudioCppRuntime &) = delete;
void load(const AudioCppModelConfig & config);
void free();
engine::runtime::TaskResult run(
const engine::runtime::TaskRequest & request);
void start_stream(const engine::runtime::TaskRequest & request);
engine::runtime::StreamEvent process_audio_chunk(
const engine::runtime::AudioChunk & chunk);
engine::runtime::TaskResult finish_stream();
private:
engine::runtime::IVoiceTaskSession & require_session_locked();
engine::runtime::IOfflineVoiceTaskSession & require_offline_locked();
engine::runtime::IStreamingVoiceTaskSession & require_streaming_locked();
void free_locked();
std::mutex mutex_;
engine::runtime::ModelRegistry registry_;
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model_;
std::unique_ptr<engine::runtime::IVoiceTaskSession> session_;
};
} // namespace audio_cpp

View File

@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
#include "model_config.h"
#include <limits>
#include <stdexcept>
#include <string>
namespace audio_cpp {
namespace {
using engine::core::BackendType;
using engine::runtime::RunMode;
using engine::runtime::VoiceTaskKind;
std::string require_option(
const std::unordered_map<std::string, std::string> & options,
const std::string & name) {
const auto it = options.find(name);
if (it == options.end() || it->second.empty()) {
throw std::invalid_argument("audio.cpp model config requires " + name);
}
return it->second;
}
VoiceTaskKind parse_task(const std::string & value) {
static const std::unordered_map<std::string, VoiceTaskKind> tasks = {
{"vad", VoiceTaskKind::Vad},
{"asr", VoiceTaskKind::Asr},
{"diarization", VoiceTaskKind::Diarization},
{"source-separation", VoiceTaskKind::SourceSeparation},
{"audio-generation", VoiceTaskKind::AudioGeneration},
{"tts", VoiceTaskKind::Tts},
{"voice-cloning", VoiceTaskKind::VoiceCloning},
{"voice-conversion", VoiceTaskKind::VoiceConversion},
{"speech-to-speech", VoiceTaskKind::SpeechToSpeech},
{"alignment", VoiceTaskKind::Alignment},
{"voice-design", VoiceTaskKind::VoiceDesign},
{"speaker-recognition", VoiceTaskKind::SpeakerRecognition},
{"svc", VoiceTaskKind::Svc},
};
const auto it = tasks.find(value);
if (it == tasks.end()) {
throw std::invalid_argument("unsupported audio.cpp task: " + value);
}
return it->second;
}
RunMode parse_mode(const std::string & value) {
if (value == "offline") {
return RunMode::Offline;
}
if (value == "streaming") {
return RunMode::Streaming;
}
throw std::invalid_argument("unsupported audio.cpp mode: " + value);
}
BackendType parse_backend(const std::string & value) {
if (value == "cpu") {
return BackendType::Cpu;
}
if (value == "cuda") {
return BackendType::Cuda;
}
if (value == "vulkan") {
return BackendType::Vulkan;
}
if (value == "metal") {
return BackendType::Metal;
}
if (value == "best") {
return BackendType::BestAvailable;
}
throw std::invalid_argument("unsupported audio.cpp backend: " + value);
}
int parse_integer(
const std::string & name,
const std::string & value,
int minimum) {
size_t parsed = 0;
long result = 0;
try {
result = std::stol(value, &parsed);
} catch (const std::exception &) {
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
}
if (parsed != value.size() ||
result < minimum ||
result > std::numeric_limits<int>::max()) {
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
}
return static_cast<int>(result);
}
void copy_namespaced_option(
const std::string & key,
const std::string & prefix,
const std::string & value,
std::unordered_map<std::string, std::string> & destination) {
const std::string name = key.substr(prefix.size());
if (name.empty()) {
throw std::invalid_argument("audio.cpp option namespace requires a name: " + key);
}
destination[name] = value;
}
} // namespace
AudioCppModelConfig parse_model_config(
const std::filesystem::path & model_path,
const std::unordered_map<std::string, std::string> & options) {
AudioCppModelConfig config;
config.model_path = model_path;
config.family = require_option(options, "family");
config.task.task = parse_task(require_option(options, "task"));
config.task.mode = RunMode::Offline;
config.load.model_path = model_path;
config.load.family_hint = config.family;
config.session.backend.type = BackendType::Cpu;
if (const auto it = options.find("mode"); it != options.end()) {
config.task.mode = parse_mode(it->second);
}
if (const auto it = options.find("backend"); it != options.end()) {
config.session.backend.type = parse_backend(it->second);
}
if (const auto it = options.find("device"); it != options.end()) {
config.session.backend.device = parse_integer("device", it->second, 0);
}
if (const auto it = options.find("threads"); it != options.end()) {
config.session.backend.threads = parse_integer("threads", it->second, 1);
}
if (const auto it = options.find("model_spec"); it != options.end()) {
config.load.model_spec_override = std::filesystem::path(it->second);
}
if (const auto it = options.find("config_id"); it != options.end()) {
config.load.config_id = it->second;
}
if (const auto it = options.find("weight_id"); it != options.end()) {
config.load.weight_id = it->second;
}
for (const auto & [key, value] : options) {
if (key.rfind("load.", 0) == 0) {
copy_namespaced_option(key, "load.", value, config.load.options);
} else if (key.rfind("session.", 0) == 0) {
copy_namespaced_option(key, "session.", value, config.session.options);
}
}
return config;
}
} // namespace audio_cpp

View File

@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/session.h"
#include <filesystem>
#include <string>
#include <unordered_map>
namespace audio_cpp {
struct AudioCppModelConfig {
std::filesystem::path model_path;
std::string family;
engine::runtime::TaskSpec task;
engine::runtime::ModelLoadRequest load;
engine::runtime::SessionOptions session;
};
AudioCppModelConfig parse_model_config(
const std::filesystem::path & model_path,
const std::unordered_map<std::string, std::string> & options);
} // namespace audio_cpp

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
backend_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
fixture_dir="${tmp_dir}/audio.cpp"
prefix_dir="${tmp_dir}/prefix"
tools_dir="${tmp_dir}/tools"
mkdir -p "${fixture_dir}" "${prefix_dir}/lib/cmake/Protobuf" \
"${prefix_dir}/lib/cmake/gRPC" "${tools_dir}"
cat >"${fixture_dir}/engine_runtime.cpp" <<'EOF'
void audio_cpp_build_contract_fixture() {}
EOF
cat >"${fixture_dir}/CMakeLists.txt" <<'EOF'
cmake_minimum_required(VERSION 3.20)
project(AudioCppBuildContractFixture LANGUAGES CXX)
option(ENGINE_ENABLE_CUDA "Build with CUDA" OFF)
option(ENGINE_ENABLE_VULKAN "Build with Vulkan" OFF)
option(ENGINE_ENABLE_METAL "Build with Metal" OFF)
foreach(wrong_option IN ITEMS
AUDIO_CPP_ENABLE_CUDA
AUDIO_CPP_ENABLE_VULKAN
AUDIO_CPP_ENABLE_METAL
AUDIOCPP_ENABLE_CUDA
AUDIOCPP_ENABLE_VULKAN
AUDIOCPP_ENABLE_METAL
ENGINE_CUDA
ENGINE_VULKAN
ENGINE_METAL
GGML_CUDA
GGML_VULKAN
GGML_METAL)
if(DEFINED ${wrong_option})
message(FATAL_ERROR "legacy or unsupported audio.cpp option: ${wrong_option}")
endif()
endforeach()
add_library(engine_runtime STATIC engine_runtime.cpp)
EOF
cat >"${prefix_dir}/lib/cmake/Protobuf/ProtobufConfig.cmake" <<'EOF'
set(Protobuf_FOUND TRUE)
set(Protobuf_VERSION 0.0.0)
if(NOT TARGET protobuf::libprotobuf)
add_library(protobuf::libprotobuf INTERFACE IMPORTED)
endif()
EOF
cat >"${prefix_dir}/lib/cmake/gRPC/gRPCConfig.cmake" <<'EOF'
set(gRPC_FOUND TRUE)
if(NOT TARGET gRPC::grpc++)
add_library(gRPC::grpc++ INTERFACE IMPORTED)
endif()
if(NOT TARGET gRPC::grpc++_reflection)
add_library(gRPC::grpc++_reflection INTERFACE IMPORTED)
endif()
EOF
cat >"${tools_dir}/protoc" <<'EOF'
#!/usr/bin/env sh
exit 0
EOF
cat >"${tools_dir}/grpc_cpp_plugin" <<'EOF'
#!/usr/bin/env sh
exit 0
EOF
chmod +x "${tools_dir}/protoc" "${tools_dir}/grpc_cpp_plugin"
assert_cache_bool() {
local cache_file="$1"
local name="$2"
local expected="$3"
grep -q "^${name}:BOOL=${expected}$" "${cache_file}" || {
echo "expected ${name}:BOOL=${expected} in ${cache_file}" >&2
return 1
}
}
configure_case() {
local name="$1"
local cuda="$2"
local vulkan="$3"
local metal="$4"
local build_dir="${tmp_dir}/build-${name}"
PATH="${tools_dir}:${PATH}" cmake \
-S "${backend_dir}" \
-B "${build_dir}" \
-DCMAKE_PREFIX_PATH="${prefix_dir}" \
-DAUDIO_CPP_DIR="${fixture_dir}" \
-DENGINE_ENABLE_CUDA="${cuda}" \
-DENGINE_ENABLE_VULKAN="${vulkan}" \
-DENGINE_ENABLE_METAL="${metal}" \
>/dev/null
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_CUDA "${cuda}"
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_VULKAN "${vulkan}"
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_METAL "${metal}"
grep -q 'engine_runtime' \
"${build_dir}/CMakeFiles/audio-cpp-grpc-server.dir/link.txt" || {
echo "audio-cpp-grpc-server does not link engine_runtime" >&2
return 1
}
}
configure_case cpu OFF OFF OFF
configure_case cuda ON OFF OFF
configure_case vulkan OFF ON OFF
configure_case metal OFF OFF ON
if PATH="${tools_dir}:${PATH}" cmake \
-S "${backend_dir}" \
-B "${tmp_dir}/build-wrong-option" \
-DCMAKE_PREFIX_PATH="${prefix_dir}" \
-DAUDIO_CPP_DIR="${fixture_dir}" \
-DGGML_CUDA=ON \
>/dev/null 2>&1; then
echo "strict audio.cpp fixture accepted legacy GGML_CUDA option" >&2
exit 1
fi
make_database="${tmp_dir}/make-database"
make -C "${backend_dir}" -pn >"${make_database}"
audio_cpp_version="$(
sed -n 's/^AUDIO_CPP_VERSION = //p' "${make_database}" | head -n 1
)"
[[ "${audio_cpp_version}" =~ ^[0-9a-f]{40}$ ]] || {
echo "AUDIO_CPP_VERSION must be a pinned 40-character commit" >&2
exit 1
}
fetch_plan="${tmp_dir}/fetch-plan"
make -C "${backend_dir}" -Bn audio.cpp >"${fetch_plan}"
grep -q 'github.com/0xShug0/audio.cpp' "${fetch_plan}"
grep -q "${audio_cpp_version}" "${fetch_plan}"
cat >"${tools_dir}/uname" <<'EOF'
#!/usr/bin/env sh
if [ "$#" -eq 1 ] && [ "$1" = "-s" ]; then
echo Darwin
exit 0
fi
echo "build contract requires uname -s" >&2
exit 64
EOF
chmod +x "${tools_dir}/uname"
darwin_plan="${tmp_dir}/darwin-plan"
PATH="${tools_dir}:${PATH}" make -C "${backend_dir}" -n \
AUDIO_CPP_SRC="${fixture_dir}" grpc-server >"${darwin_plan}"
grep -q -- '-DENGINE_ENABLE_CUDA=OFF' "${darwin_plan}"
grep -q -- '-DENGINE_ENABLE_VULKAN=OFF' "${darwin_plan}"
grep -q -- '-DENGINE_ENABLE_METAL=ON' "${darwin_plan}"
echo "audio.cpp build contract: PASS"

View File

@@ -0,0 +1,490 @@
// SPDX-License-Identifier: MIT
#include "audio_cpp_runtime.h"
#include "model_config.h"
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <exception>
#include <filesystem>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace {
using engine::runtime::AudioChunk;
using engine::runtime::CapabilitySet;
using engine::runtime::ILoadedVoiceModel;
using engine::runtime::IOfflineVoiceTaskSession;
using engine::runtime::IStreamingVoiceTaskSession;
using engine::runtime::IVoiceModelLoader;
using engine::runtime::IVoiceTaskSession;
using engine::runtime::ModelInspection;
using engine::runtime::ModelLoadRequest;
using engine::runtime::ModelMetadata;
using engine::runtime::RunMode;
using engine::runtime::SessionOptions;
using engine::runtime::SessionPreparationRequest;
using engine::runtime::StreamEvent;
using engine::runtime::TaskCapability;
using engine::runtime::TaskRequest;
using engine::runtime::TaskResult;
using engine::runtime::TaskSpec;
using engine::runtime::VoiceTaskKind;
void require(bool condition, const std::string & message) {
if (!condition) {
throw std::runtime_error(message);
}
}
template <typename Function>
void require_throws(Function && function, const std::string & expected) {
try {
function();
} catch (const std::exception & error) {
require(
std::string(error.what()).find(expected) != std::string::npos,
"expected error containing '" + expected + "', got '" + error.what() + "'");
return;
}
throw std::runtime_error("expected exception containing '" + expected + "'");
}
struct SessionGate {
std::mutex mutex;
std::condition_variable condition;
bool first_entered = false;
bool release_first = false;
std::atomic<int> entries{0};
};
struct FakeState {
std::mutex mutex;
std::vector<std::string> events;
CapabilitySet capabilities;
bool fail_load = false;
bool fail_session = false;
int generation = 0;
std::shared_ptr<SessionGate> gate;
void record(std::string event) {
std::lock_guard<std::mutex> lock(mutex);
events.push_back(std::move(event));
}
};
class FakeSession final
: public IOfflineVoiceTaskSession,
public IStreamingVoiceTaskSession {
public:
FakeSession(
std::shared_ptr<FakeState> state,
int generation,
TaskSpec task,
SessionOptions options)
: state_(std::move(state)),
generation_(generation),
task_(task),
options_(std::move(options)) {}
~FakeSession() override {
state_->record("session-" + std::to_string(generation_) + "-destroyed");
}
std::string family() const override { return "fake-family"; }
VoiceTaskKind task_kind() const override { return task_.task; }
RunMode run_mode() const override { return task_.mode; }
void prepare(const SessionPreparationRequest & request) override {
prepared_ = request;
}
TaskResult run(const TaskRequest &) override {
if (state_->gate != nullptr) {
const int entry = ++state_->gate->entries;
if (entry == 1) {
std::unique_lock<std::mutex> lock(state_->gate->mutex);
state_->gate->first_entered = true;
state_->gate->condition.notify_all();
state_->gate->condition.wait(
lock,
[&] { return state_->gate->release_first; });
}
}
TaskResult result;
result.text_output = engine::runtime::Transcript{
"generation-" + std::to_string(generation_),
"en",
};
return result;
}
engine::runtime::StreamingPolicy streaming_policy() const override {
engine::runtime::StreamingPolicy policy;
policy.input = engine::runtime::StreamingInputKind::AudioChunks;
policy.output = engine::runtime::StreamingOutputKind::PullEvents;
policy.preferred_audio_chunk_samples = 160;
return policy;
}
void start_stream(const TaskRequest &) override {
streaming_ = true;
}
std::optional<StreamEvent> next_stream_event() override {
return std::nullopt;
}
void set_stream_event_sink(engine::runtime::StreamEventCallback sink) override {
sink_ = std::move(sink);
}
TaskResult finish_stream() override {
streaming_ = false;
return run({});
}
void reset() override {
streaming_ = false;
}
StreamEvent process_audio_chunk(const AudioChunk & chunk) override {
require(streaming_, "stream was not started");
StreamEvent event;
event.audio_output = engine::runtime::AudioBuffer{
chunk.sample_rate,
chunk.channels,
chunk.samples,
};
if (sink_) {
sink_(event);
}
return event;
}
TaskResult finalize() override {
streaming_ = false;
return run({});
}
private:
std::shared_ptr<FakeState> state_;
int generation_;
TaskSpec task_;
SessionOptions options_;
SessionPreparationRequest prepared_;
engine::runtime::StreamEventCallback sink_;
bool streaming_ = false;
};
class FakeLoadedModel final : public ILoadedVoiceModel {
public:
FakeLoadedModel(std::shared_ptr<FakeState> state, int generation)
: state_(std::move(state)),
generation_(generation) {
metadata_.family = "fake-family";
metadata_.variant = "complete-fake";
metadata_.description = "complete test implementation";
metadata_.config_candidates = {"config.json"};
metadata_.weight_candidates = {"weights.gguf"};
}
~FakeLoadedModel() override {
state_->record("model-" + std::to_string(generation_) + "-destroyed");
}
const ModelMetadata & metadata() const noexcept override {
return metadata_;
}
const CapabilitySet & capabilities() const noexcept override {
return state_->capabilities;
}
std::unique_ptr<IVoiceTaskSession> create_task_session(
const TaskSpec & task,
const SessionOptions & options) const override {
if (state_->fail_session) {
throw std::runtime_error("session creation failed");
}
return std::make_unique<FakeSession>(state_, generation_, task, options);
}
private:
std::shared_ptr<FakeState> state_;
int generation_;
ModelMetadata metadata_;
};
class FakeLoader final : public IVoiceModelLoader {
public:
explicit FakeLoader(std::shared_ptr<FakeState> state)
: state_(std::move(state)) {}
std::string family() const override { return "fake-family"; }
bool can_load(const ModelLoadRequest & request) const override {
return request.family_hint == family();
}
ModelInspection inspect(const ModelLoadRequest & request) const override {
ModelInspection inspection;
inspection.metadata.family = family();
inspection.metadata.variant = "complete-fake";
inspection.metadata.description = "complete test loader";
inspection.metadata.config_candidates = {"config.json"};
inspection.metadata.weight_candidates = {"weights.gguf"};
inspection.capabilities = state_->capabilities;
inspection.model_root = request.model_path;
return inspection;
}
std::unique_ptr<ILoadedVoiceModel> load(
const ModelLoadRequest &) const override {
if (state_->fail_load) {
throw std::runtime_error("model load failed");
}
const int generation = ++state_->generation;
return std::make_unique<FakeLoadedModel>(state_, generation);
}
CapabilitySet advertised_capabilities() const override {
return state_->capabilities;
}
std::string advertised_instructions_policy() const override {
return "explicit";
}
std::vector<std::string> advertised_api_endpoints() const override {
return {"/v1/audio/transcriptions"};
}
private:
std::shared_ptr<FakeState> state_;
};
audio_cpp::AudioCppModelConfig offline_asr_config(
const std::filesystem::path & model_path) {
return audio_cpp::parse_model_config(
model_path,
{
{"family", "fake-family"},
{"task", "asr"},
{"mode", "offline"},
{"backend", "cpu"},
{"device", "2"},
{"threads", "3"},
{"load.cache", "memory"},
{"session.language", "en"},
});
}
std::unique_ptr<audio_cpp::AudioCppRuntime> make_runtime(
const std::shared_ptr<FakeState> & state) {
engine::runtime::ModelRegistry registry;
registry.register_loader(std::make_shared<FakeLoader>(state));
return std::make_unique<audio_cpp::AudioCppRuntime>(std::move(registry));
}
void test_model_config(const std::filesystem::path & model_path) {
const auto config = offline_asr_config(model_path);
require(config.model_path == model_path, "model path was not preserved");
require(config.family == "fake-family", "family was not parsed");
require(config.task.task == VoiceTaskKind::Asr, "task was not parsed");
require(config.task.mode == RunMode::Offline, "mode was not parsed");
require(
config.session.backend.type == engine::core::BackendType::Cpu,
"backend was not parsed");
require(config.session.backend.device == 2, "device was not parsed");
require(config.session.backend.threads == 3, "threads were not parsed");
require(config.load.options.at("cache") == "memory", "load option prefix was not stripped");
require(
config.session.options.at("language") == "en",
"session option prefix was not stripped");
require_throws(
[&] { audio_cpp::parse_model_config(model_path, {{"task", "asr"}}); },
"family");
require_throws(
[&] { audio_cpp::parse_model_config(model_path, {{"family", "fake-family"}}); },
"task");
require_throws(
[&] {
audio_cpp::parse_model_config(
model_path,
{{"family", "fake-family"}, {"task", "asr"}, {"mode", "batch"}});
},
"mode");
require_throws(
[&] {
audio_cpp::parse_model_config(
model_path,
{{"family", "fake-family"}, {"task", "asr"}, {"backend", "tpu"}});
},
"backend");
}
void test_capability_validation(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Tts, {RunMode::Offline}},
};
auto runtime = make_runtime(state);
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"task");
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Streaming}},
};
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"mode");
}
void test_atomic_replacement(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Offline}},
};
auto runtime = make_runtime(state);
runtime->load(offline_asr_config(model_path));
state->fail_load = true;
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"model load failed");
require(
runtime->run({}).text_output->text == "generation-1",
"old model was not retained after load failure");
state->fail_load = false;
state->fail_session = true;
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"session creation failed");
require(
runtime->run({}).text_output->text == "generation-1",
"old model was not retained after session creation failure");
}
void test_teardown_order(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Offline}},
};
auto runtime = make_runtime(state);
runtime->load(offline_asr_config(model_path));
runtime->free();
std::lock_guard<std::mutex> lock(state->mutex);
require(state->events.size() == 2, "expected one session and one model teardown");
require(
state->events[0] == "session-1-destroyed",
"session was not destroyed before model");
require(
state->events[1] == "model-1-destroyed",
"model teardown event was not second");
}
void test_runtime_serializes_calls(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Offline}},
};
state->gate = std::make_shared<SessionGate>();
auto runtime = make_runtime(state);
runtime->load(offline_asr_config(model_path));
auto first = std::async(std::launch::async, [&] { return runtime->run({}); });
{
std::unique_lock<std::mutex> lock(state->gate->mutex);
state->gate->condition.wait(
lock,
[&] { return state->gate->first_entered; });
}
std::promise<void> release_second;
std::shared_future<void> second_barrier = release_second.get_future().share();
std::promise<void> second_attempted_promise;
auto second_attempted = second_attempted_promise.get_future();
auto second = std::async(std::launch::async, [&] {
second_barrier.wait();
second_attempted_promise.set_value();
return runtime->run({});
});
release_second.set_value();
second_attempted.wait();
require(
second.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout,
"second call completed while first call held the runtime");
require(
state->gate->entries.load() == 1,
"second call entered the upstream session concurrently");
{
std::lock_guard<std::mutex> lock(state->gate->mutex);
state->gate->release_first = true;
}
state->gate->condition.notify_all();
first.get();
second.get();
require(state->gate->entries.load() == 2, "second call never reached the session");
}
void test_streaming_surface(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Streaming}},
};
auto runtime = make_runtime(state);
auto config = offline_asr_config(model_path);
config.task.mode = RunMode::Streaming;
runtime->load(config);
runtime->start_stream({});
const auto event = runtime->process_audio_chunk({16000, 1, 0, {0.25f}});
require(event.audio_output.has_value(), "streaming chunk result was lost");
require(
event.audio_output->samples == std::vector<float>{0.25f},
"streaming chunk samples changed");
require(
runtime->finish_stream().text_output->text == "generation-1",
"streaming final result was lost");
}
} // namespace
int main(int argc, char ** argv) {
try {
require(argc == 2, "runtime_test requires an existing model path argument");
const std::filesystem::path model_path(argv[1]);
test_model_config(model_path);
test_capability_validation(model_path);
test_atomic_replacement(model_path);
test_teardown_order(model_path);
test_runtime_serializes_calls(model_path);
test_streaming_surface(model_path);
std::cout << "audio.cpp runtime unit tests: PASS\n";
return 0;
} catch (const std::exception & error) {
std::cerr << "audio.cpp runtime unit tests: FAIL: " << error.what() << '\n';
return 1;
}
}

View File

@@ -1,330 +0,0 @@
// Ordering guard for mutable backend image tags.
//
// Backend images are published to quay.io/go-skynet/local-ai-backends and
// localai/localai-backends under two kinds of tag:
//
// immutable sha-<short>-<suffix> one per commit, never reused
// mutable master-<suffix> moved on every master build
// latest-<suffix>
// v<version>-<suffix>
//
// Nothing used to check whether an incoming build was newer than whatever a
// mutable tag already pointed at, so the last writer won. That is not a
// theoretical race: backend CI queues run hours deep (measured ~260 min
// average queue wait against ~19 min average execution) and master pushes get
// a concurrency group keyed by github.sha, so no run supersedes another.
// Completion order does not track commit order. On 19 Jul 2026 a build of a
// commit from 06:45 UTC finished at 15:40 UTC and moved
// master-nvidia-l4t-cuda-13-arm64-longcat-video back onto a pre-fix image,
// silently un-shipping a merged cuDNN packaging fix for two days.
//
// This module decides, per tag, whether a push may proceed. Everything here is
// dependency-free; the registry and GitHub lookups take an injected fetch so
// the decision logic is unit-testable without network access
// (scripts/lib/tag-guard_test.mjs, run by `make test-ci-scripts`).
// docker/metadata-action's `type=sha` emits `sha-<7+ hex>`, and our flavor
// appends the backend tag-suffix. Anything matching this is a per-commit
// artifact: it is how a human pins a known-good build, so it must always push.
const IMMUTABLE_TAG_RE = /^sha-[0-9a-f]{7,40}(?:-|$)/;
const REVISION_LABEL = "org.opencontainers.image.revision";
const MANIFEST_ACCEPT = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
].join(",");
// Split "quay.io/go-skynet/local-ai-backends:master-foo" into its parts.
// Docker Hub refs carry no registry host ("localai/localai-backends:tag"), so
// a first segment without a dot or colon means Docker Hub.
export function parseImageRef(ref) {
const lastColon = ref.lastIndexOf(":");
const lastSlash = ref.lastIndexOf("/");
if (lastColon === -1 || lastColon < lastSlash) {
throw new Error(`image ref has no tag: ${ref}`);
}
const name = ref.slice(0, lastColon);
const tag = ref.slice(lastColon + 1);
const segments = name.split("/");
let host = "docker.io";
let repository = name;
if (segments.length > 1 && /[.:]/.test(segments[0])) {
host = segments[0];
repository = segments.slice(1).join("/");
} else if (segments.length === 1) {
repository = `library/${name}`;
}
return { host, repository, tag, ref };
}
export function isImmutableTag(ref) {
return IMMUTABLE_TAG_RE.test(parseImageRef(ref).tag);
}
// Partition a metadata-action tag list into the tags that always push and the
// tags that need an ordering check.
export function classifyTags(refs) {
const immutable = [];
const mutable = [];
for (const ref of refs) {
(isImmutableTag(ref) ? immutable : mutable).push(ref);
}
return { immutable, mutable };
}
// Decide a single mutable tag.
//
// `current` is the outcome of resolving the tag's present revision:
// { status: "absent" } tag has never been pushed
// { status: "unlabeled" } image carries no revision label
// { status: "error", detail } registry lookup failed
// { status: "ok", revision } revision is known
//
// `comparison` is the outcome of comparing that revision to the incoming one:
// { status: "ahead" | "identical" } incoming descends from current
// { status: "behind" | "diverged" } incoming is older or unrelated
// { status: "unresolvable" } a commit is no longer in the repo
// { status: "error", detail } compare API failed
//
// Fail-open cases (unlabeled, unresolvable, lookup errors) push with a loud
// warning rather than blocking. A guard that fails closed on a registry or API
// blip would itself stop shipping merged fixes, which is the bug we are fixing,
// only louder. Every fail-open path is annotated so it is visible in the run.
export function decideMutableTag({ tag, incomingSha, current, comparison }) {
const short = (incomingSha || "").slice(0, 7);
switch (current.status) {
case "absent":
return {
push: true,
severity: "info",
reason: `${tag}: tag does not exist yet, publishing ${short}`,
};
case "unlabeled":
return {
push: true,
severity: "warning",
reason:
`${tag}: current image carries no ${REVISION_LABEL} label, ` +
`cannot verify ordering, publishing ${short} anyway`,
};
case "error":
return {
push: true,
severity: "warning",
reason:
`${tag}: could not read the current image (${current.detail}), ` +
`cannot verify ordering, publishing ${short} anyway`,
};
}
const from = (current.revision || "").slice(0, 7);
switch (comparison.status) {
case "identical":
return {
push: true,
severity: "info",
reason: `${tag}: already at ${short}, republishing the same commit`,
};
case "ahead":
return {
push: true,
severity: "info",
reason: `${tag}: ${short} descends from ${from}, advancing the tag`,
};
case "behind":
return {
push: false,
severity: "warning",
reason:
`${tag}: REFUSING to move the tag backwards: ${short} is an ` +
`ancestor of the published ${from}. A newer build already won this ` +
`race; this straggler would un-ship it. Pin sha-${short} if you ` +
`need this exact build.`,
};
case "diverged":
return {
push: false,
severity: "warning",
reason:
`${tag}: REFUSING to move the tag: ${short} and the published ` +
`${from} have no ancestor relationship. Pin sha-${short} if you ` +
`need this exact build.`,
};
case "unresolvable":
return {
push: true,
severity: "warning",
reason:
`${tag}: the published revision ${from} is no longer reachable in ` +
`the repository, cannot verify ordering, publishing ${short} anyway`,
};
default:
return {
push: true,
severity: "warning",
reason:
`${tag}: commit comparison failed (${comparison.detail}), ` +
`cannot verify ordering, publishing ${short} anyway`,
};
}
}
// Read the org.opencontainers.image.revision label off whatever a tag
// currently points at. Both repositories are public, so anonymous pull tokens
// are enough and the guard needs no registry credentials of its own.
export async function resolveTagRevision(ref, { fetchImpl = fetch } = {}) {
const { host, repository, tag } = parseImageRef(ref);
const registry = host === "docker.io" ? "registry-1.docker.io" : host;
const base = `https://${registry}/v2/${repository}`;
let auth = {};
try {
if (host === "docker.io") {
const tokenRes = await fetchImpl(
`https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`,
);
if (!tokenRes.ok) {
return { status: "error", detail: `docker hub token ${tokenRes.status}` };
}
const { token } = await tokenRes.json();
auth = { Authorization: `Bearer ${token}` };
}
const manifestRes = await fetchImpl(`${base}/manifests/${tag}`, {
headers: { Accept: MANIFEST_ACCEPT, ...auth },
});
if (manifestRes.status === 404) {
return { status: "absent" };
}
if (!manifestRes.ok) {
return { status: "error", detail: `manifest ${manifestRes.status}` };
}
let manifest = await manifestRes.json();
// Multi-arch: every leg of one build is stamped with the same revision, so
// the first child is representative.
if (Array.isArray(manifest.manifests)) {
const child = manifest.manifests.find((m) => m.digest);
if (!child) {
return { status: "unlabeled" };
}
const childRes = await fetchImpl(`${base}/manifests/${child.digest}`, {
headers: { Accept: MANIFEST_ACCEPT, ...auth },
});
if (!childRes.ok) {
return { status: "error", detail: `child manifest ${childRes.status}` };
}
manifest = await childRes.json();
}
const configDigest = manifest.config && manifest.config.digest;
if (!configDigest) {
return { status: "unlabeled" };
}
const configRes = await fetchImpl(`${base}/blobs/${configDigest}`, {
headers: auth,
});
if (!configRes.ok) {
return { status: "error", detail: `config blob ${configRes.status}` };
}
const config = await configRes.json();
const labels =
(config.config && config.config.Labels) ||
(config.container_config && config.container_config.Labels) ||
{};
const revision = labels[REVISION_LABEL];
return revision ? { status: "ok", revision } : { status: "unlabeled" };
} catch (err) {
return { status: "error", detail: String(err && err.message ? err.message : err) };
}
}
// Ask GitHub how `head` relates to `base`. The compare API answers this
// directly, which keeps the guard working in the shallow sparse checkouts the
// merge and publish jobs use. `git merge-base --is-ancestor` would need the
// full history fetched into every one of the ~200 merge jobs per push.
export async function compareCommits({
repository,
base,
head,
token,
fetchImpl = fetch,
}) {
if (base === head) {
return { status: "identical" };
}
try {
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetchImpl(
`https://api.github.com/repos/${repository}/compare/${base}...${head}`,
{ headers },
);
if (res.status === 404) {
return { status: "unresolvable" };
}
if (!res.ok) {
return { status: "error", detail: `compare ${res.status}` };
}
const body = await res.json();
// GitHub reports ahead/behind/identical/diverged. Unrelated histories in
// the same repository come back as "diverged" with no merge base.
const known = ["ahead", "behind", "identical", "diverged"];
return known.includes(body.status)
? { status: body.status }
: { status: "error", detail: `unexpected compare status ${body.status}` };
} catch (err) {
return { status: "error", detail: String(err && err.message ? err.message : err) };
}
}
// Full pass over a metadata-action tag list. Returns the tags that may be
// pushed plus a decision log for the job summary.
export async function guardTags({
refs,
incomingSha,
repository,
token,
fetchImpl = fetch,
}) {
const { immutable, mutable } = classifyTags(refs);
const decisions = immutable.map((tag) => ({
tag,
push: true,
severity: "info",
reason: `${tag}: immutable per-commit tag, always published`,
}));
for (const tag of mutable) {
const current = await resolveTagRevision(tag, { fetchImpl });
let comparison = { status: "skipped" };
if (current.status === "ok") {
comparison = await compareCommits({
repository,
base: current.revision,
head: incomingSha,
token,
fetchImpl,
});
}
decisions.push({
tag,
...decideMutableTag({ tag, incomingSha, current, comparison }),
});
}
return {
allowed: decisions.filter((d) => d.push).map((d) => d.tag),
blocked: decisions.filter((d) => !d.push).map((d) => d.tag),
decisions,
};
}

View File

@@ -1,285 +0,0 @@
// Unit tests for the mutable-tag ordering guard (scripts/lib/tag-guard.mjs).
//
// Run with `make test-ci-scripts` (plain `node --test`, no dependencies), which
// is what .github/workflows/lint.yml runs. The registry and GitHub lookups take
// an injected fetch, so nothing here touches the network.
import test from "node:test";
import assert from "node:assert/strict";
import {
classifyTags,
compareCommits,
decideMutableTag,
guardTags,
isImmutableTag,
parseImageRef,
} from "./tag-guard.mjs";
const QUAY = "quay.io/go-skynet/local-ai-backends";
const HUB = "localai/localai-backends";
const SUFFIX = "-nvidia-l4t-cuda-13-arm64-longcat-video";
// The commits from the incident this guard exists to prevent.
const PRE_FIX = "10211948b5470d332a93c841a9c8fe2b9a737148";
const POST_FIX = "626ae4d51000000000000000000000000000000a";
test("parseImageRef splits a registry-qualified ref", () => {
assert.deepEqual(parseImageRef(`${QUAY}:master${SUFFIX}`), {
host: "quay.io",
repository: "go-skynet/local-ai-backends",
tag: `master${SUFFIX}`,
ref: `${QUAY}:master${SUFFIX}`,
});
});
test("parseImageRef treats a dotless first segment as Docker Hub", () => {
const parsed = parseImageRef(`${HUB}:latest${SUFFIX}`);
assert.equal(parsed.host, "docker.io");
assert.equal(parsed.repository, "localai/localai-backends");
});
test("parseImageRef rejects a ref with no tag", () => {
assert.throws(() => parseImageRef(QUAY), /no tag/);
});
test("sha- tags are immutable, rolling tags are not", () => {
assert.equal(isImmutableTag(`${QUAY}:sha-626ae4d${SUFFIX}`), true);
assert.equal(isImmutableTag(`${QUAY}:sha-626ae4d`), true);
assert.equal(isImmutableTag(`${QUAY}:master${SUFFIX}`), false);
assert.equal(isImmutableTag(`${QUAY}:latest${SUFFIX}`), false);
assert.equal(isImmutableTag(`${QUAY}:v4.7.0${SUFFIX}`), false);
// A tag that merely starts with the letters "sha" is not a commit tag.
assert.equal(isImmutableTag(`${QUAY}:shazam${SUFFIX}`), false);
});
test("classifyTags partitions a metadata-action tag list", () => {
const { immutable, mutable } = classifyTags([
`${QUAY}:master${SUFFIX}`,
`${QUAY}:sha-626ae4d${SUFFIX}`,
`${HUB}:master${SUFFIX}`,
`${HUB}:sha-626ae4d${SUFFIX}`,
]);
assert.deepEqual(immutable, [
`${QUAY}:sha-626ae4d${SUFFIX}`,
`${HUB}:sha-626ae4d${SUFFIX}`,
]);
assert.deepEqual(mutable, [`${QUAY}:master${SUFFIX}`, `${HUB}:master${SUFFIX}`]);
});
test("a descendant commit advances the tag", () => {
const d = decideMutableTag({
tag: "master",
incomingSha: POST_FIX,
current: { status: "ok", revision: PRE_FIX },
comparison: { status: "ahead" },
});
assert.equal(d.push, true);
assert.match(d.reason, /advancing the tag/);
});
test("the incident case is blocked: an older commit cannot move the tag back", () => {
const d = decideMutableTag({
tag: `master${SUFFIX}`,
incomingSha: PRE_FIX,
current: { status: "ok", revision: POST_FIX },
comparison: { status: "behind" },
});
assert.equal(d.push, false);
assert.equal(d.severity, "warning");
assert.match(d.reason, /REFUSING to move the tag backwards/);
// The workaround for the real incident was pinning the sha- tag; the message
// has to point there.
assert.match(d.reason, /Pin sha-1021194/);
});
test("unrelated histories are blocked", () => {
const d = decideMutableTag({
tag: "master",
incomingSha: POST_FIX,
current: { status: "ok", revision: PRE_FIX },
comparison: { status: "diverged" },
});
assert.equal(d.push, false);
assert.match(d.reason, /no ancestor relationship/);
});
test("a republish of the same commit is allowed", () => {
const d = decideMutableTag({
tag: "master",
incomingSha: POST_FIX,
current: { status: "ok", revision: POST_FIX },
comparison: { status: "identical" },
});
assert.equal(d.push, true);
assert.equal(d.severity, "info");
});
test("a tag that does not exist yet is published without a warning", () => {
const d = decideMutableTag({
tag: "v4.7.0",
incomingSha: POST_FIX,
current: { status: "absent" },
comparison: { status: "skipped" },
});
assert.equal(d.push, true);
assert.equal(d.severity, "info");
});
test("fail-open paths push but always warn", () => {
for (const [current, comparison, pattern] of [
[{ status: "unlabeled" }, { status: "skipped" }, /no org\.opencontainers\.image\.revision label/],
[{ status: "error", detail: "manifest 503" }, { status: "skipped" }, /manifest 503/],
[{ status: "ok", revision: PRE_FIX }, { status: "unresolvable" }, /no longer reachable/],
[{ status: "ok", revision: PRE_FIX }, { status: "error", detail: "compare 502" }, /compare 502/],
]) {
const d = decideMutableTag({
tag: "master",
incomingSha: POST_FIX,
current,
comparison,
});
assert.equal(d.push, true);
assert.equal(d.severity, "warning");
assert.match(d.reason, pattern);
}
});
// --- fetch-backed helpers -------------------------------------------------
function jsonResponse(body, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
test("compareCommits short-circuits identical commits without a request", async () => {
let called = false;
const res = await compareCommits({
repository: "mudler/LocalAI",
base: POST_FIX,
head: POST_FIX,
fetchImpl: async () => {
called = true;
return jsonResponse({});
},
});
assert.deepEqual(res, { status: "identical" });
assert.equal(called, false);
});
test("compareCommits maps a 404 to unresolvable", async () => {
const res = await compareCommits({
repository: "mudler/LocalAI",
base: PRE_FIX,
head: POST_FIX,
fetchImpl: async () => jsonResponse({}, 404),
});
assert.deepEqual(res, { status: "unresolvable" });
});
test("compareCommits surfaces the GitHub status verbatim", async () => {
const res = await compareCommits({
repository: "mudler/LocalAI",
base: POST_FIX,
head: PRE_FIX,
fetchImpl: async () => jsonResponse({ status: "behind" }),
});
assert.deepEqual(res, { status: "behind" });
});
test("compareCommits turns a thrown fetch into a non-fatal error", async () => {
const res = await compareCommits({
repository: "mudler/LocalAI",
base: PRE_FIX,
head: POST_FIX,
fetchImpl: async () => {
throw new Error("ECONNRESET");
},
});
assert.equal(res.status, "error");
assert.match(res.detail, /ECONNRESET/);
});
// A fake registry serving one multi-arch tag stamped with `revision`.
function fakeRegistry(revision) {
return async (url) => {
if (url.includes("auth.docker.io")) {
return jsonResponse({ token: "t" });
}
if (url.endsWith("/manifests/master-x")) {
return jsonResponse({ manifests: [{ digest: "sha256:child" }] });
}
if (url.endsWith("/manifests/sha256:child")) {
return jsonResponse({ config: { digest: "sha256:cfg" } });
}
if (url.endsWith("/blobs/sha256:cfg")) {
return jsonResponse({
config: { Labels: { "org.opencontainers.image.revision": revision } },
});
}
return jsonResponse({}, 404);
};
}
test("guardTags always keeps immutable tags and drops a backwards mutable tag", async () => {
const fetchImpl = async (url, init) => {
if (url.startsWith("https://api.github.com/")) {
return jsonResponse({ status: "behind" });
}
return fakeRegistry(POST_FIX)(url, init);
};
const result = await guardTags({
refs: [`${QUAY}:sha-1021194-x`, `${QUAY}:master-x`],
incomingSha: PRE_FIX,
repository: "mudler/LocalAI",
fetchImpl,
});
assert.deepEqual(result.allowed, [`${QUAY}:sha-1021194-x`]);
assert.deepEqual(result.blocked, [`${QUAY}:master-x`]);
});
test("guardTags advances a mutable tag when the incoming commit is newer", async () => {
const fetchImpl = async (url, init) => {
if (url.startsWith("https://api.github.com/")) {
return jsonResponse({ status: "ahead" });
}
return fakeRegistry(PRE_FIX)(url, init);
};
const result = await guardTags({
refs: [`${QUAY}:sha-626ae4d-x`, `${QUAY}:master-x`],
incomingSha: POST_FIX,
repository: "mudler/LocalAI",
fetchImpl,
});
assert.deepEqual(result.allowed, [`${QUAY}:sha-626ae4d-x`, `${QUAY}:master-x`]);
assert.deepEqual(result.blocked, []);
});
test("guardTags publishes an unlabeled tag rather than stalling on it", async () => {
const fetchImpl = async (url) => {
if (url.endsWith("/manifests/master-x")) {
return jsonResponse({ config: { digest: "sha256:cfg" } });
}
if (url.endsWith("/blobs/sha256:cfg")) {
return jsonResponse({ config: {} });
}
return jsonResponse({}, 404);
};
const result = await guardTags({
refs: [`${QUAY}:master-x`],
incomingSha: POST_FIX,
repository: "mudler/LocalAI",
fetchImpl,
});
assert.deepEqual(result.allowed, [`${QUAY}:master-x`]);
assert.equal(result.decisions[0].severity, "warning");
});

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env node
//
// CI entrypoint for the mutable-tag ordering guard. Reads the tag list
// docker/metadata-action produced, decides which tags this build is allowed to
// publish (see scripts/lib/tag-guard.mjs for the rules and the incident that
// motivated them), prints the surviving tags one per line on stdout, and logs
// every decision to stderr, the GitHub job summary and, for anything blocked or
// unverifiable, a workflow annotation.
//
// The whole point of this guard is to be noisy: the bug it fixes ran silently
// for two days. Never make a skipped tag a quiet no-op.
//
// Inputs (environment):
// DOCKER_METADATA_OUTPUT_JSON metadata-action output; .tags is used
// TAGS newline/comma separated tags (alternative)
// GITHUB_SHA commit being published
// GITHUB_REPOSITORY owner/name, for the compare API
// GITHUB_TOKEN optional, raises the compare API rate limit
// REGISTRY_PREFIX optional, keep only tags with this prefix
//
// Exit codes:
// 0 decisions made (blocked tags are not an error; the build still ships
// its immutable sha- tag, which is the artifact humans pin)
// 1 bad input, e.g. no tag list at all
import fs from "node:fs";
import { guardTags } from "./lib/tag-guard.mjs";
function readTags() {
const raw = process.env.DOCKER_METADATA_OUTPUT_JSON;
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.tags)) {
return parsed.tags;
}
}
if (process.env.TAGS) {
return process.env.TAGS.split(/[\n,]/)
.map((t) => t.trim())
.filter(Boolean);
}
return [];
}
function appendSummary(lines) {
const path = process.env.GITHUB_STEP_SUMMARY;
if (!path) {
return;
}
try {
fs.appendFileSync(path, `${lines.join("\n")}\n`);
} catch (err) {
process.stderr.write(`tag-guard: could not write job summary: ${err}\n`);
}
}
async function main() {
const prefix = process.env.REGISTRY_PREFIX || "";
const refs = readTags().filter((t) => t.startsWith(prefix));
if (refs.length === 0) {
process.stderr.write(
`tag-guard: no tags to consider (REGISTRY_PREFIX=${prefix || "<none>"})\n`,
);
return;
}
const incomingSha = process.env.GITHUB_SHA;
if (!incomingSha) {
throw new Error("GITHUB_SHA is not set");
}
const { allowed, blocked, decisions } = await guardTags({
refs,
incomingSha,
repository: process.env.GITHUB_REPOSITORY || "mudler/LocalAI",
token: process.env.GITHUB_TOKEN,
});
const summary = ["", `#### Tag ordering guard (${incomingSha.slice(0, 7)})`, ""];
for (const d of decisions) {
process.stderr.write(`tag-guard: ${d.push ? "PUSH " : "SKIP "} ${d.reason}\n`);
summary.push(`- ${d.push ? "published" : "**skipped**"}: ${d.reason}`);
// Annotate anything that is not a plain, verified advance so it shows up on
// the run page without anyone having to open the log.
if (!d.push || d.severity === "warning") {
process.stderr.write(`::warning title=backend tag guard::${d.reason}\n`);
}
}
appendSummary(summary);
if (blocked.length > 0) {
process.stderr.write(
`tag-guard: ${blocked.length} mutable tag(s) withheld; ` +
`${allowed.length} tag(s) will be published\n`,
);
}
process.stdout.write(`${allowed.join("\n")}\n`);
}
main().catch((err) => {
process.stderr.write(`tag-guard: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});