Files
LocalAI/pkg/addons/packaging/packaging_test.go
T
mudler-agentandEttore Di Giacinto 7904b3d0ab feat(addons): declare typed host capabilities (#13034)
* feat(addons): model execution status observations

Keep observation lifetimes explicit before runtime integration. The pure
reducer rejects retries and stale replies using exact binding identity.

Cover all eleven trace families, the complete transition matrix and
bounded histories with safety and liveness checks.

Assisted-by: nib:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): visualize execution status traces

Generate a self-contained viewer from the real reducer and shared traces.
Check independent expectations, coverage and bounded invariants before
exporting data. Detect stale artifacts and nondeterministic generation
without maintaining a second reducer in JavaScript.

Assisted-by: nib:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): honor the viewer artifact root

Keep default viewer output under SDD_LOCAL_ROOT when configured.\nLet go generate use the same default without forcing a source artifact.\nRetain the portable fallback and explicit output and check semantics.

Assisted-by: nib:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add typed extension contracts

Validate optional home commands, execution bindings and requested host
operations without changing manifest versions or granting authority.
Decode bounded execution status payloads conservatively and document
strict staging compatibility for older hosts.

Assisted-by: nib:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): reject noncanonical extension keys

Reject case aliases before typed decoding can override canonical fields
or bypass presence checks. Keep locale maps outside exact-key checks.

Add regression coverage for status, declaration objects, and nested
entries. All status errors return unknown readiness and isolation.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): reject root enums for status tools

Status tools must accept the empty object. The supported schema enums
contain only strings, so a root enum excludes every object.

Reject root enums only for execution status bindings. Cover incompatible
root constraints and optional properties without changing shared validation.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): project typed home commands

Resolve declared home destinations and display metadata in the installed
Apps view. Pin packaged command icons to immutable asset URLs.

Cover strict declaration transport, immutable receipts, exact health echo,
and process restart. Keep legacy packages and disabled installed intent.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): preserve typed lifecycle declarations

Copy execution and host-operation declarations from installed receipts.
The existing capability completeness test exposes the missing fields.

Exercise real staged Manager upgrades, activation rollback and recovery.
Check installed Apps metadata through pinned mTLS frontends for each actor.
Verify mutation isolation and name receipt replay coverage accurately.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): stabilize fixture archive digests

Map iteration changes tar entry order between identical publishes.
Sort fixture filenames so unchanged packages retain their digest.

Check repeated publish bytes, digests and lexical archive entry order
with and without a manifest transform. Register the regression case.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): resolve explicit host policy grants

Load strict administrator policy files once before application startup.
Honor explicit empty overrides without reading ignored policy files.
Copy programmatic and manager grants to isolate startup authority.

Keep the legacy CLI fallback until the official deployment policy ships.
The generic resolver already defaults to empty grants.

Verify config, CLI, hostops and execution-status checks with GOMAXPROCS=2,
-p=2 and -ldflags="-s -w" to fit the available build disk.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): select explicit deployment grants

Move official host grants out of the generic executable into reviewed
host deployment policy. Stock Docker explicitly selects the shipped file;
unconfigured binaries and nil or empty programmatic grants grant nothing.

Document the standalone upgrade break and deliberate operator selection.
Do not infer consent from installed addons or merge explicit overrides.
Guard policy packaging and generic configuration boundaries with tests.

Verify executionstatus, viewer -check, config, CLI, packaging and root
architecture tests with auth, GOMAXPROCS=2 and bounded build parallelism.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): gate activation on required host ops

Refuse unmet required requests before package publication or runtime
withdrawal. Keep installed intent unavailable when startup grants change.
Optional requests permit degraded operation and never create authority.

Carry bounded admin diagnostics through Apps and the owner carrier.
Keep startup policy immutable and broker authorization authoritative.

Verify executionstatus, viewer -check, lifecycle, packages, updates,
hostops, Apps and gateway offline with auth and bounded parallelism.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): guard host operation registry parity

Compare startup admission names against real builtin registrations so
registry drift cannot silently change request satisfaction.

Cover dynamic progress and stop operations and connector inbound.
Reject missing, extra, duplicate and case-changed names with negative
controls. Record both regression cases in the suite inventory.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): fence status observation effects

Keep observation ownership in admitted runtime operations. Deny all
correlated callbacks before dispatch, including inbound-class callbacks.
Reject status outputs before callers can open or adopt artifact handles.

Retain bounded denial correlations until the runtime endpoint closes so
late callbacks cannot recover ordinary authority. Preserve independent
ordinary operations and document the dedicated read API for T9.

Verify the status model, viewer, and race-enabled gateway, hostops and
runtime suites with the pinned offline environment.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drain callbacks before retirement

Server.Close does not wait for active callback requests. Clearing retired
observation correlations first can restore inbound authority.

Stop endpoint admission before closing connections. Drain admitted requests
before releasing correlations, and refuse dispatch after Stop. Keep locks
out of handlers and cleanup waits so cancellation cannot deadlock teardown.

Cover pending inbound callbacks, decoded authorization paused across Stop,
and handlers that ignore cancellation. Preserve ordinary authorized work
on other endpoints. Document delayed reclamation under the existing bound.

Verify the status model and viewer, then race-test gateway, hostops,
runtime, the model and root architecture checks with auth enabled.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): bind owner status to exact clients

Add authenticated, versioned status reads over the owner carrier.
Revalidate actor permissions and provider selection before dispatch and
before returning an observation. Keep status separate from authority.

Admit the captured client against its immutable identity under the
withdrawal lock. Never dispatch an old binding through a replacement.
Generate runtime operation IDs on the owner and retain the T8 effect fence.

Cover forged identities, strict DTOs, stale replies, restart ordering,
wire discriminators, and unchanged model exclusion of uiOnly tools.
Document the T10 callback and request-shell integration requirements.

Verify the model and viewer, then race-test gateway, runtime, tools,
and the model with auth enabled.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): compose owner status with runtime

Exercise installed addon processes through the authenticated mTLS carrier.
Keep runtime clients real while channel barriers control lookup ordering.

Reject captured clients after same-generation replacement before dispatch.
Reject completed status reads when replacement precedes owner consumption.
Prove fresh bindings succeed and record named tools and host operation IDs.

Verify the model and viewer, then race-test gateway, runtime, tools,
the model, and the protocol fixture package with auth enabled.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(studio): scope execution status observations

Run the execution status reducer within one explicit request lifetime.
Coalesce consumers without retries and withdraw stale or canceled reads.

Keep owner admission and production lifecycle integration separate for
review before wiring the shell into requests and turns.

Assisted-by: OpenAI:ChatGPT
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): isolate consumer cancellation

Consumer cancellation during validation must not withdraw a valid
observation from other consumers. Return unknown locally before shared
invalidation, while preserving parent closure and freshness failures.

Cover cancellation and deterministic deadline expiry with one-read
reducer traces and live-consumer checks.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): track selection mutation epochs

Detect selection ABA at config and owner mutation boundaries. Clone
provider pins for Studio readers and share synchronized selection state
across config value copies.

Track lifecycle publication, candidate rollback and recovery transitions.
Keep reads and unchanged publications stable. These lifetime epochs do
not replace current-incarnation checks or grant execution authority.

Verify race tests for config and the add-on Manager, plus application
compilation with auth tags. Request binding remains separate work.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): expose owner binding transport

Add a host-only binding API with an optional owner resolver. Forward
conversation claims over authenticated mTLS with strict versioned DTOs.
Compare full bindings during validation and deny missing resolvers.

Keep ordinary tool execution unchanged through an optional backend.
Test trusted resolver fixtures, stale bindings, denial and compatibility.
Application authority and store revisions remain a separate milestone.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(studio): fence selection snapshots with epochs

Read actor-scoped records and opaque mutation tokens under store locks.
Invalidate tokens after successful eligibility changes, including ABA.
Keep reads, rejected writes and turn history from invalidating tokens.
Clone mutable records so callers cannot bypass mutation tracking.

Support authoritative revisions only for memory-only JSON stores.
File stores lack exclusive ownership; GORM lacks persisted revisions.
Fail closed for both rather than trust frontend-local state.
Ordinary chats remain unchanged; application integration stays separate.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): enforce persistent JSON ownership

Acquire lifetime sidecar locks before loading or repairing JSON data.
Enable selection revisions only under cooperating exclusive ownership.

Close drains operations before releasing ownership and rejects later use.
Clean up unpublished stores when application initialization fails.
Retain published locks through Shutdown because HTTP users may remain live.

Document coordinated upgrades and the advisory local-filesystem boundary.
Keep GORM revisions unsupported pending a separate approved design.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Codex:gpt-5

* test(application): isolate warning log capture

Run warning assertions in isolated test subprocesses. The suite leaves
background log producers running, which race with global logger changes.

Keep the parent logger unchanged and check child exits and completion
markers. Preserve pin persistence and ownership warning assertions.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(studio): resolve local owner execution bindings

Resolve execution providers from owner-held chat and agent snapshots.
Fence bindings with selection epochs, mutation tokens and live runtime
identity. Recompute every component during status authorization.

Keep named-user and SQL observations unavailable until authoritative
access and shared selection revisions can detect ABA changes.

Drain restart test producers before releasing JSON store ownership.
Register six product resolver cases in the gateway suite inventory.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(studio): bound product scopes to chat turns

Keep request-local product state alive through tool calls, checkpoints
and persistence. Give chat priming its own scope instead of seeding the
next turn with a closed observation.

Exercise one reducer read per scope across prompt and tool consumers.
Check closure of pending reads on normal completion and interruption.
Provider wiring and safety projection remain separate work.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(studio): consume typed execution providers

Replace the Sandbox-specific status heuristic with owner-bound typed reads.
Share one reducer observation across turn consumers and close it at the
outer scope boundary. Keep priming and standalone requests independent.

Sandbox exposes a separate callback-free typed read while preserving its
private Settings payload. Unknown backends never imply isolation.
Provider isolation does not prove host network-policy enforcement.

Local-owner JSON bindings are supported. Named-user and SQL bindings
remain explicitly unavailable and fail closed.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(application): isolate auth startup lifetime

Shutdown does not join every background producer. Auth startup tests
can therefore delete their temporary directory while a writer remains.

Run each case in a bounded child process. The parent waits for child
exit before deleting the fixture, preserving every startup assertion.
Production shutdown and JSON ownership retention remain unchanged.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): resolve installed Home app commands

Project Home navigation from installed addon declarations, not product IDs.
Reserve native and skill names and suppress every colliding addon alias.

Re-read the authenticated registry before navigation and reject stale
actors, generations, immutable entries and destinations. Keep mounted
consumers polling through disabled and failed states. Abort pending reads
on actor changes and ignore replies from obsolete request epochs.

Studio declares its own alias. Preserve legacy redirects and pin migration.
Add unit coverage, localized Home copy and command lifecycle documentation.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): accept resolved Home command metadata

Apps resolves inherited titles beyond the explicit manifest label cap.
Bound resolved labels to 147 Unicode characters, including secondary pages.
Keep the backend's 60-character limit for explicit manifest labels.

Match packaged icon paths to the contract's ASCII alphabet and image types.
Accept dotted names while rejecting traversal, encodings and foreign assets.

Verify 68 focused cases, 657 total cases, lint, i18n checks and the UI build.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): bind safety notices to actual providers

Warn for addon host execution and unknown isolation without changing tool
permissions or built-in host consent. Reset dismissal when the admitted
binding, observation, actor or chat context changes.

Expose presentation-only status identity from the admitted request.
Keep configured network policy visible when enforcement is unverified.
Render bounded provider explanations as escaped React text.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): isolate command status across sessions

Invalidate command observations when actors or authentication sessions
change, before effects can expose a previous actor’s reply.

Keep actual host execution warnings generic instead of recommending
Sandbox installation or repair. Cover both regressions with source-hook
lifecycle tests and provider notice assertions.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): declare audited host requests

Declare actual official hostclient operations without inferring requests
from deployment grants. Keep optional settings and egress checks separate
from required workspace and job operations.

Verify all twelve manifests through strict package fixtures and assert
that requests never self-grant. Guard generic provider and Home dispatch
without banning historical routes, native Studio, or deployment policy.

Document the audited operations and unresolved named-user and SQL gaps.
Refresh Sandbox catalog metadata for its existing typed status tool.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): wire exact extension inventories and real provider selection

Run contract, official packaging and root architecture gates through actual report inventories. Exercise two installed execution providers through the real owner resolver and distinguish callback pass-through coverage. Share strict report validation with focused-scope handling and missing/skipped artifact self-tests; document direct evidence and remaining harness limits.

Assisted-by: Codex:gpt-5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): expire registry reads on auth refresh

Bind registry authority to the actual authentication session epoch.
Same-user refresh now rejects old polls and pending Home navigation
before effect cleanup, even when permissions and metadata stay unchanged.

Exercise both races through the production registry provider harness.
Verify 59 focused tests, 669 total tests, lint, i18n, and the UI build.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): resolve current execution selection

Use existing principal-scoped chat and agent reads for advisory status.
Revalidate current named credentials instead of requiring historical SQL
revisions. Local-owner mode also works with the existing GORM store.

Remove resolver nonces and historical revision prerequisites. Keep exact
runtime binding and existing status admission hooks. Document current-read
consistency without promising revoke-and-restore detection.

Verify the SQLite regression fails before the fix. Two separate full race
runs pass: application 262 specs (3 skips), auth 257, gateway 115, tools 91.
Cover named chat, API key and session credentials and current-state denial.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): drop selection revision tracking

Use current selection comparisons for advisory status instead of mutation
history. Keep actor, credential and conversation identity explicit in the
model. Hash current inputs at the gateway without exposing raw store data.

Remove unused epochs, revision interfaces and JSON ownership lifetimes.
Preserve current authorization, runtime identity, mutexes and atomic writes.
Document current-read semantics without historical ABA guarantees.

Verify all affected packages with the race detector and auth tag. Check
the trace viewer, prerequisite self-tests and 18 chat-command unit tests.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): scope Apps reads and provider status

Bind Apps replies and registry ingestion to their originating auth session.
Reject late replies before effect cleanup on refresh or account switch.

Use selected-provider command DTOs in the composer instead of private
Sandbox discovery. Keep unknown isolation unknown and remove specific
add-on installation recommendations from the affected native surfaces.

Test production Apps, registry, switches and composer paths with controlled
cleanup and two arbitrary providers. Update the matching feature docs.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): guard composer and session publication

Remove the deleted Sandbox variable from the full composer render.
Report unknown command isolation explicitly in the about card.

Bind Apps records and admin action results to their originating actor
and actual authentication epoch before cleanup can run. Reject stale
request admission and verification without changing server authorization.

Add full-component render and pending session-transition regressions.
Enable no-undef for the production composer in normal UI lint.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): expire cancelled upgrade reconciliation

Bind cancellation reconciliation to the initiating session authority.
Discard status replies after expiry without publishing old outcomes.
Match the exact operation identity before clearing a held action.

Cover pending cancellation reads, expiry before reads, and preservation
of a newer attempt with the same action key.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): bind background upgrades to auth lifetime

Keep background upgrade authority in AuthProvider, independent of views.
Expire pending observations before auth consumers render or logout waits.
Preserve same-session navigation and exact-operation cleanup.

Cover cancellation, completion, and verification after the origin unmounts
with actual provider transitions and the real upgrade store.
Thread captured authority through Activity retries and document expiry.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): remove implicit sidebar pins

Start every account without implicit Studio or Talk pins. Preserve saved
user selections and durable explicit pins across reopen and frontends.

Remove startup seeding, seed metadata APIs, and first-install exceptions.
Apply equal retention to all absent IDs without dropping database tables.
Keep store initialization, cached errors, and the leader sweep unchanged.

Verify genuine RED regressions before removal. Race checks pass for all
42 pin specs, 10 focused application specs, and 10 pin endpoint specs.
Update pin documentation; renamed specs have no exact inventory entries.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): declare typed model destinations

Let installed addons own model launch destinations without teaching core
about addon identities or private routes. Keep native chat and completion
outside the declaration vocabulary.

Validate strict keys, unique capabilities, declared pages and SDK route
bounds. Carry cloned typed metadata through Apps using the shared
contract type, and add owning Studio, Talk, Face and Voice declarations.

Verify strict contract RED before implementation, then 1276 passing
specs across contract, Apps, packages, runtime, protocol, config and manager.
Verify 178 packaging and architecture specs and exact carrier inventories.
Frontend dispatch and route cleanup remain a separate milestone.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(ui): resolve declared model launch actions

Resolve installed model actions through the shared addon registry. Keep
native chat and completion launches, and never promote absent providers.

Refresh actor-filtered Apps before navigation and reject stale authority,
provider metadata, generation, immutable entry and destination claims.
Use named menu choices for collisions without loading addon modules.

Remove unreleased addon-specific redirects and migrate browser fixtures
to canonical declared pages. Preserve native chat handoff and addon-local
navigation adapters. Update user documentation and historical plans.

Verify three production UI consumers fail against their previous code,
then pass all 13 mocked-HTTP browser regressions. Verify 696 host unit
cases, full quiet lint, targeted no-undef, locales, inline styles and build.
Full backend/browser integration remains outside these mocked-HTTP checks.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): validate Ginkgo hooks before focus

Ginkgo emits null container hierarchies for suite hooks. Require hooks\nto pass before applying focus only to It cases. Normalize null\nhierarchies without weakening exact required-case comparisons.\n\nRun reporter regressions in prerequisites. Register the declared uiOnly\ntool case and remove an update fixture case excluded by the existing\nmanager focus. Both inventories match the complete CI case output.\n\nVerify 12 reporter regressions, 39 prerequisite cases, three existing\nreports, and fresh runtime, tools and focused update manager suites.

Assisted-by: Codex:gpt-5 [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): restore Go CI contract checks

Align route fixtures with explicit pins and declared network controls.
Keep network enforcement unknown instead of inferring it from isolation.

Propagate viewer output errors and select output paths with CLI arguments.
Check test writes and use Ginkgo assertions without copying atomic state.
Register the three migrated binding cases in the exact suite inventory.

Document two narrow security false positives for administrator file access
and symbolic credentials in reducer traces. Focused race tests pass.
The available golangci-lint reports zero issues in affected packages.

Assisted-by: OpenAI ChatGPT [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): align browser coverage with addon contracts

The generic provider refactor removes implicit install advice and private
Sandbox discovery. Update browser fixtures to use the installed command
metadata and typed execution observations without restoring those paths.
Keep command warnings, consent, disabled switches and privacy assertions.

Home now holds a live command consumer. Exercise polling shutdown on a
route without that consumer, after its lazy transition finishes. Preserve
the bounded cadence, error backoff and disable-stop assertions.

Restore the remembered-voices link through the addon's compatibility
router. Cover native migrations and NotFound for removed generator aliases.

Validation: 293 affected browser cases and three native service cases pass.
The gallery command passes all 74 inventoried cases. Full UI units, quiet
lint, locale parity, production build and diff checks pass.

Assisted-by: OpenAI ChatGPT [functions]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): bind status to owner provider policy

Resolve typed status with the same current owner pins as execution.
Fail closed on owner policy outages and fingerprint authoritative pins.

Exercise CAS updates, conflicts, stale bindings, and policy outages.
Retain host settings when the named-account fixture replaces its gateway.
Refresh exact gateway and Apps inventories from passing reports.

Validation: the new regression fails before the fix. All 16 focused
binding and policy cases pass. Gateway passes 361 exact cases and Apps
passes 265 exact cases across their configured package reports.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): share qualified tool identities

Use one formatter and contract-component parser for add-on identities.
Keep callable names bounded to 64 characters while recognizing long
unavailable stand-ins without granting dispatch authority.

Validate selected execution names and their provider ID at the owner
binding boundary. The real adapter already emits canonical identities.
Update exact importer and case inventories from passing reports.

Migrate the binding backend test to Ginkgo and check shutdown errors
reported by lint in the affected packages.

Validation: behavioral RED proves malformed stand-in acceptance and
plain selected-name acceptance. Affected full race suites pass: tools
94 and Studio 638, with zero skips. Application passes 275 of 278;
three existing PostgreSQL cases skip without their configured URL. All 12 owner-binding cases
pass and match the exact inventory. Scoped golangci-lint reports no issues.

Assisted-by: Codex:gpt-5 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): share execution declarations

Share pure declaration predicates across manifest validation, gateway
observations, and owner bindings. Keep authorization and exact-runtime
admission checks local. Reuse the strict manifest schema validator so
raw status definitions fail closed without changing manifest acceptance.

Share the workspace-operation prefix scan without changing grant sources.
Add declaration parity and workspace-prefix tables and update exact
inventories from passing reports.

Validation: two new gateway cases fail before the fix; all 14 pass after.
Race tests pass: contract 245, gateway 136, tools 94, and focused
application binding/workspace cases 22. Scoped golangci-lint: 0 issues.

Assisted-by: Codex:gpt-5 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): retain document skill cases

Match the contract inventory to the passing report after rebasing.\nKeep the new document-provider case and updated reserved-name cases.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

Assisted-by: OpenAI:gpt-6.1-sol

* test(addons): follow owner-managed upgrades

Accepted upgrades belong to the owner and cannot be cancelled by UI.
Exercise observer expiry during reads, completion and verification.
Supply the real upgrade store to source-executed auth harnesses so
logout reaches its synchronous authority invalidation.

Match whole-package inventories to fresh Manager and gateway reports.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-02 23:50:40 +00:00

1487 lines
74 KiB
Go

// SPDX-License-Identifier: MIT
package packaging
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"gopkg.in/yaml.v3"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
amd = "linux/amd64"
arm = "linux/arm64"
rev = "0123456789abcdef0123456789abcdef01234567"
)
var both = map[string][]string{amd: nil, arm: nil}
var _ = Describe("package layout", func() {
It("builds a deterministic platform layout with executable, ui and manifest layers", func() {
p := newProject(both)
a := p.mustBuild(amd, "1.2.3", "same bytes", "--revision", rev)
b := p.mustBuild(amd, "1.2.3", "same bytes", "--revision", rev)
la, ra, err := loadLeg(a)
Expect(err).NotTo(HaveOccurred())
lb, _, err := loadLeg(b)
Expect(err).NotTo(HaveOccurred())
Expect(la.descriptor.Digest).To(Equal(lb.descriptor.Digest))
Expect(la.manifest.ArtifactType).To(Equal(ArtifactType))
Expect(la.manifest.Annotations).To(HaveKeyWithValue(AnnotationVersion, "1.2.3"))
Expect(la.manifest.Annotations).To(HaveKeyWithValue(AnnotationPlatform, amd))
Expect(la.manifest.Annotations).To(HaveKeyWithValue(AnnotationRevision, rev))
Expect(la.config.OS).To(Equal("linux"))
Expect(la.config.Architecture).To(Equal("amd64"))
var roles []string
for _, l := range ra.Layers {
roles = append(roles, l.Role)
Expect(l.MediaType).To(Equal("application/vnd.oci.image.layer.v1.tar+gzip"))
}
Expect(roles).To(Equal([]string{"executable", "ui", "skills", "manifest"}))
Expect(ra.Publishable).To(BeTrue())
Expect(p.verify(a, amd, "1.2.3")).To(Succeed())
other := p.mustBuild(amd, "1.2.3", "other bytes", "--revision", rev)
lo, _, _ := loadLeg(other)
Expect(lo.descriptor.Digest).NotTo(Equal(la.descriptor.Digest))
})
It("restores the validated executable mode on extraction", func() {
p := newProject(both)
dir := p.mustBuild(amd, "1.2.3", "bin")
dest := filepath.Join(GinkgoT().TempDir(), "out")
_, err := run("extract", "--config", p.config, "--layout", dir, "--platform", amd, "--version", "1.2.3", "--dest", dest)
Expect(err).NotTo(HaveOccurred())
info, err := os.Stat(filepath.Join(dest, "tables"))
Expect(err).NotTo(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o755)))
ui, err := os.Stat(filepath.Join(dest, "ui", "tables.js"))
Expect(err).NotTo(HaveOccurred())
Expect(ui.Mode().Perm()).To(Equal(os.FileMode(0o644)))
m, err := os.ReadFile(filepath.Join(dest, "manifest.json"))
Expect(err).NotTo(HaveOccurred())
Expect(string(m)).To(ContainSubstring(`"version": "1.2.3"`))
Expect(string(m)).To(ContainSubstring(`"arch": "amd64"`))
})
It("refuses a platform that the matrix has not enabled unless it is a local check", func() {
p := newProject(map[string][]string{amd: nil})
_, err := p.build(arm, "1.2.3", exe("x"))
Expect(err).To(MatchError(ContainSubstring("not enabled")))
dir, err := p.build(arm, "1.2.3", exe("x"), "--local-check")
Expect(err).NotTo(HaveOccurred())
_, rec, err := loadLeg(dir)
Expect(err).NotTo(HaveOccurred())
Expect(rec.Publishable).To(BeFalse())
_, err = p.build("windows/amd64", "1.2.3", exe("x"))
Expect(err).To(HaveOccurred())
})
It("refuses an invalid version, an existing layout and an empty executable", func() {
p := newProject(both)
for _, v := range []string{"", "v1.2.3", "1.2", "1.2.3+build", "latest", "01.2.3"} {
_, err := p.build(amd, v, exe("x"))
Expect(err).To(HaveOccurred(), v)
}
dir := p.mustBuild(amd, "1.2.3", "x")
_, err := run("build", "--config", p.config, "--root", p.root, "--layout", dir, "--version", "1.2.3", "--platform", amd, "--executable", exe("x"))
Expect(err).To(MatchError(ContainSubstring("not empty")))
_, err = p.build(amd, "1.2.3", exe(""))
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("package rules", func() {
var p *project
var dir string
BeforeEach(func() {
p = newProject(map[string][]string{amd: {"lib/libruntime.so"}, arm: nil})
rt := filepath.Join(GinkgoT().TempDir(), "rt")
Expect(os.MkdirAll(filepath.Join(rt, "lib"), 0o755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(rt, "lib", "libruntime.so"), []byte("library"), 0o644)).To(Succeed())
var err error
dir, err = p.build(amd, "1.2.3", exe("binary"), "--runtime-dir", rt)
Expect(err).NotTo(HaveOccurred())
Expect(p.verify(dir, amd, "1.2.3")).To(Succeed())
})
It("rejects a package without its entrypoint", func() {
s := readSpec(dir)
find(s, "executable").drop("tables")
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_ENTRYPOINT")
})
It("rejects a declared UI whose layer or files are missing", func() {
s := readSpec(dir)
find(s, "ui").drop("ui/tables.js")
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_UI")
s = readSpec(dir)
var kept []layerSpec
for _, l := range s {
if l.role != "ui" {
kept = append(kept, l)
}
}
rewrite(dir, kept, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_UI")
})
It("rejects a package without a required runtime library", func() {
s := readSpec(dir)
find(s, "executable").drop("lib/libruntime.so")
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_RUNTIME")
_, err := p.build(amd, "1.2.4", exe("binary"))
Expect(err).To(MatchError(ContainSubstring("--runtime-dir")))
_, err = p.build(amd, "1.2.4", exe("binary"), "--runtime-dir", GinkgoT().TempDir())
Expect(err).To(MatchError(ContainSubstring("runtime file")))
})
It("rejects a manifest whose platform differs from the image", func() {
s := readSpec(dir)
f := find(s, "manifest").get("manifest.json")
f.data = bytes.Replace(f.data, []byte(`"os": "linux"`), []byte(`"os": "darwin"`), 1)
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_PLATFORM")
})
It("rejects an image config or annotation of another platform", func() {
rewrite(dir, readSpec(dir), func(_ *v1.Manifest, c *v1.ConfigFile, _ *Receipt) { c.Architecture = "arm64" })
expectCode(p.verify(dir, amd, "1.2.3"), "E_PLATFORM")
})
It("rejects a manifest version that differs from the release version", func() {
s := readSpec(dir)
f := find(s, "manifest").get("manifest.json")
f.data = bytes.Replace(f.data, []byte(`"version": "1.2.3"`), []byte(`"version": "9.9.9"`), 1)
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_VERSION")
expectCode(p.verify(dir, amd, "9.9.9"), "E_VERSION")
})
It("rejects another artifact type", func() {
rewrite(dir, readSpec(dir), func(m *v1.Manifest, _ *v1.ConfigFile, _ *Receipt) { m.ArtifactType = "application/vnd.example.other" })
expectCode(p.verify(dir, amd, "1.2.3"), "E_ARTIFACT_TYPE")
})
It("rejects unexpected layers, roles and layer media types", func() {
s := readSpec(dir)
extra := append(append([]layerSpec{}, s...), layerSpec{role: "extra", files: []tfile{{name: "extra.bin", data: []byte("x"), mode: 0o644}}})
rewrite(dir, extra, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS")
s = readSpec(dir)[:4]
s[0], s[1] = s[1], s[0]
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS")
s = readSpec(dir)
s[0], s[1] = s[1], s[0]
s[0].mediaType = "application/vnd.docker.image.rootfs.diff.tar.gzip"
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS")
})
It("rejects an unexpected file in the executable layer", func() {
s := readSpec(dir)
e := find(s, "executable")
e.files = append(e.files, tfile{name: "payload.sh", data: []byte("x"), mode: 0o644})
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS")
})
It("rejects a blob whose bytes differ from its digest", func() {
l, _, err := loadLeg(dir)
Expect(err).NotTo(HaveOccurred())
path := filepath.Join(dir, "blobs", "sha256", l.manifest.Layers[0].Digest.Hex)
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
b[len(b)/2] ^= 0xff
Expect(os.WriteFile(path, b, 0o644)).To(Succeed())
expectCode(p.verify(dir, amd, "1.2.3"), "E_DIGEST")
})
It("rejects a receipt that does not match the image", func() {
rb, err := os.ReadFile(filepath.Join(dir, receiptFile))
Expect(err).NotTo(HaveOccurred())
var r Receipt
Expect(json.Unmarshal(rb, &r)).To(Succeed())
r.ManifestDigest = "sha256:" + strings.Repeat("0", 64)
rb, _ = json.Marshal(r)
Expect(os.WriteFile(filepath.Join(dir, receiptFile), rb, 0o644)).To(Succeed())
expectCode(p.verify(dir, amd, "1.2.3"), "E_DIGEST")
Expect(os.Remove(filepath.Join(dir, receiptFile))).To(Succeed())
expectCode(p.verify(dir, amd, "1.2.3"), "E_DIGEST")
})
It("rejects invalid extraction permissions", func() {
cases := map[string]func(*layerSpec){
"set-uid executable": func(l *layerSpec) { l.get("tables").mode = 0o4755 },
"world-writable file": func(l *layerSpec) { l.get("lib/libruntime.so").mode = 0o666 },
"executable library": func(l *layerSpec) { l.get("lib/libruntime.so").mode = 0o755 },
"non-executable entrypoint": func(l *layerSpec) { l.get("tables").mode = 0o644 },
}
for label, mut := range cases {
d := p.mustBuild(amd, "1.2.5", "b", "--local-check", "--runtime-dir", mustRuntime())
s := readSpec(d)
mut(find(s, "executable"))
rewrite(d, s, nil)
err := p.verify(d, amd, "1.2.5")
expectCode(err, "E_MODE")
_ = label
}
})
It("rejects a UI file that is executable or outside the declared directory", func() {
s := readSpec(dir)
find(s, "ui").get("ui/tables.css").mode = 0o755
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_MODE")
s = readSpec(dir)
find(s, "ui").get("ui/tables.css").mode = 0o644
u := find(s, "ui")
u.files = append(u.files, tfile{name: "other/x.js", data: []byte("x"), mode: 0o644})
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYERS")
})
It("rejects traversal paths, links and duplicates", func() {
for _, bad := range []tfile{
{name: "../escape", data: []byte("x"), mode: 0o644},
{name: "/abs", data: []byte("x"), mode: 0o644},
{name: "ui/link", typeflag: 's', link: "../../etc/passwd", mode: 0o644},
{name: "tables", data: []byte("dup"), mode: 0o755},
} {
s := readSpec(dir)
e := find(s, "executable")
e.files = append(e.files, bad)
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_PATH")
dir = p.mustBuild(amd, "1.2.3", "binary", "--local-check", "--runtime-dir", mustRuntime())
}
})
It("rejects a UI that bundles its own React runtime", func() {
s := readSpec(dir)
f := find(s, "ui").get("ui/tables.js")
f.data = append(f.data, []byte("var x=__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;")...)
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_REACT")
})
It("rejects an icon that is not an image of its type", func() {
s := readSpec(dir)
find(s, "ui").get("ui/icon.png").data = []byte("not a png")
rewrite(dir, s, nil)
expectCode(p.verify(dir, amd, "1.2.3"), "E_UI")
})
It("rejects an invalid platform layout marker or index", func() {
Expect(os.WriteFile(filepath.Join(dir, "oci-layout"), []byte(`{"imageLayoutVersion":"2.0.0"}`), 0o644)).To(Succeed())
expectCode(p.verify(dir, amd, "1.2.3"), "E_LAYOUT")
})
})
func mustRuntime() string {
GinkgoHelper()
rt := filepath.Join(GinkgoT().TempDir(), "rt")
Expect(os.MkdirAll(filepath.Join(rt, "lib"), 0o755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(rt, "lib", "libruntime.so"), []byte("library"), 0o644)).To(Succeed())
return rt
}
var _ = Describe("platform matrix", func() {
write := func(body string) string {
p := filepath.Join(GinkgoT().TempDir(), "platforms.yaml")
Expect(os.WriteFile(p, []byte(body), 0o644)).To(Succeed())
return p
}
head := "schema: 1\naddon: tables\nexecutable: tables\nmanifest: m.json\nplatforms:\n"
It("fails closed when no platform is enabled, the file is absent or empty", func() {
_, err := run("matrix", "--config", write(head+" - platform: linux/amd64\n enabled: false\n"))
Expect(err).To(MatchError(ContainSubstring("no enabled platform")))
_, err = run("matrix", "--config", filepath.Join(GinkgoT().TempDir(), "absent.yaml"))
Expect(err).To(HaveOccurred())
_, err = run("matrix", "--config", write(""))
Expect(err).To(HaveOccurred())
_, err = run("matrix", "--config", write("schema: 1\naddon: tables\nexecutable: tables\nmanifest: m.json\nplatforms: []\n"))
Expect(err).To(MatchError(ContainSubstring("no enabled platform")))
_, err = run("matrix")
Expect(err).To(HaveOccurred())
})
It("rejects enabled platforms without a native runner or check, duplicates and unknown fields", func() {
for _, body := range []string{
head + " - platform: linux/amd64\n enabled: true\n",
head + " - platform: linux/amd64\n enabled: true\n runner: r\n",
head + " - platform: linux/amd64\n enabled: false\n - platform: linux/amd64\n enabled: false\n",
head + " - platform: windows/amd64\n enabled: false\n",
head + " - platform: linux/amd64\n enabled: false\n registry: quay.io/x\n",
head + " - platform: linux/amd64\n enabled: false\n runtime: [../escape]\n",
} {
_, err := run("matrix", "--config", write(body))
Expect(err).To(HaveOccurred(), body)
}
})
It("prints the enabled platforms with their native runners", func() {
out, err := run("matrix", "--config", write(head+" - platform: linux/arm64\n enabled: true\n runner: r2\n native_check: executable-start\n - platform: linux/amd64\n enabled: true\n runner: r1\n native_check: executable-start\n - platform: darwin/arm64\n enabled: false\n"))
Expect(err).NotTo(HaveOccurred())
Expect(strings.TrimSpace(out)).To(Equal(`{"include":[{"platform":"linux/amd64","runner":"r1","slug":"linux-amd64"},{"platform":"linux/arm64","runner":"r2","slug":"linux-arm64"}]}`))
})
It("ships a checked-in Tables matrix with at least one enabled platform", func() {
cfg, err := loadPlatforms("../../../addons/tables/package/platforms.yaml")
Expect(err).NotTo(HaveOccurred())
enabled, err := cfg.enabled()
Expect(err).NotTo(HaveOccurred())
Expect(enabled).NotTo(BeEmpty())
})
})
var _ = Describe("event policy and channels", func() {
sha40 := rev
long := strings.Repeat("a", 1500)
decision := func(event, ref string) Decision {
GinkgoHelper()
d, err := decide(event, ref, sha40, false, false)
Expect(err).NotTo(HaveOccurred())
return d
}
It("never publishes from a pull request, whatever the ref", func() {
for _, ref := range []string{"refs/pull/1/merge", "refs/heads/master", "refs/tags/v1.0.0"} {
for _, ev := range []string{"pull_request", "pull_request_target"} {
d := decision(ev, ref)
Expect(d.Publish).To(BeFalse())
Expect(d.Aliases).To(BeEmpty())
Expect(d.Immutable).To(BeEmpty())
}
}
})
It("maps master alone to development and publishes the commit tag", func() {
d := decision("push", "refs/heads/master")
Expect(d.Publish).To(BeTrue())
Expect(d.Aliases).To(Equal([]string{"development"}))
Expect(d.Immutable).To(Equal("sha-0123456789ab"))
Expect(d.Version).To(Equal("0.0.0-dev.0123456789ab"))
})
It("never publishes from a branch push, whatever the branch is named", func() {
for _, ref := range []string{"refs/heads/Master", "refs/heads/master2", "refs/heads/feature/master", "refs/heads/development", "refs/heads/MASTER", "refs/heads/latest", "refs/heads/feature/x"} {
d := decision("push", ref)
Expect(d.Publish).To(BeFalse(), ref)
Expect(d.Aliases).To(BeEmpty(), ref)
Expect(d.Immutable).To(BeEmpty(), ref)
Expect(d.Channel).To(BeEmpty(), ref)
}
})
It("keeps reserved names out of the branch namespace", func() {
for _, br := range []string{"latest", "development", "Latest", "1.2.3", "v1.2.3", "sha-0123456789ab", "branch-x-000000000000", "0.0.0-dev.0123456789ab"} {
tag := branchTag("refs/heads/" + br)
Expect(tag).To(MatchRegexp(`^branch-[a-z0-9]+(-[a-z0-9]+)*-[0-9a-f]{12}$`), br)
Expect(tag).NotTo(Equal("latest"))
Expect(tag).NotTo(Equal("development"))
Expect(versionRE.MatchString(tag)).To(BeFalse())
Expect(immutableTagRE.MatchString(tag)).To(BeFalse())
}
})
It("separates refs that normalize to the same label", func() {
a := branchTag("refs/heads/Feature/X")
b := branchTag("refs/heads/feature-x")
c := branchTag("refs/heads/feature/x")
d := branchTag("refs/heads/feature_x")
Expect(branchLabel("Feature/X")).To(Equal(branchLabel("feature-x")))
Expect([]string{a, b, c, d}).To(HaveLen(4))
seen := map[string]bool{}
for _, t := range []string{a, b, c, d} {
Expect(seen).NotTo(HaveKey(t))
seen[t] = true
}
Expect(branchTag("refs/heads/feature/x")).To(Equal(c))
})
It("bounds empty, Unicode and very long branch names", func() {
for _, br := range []string{"日本語", "ünïcode/Ö", "!!!", "-", "a b c", long} {
tag := branchTag("refs/heads/" + br)
Expect(len(tag)).To(BeNumerically("<=", 7+maxLabel+1+12), br)
Expect(tag).To(MatchRegexp(`^branch-[a-z0-9-]+-[0-9a-f]{12}$`))
}
Expect(branchTag("refs/heads/日本語")).NotTo(Equal(branchTag("refs/heads/中文")))
_, err := decide("push", "", sha40, false, false)
Expect(err).To(HaveOccurred())
_, err = decide("push", "refs/heads/", sha40, false, false)
Expect(err).To(HaveOccurred())
_, err = decide("push", "refs/heads/"+strings.Repeat("a", 5000), sha40, false, false)
Expect(err).To(HaveOccurred())
_, err = decide("push", "refs/heads/x\nlatest", sha40, false, false)
Expect(err).To(HaveOccurred())
})
It("lets a tag push prepare the immutable version and never move latest", func() {
d := decision("push", "refs/tags/v1.2.3")
Expect(d.Publish).To(BeTrue())
Expect(d.Immutable).To(Equal("1.2.3"))
Expect(d.Aliases).To(BeEmpty())
for _, ref := range []string{"refs/tags/latest", "refs/tags/v1.2", "refs/tags/1.2.3", "refs/tags/development", "refs/tags/v1.2.3+x"} {
_, err := decide("push", ref, sha40, false, false)
Expect(err).To(HaveOccurred(), ref)
}
})
It("promotes latest only for a published, stable, non-draft release", func() {
d, err := decide("release", "refs/tags/v1.2.3", sha40, false, false)
Expect(err).NotTo(HaveOccurred())
Expect(d.Aliases).To(Equal([]string{"latest"}))
Expect(d.Channel).To(Equal("latest"))
Expect(d.Immutable).To(Equal("1.2.3"))
d, err = decide("release", "refs/tags/v1.2.3", sha40, false, true)
Expect(err).NotTo(HaveOccurred())
Expect(d.Aliases).To(BeEmpty())
d, err = decide("release", "refs/tags/v1.2.3-rc.1", sha40, false, false)
Expect(err).NotTo(HaveOccurred())
Expect(d.Aliases).To(BeEmpty())
_, err = decide("release", "refs/tags/v1.2.3", sha40, true, false)
Expect(err).To(HaveOccurred())
_, err = decide("release", "refs/heads/master", sha40, false, false)
Expect(err).To(HaveOccurred())
})
It("rejects unknown events and a malformed commit", func() {
for _, ev := range []string{"", "workflow_dispatch", "schedule", "workflow_run"} {
_, err := decide(ev, "refs/heads/master", sha40, false, false)
Expect(err).To(HaveOccurred(), ev)
}
_, err := decide("push", "refs/heads/master", "abc", false, false)
Expect(err).To(HaveOccurred())
})
It("prints the decision as key=value lines", func() {
out, err := run("channel", "--event", "push", "--ref", "refs/heads/master", "--sha", sha40)
Expect(err).NotTo(HaveOccurred())
Expect(out).To(ContainSubstring("publish=true\n"))
Expect(out).To(ContainSubstring("aliases=development\n"))
_, err = run("channel", "--event", "push", "--ref", "refs/heads/x")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("merge and completeness", func() {
It("joins every enabled platform leg into one verified index", func() {
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
cfg, err := loadPlatforms(p.config)
Expect(err).NotTo(HaveOccurred())
got, digest, err := verifyMerged(m, cfg)
Expect(err).NotTo(HaveOccurred())
Expect(got.Legs).To(HaveLen(2))
Expect(digest.String()).To(Equal(got.IndexDigest))
again := p.merged("1.2.3", rev, nil)
_, d2, err := verifyMerged(again, cfg)
Expect(err).NotTo(HaveOccurred())
Expect(d2).To(Equal(digest))
})
It("never creates a partial index when an enabled platform has no leg", func() {
p := newProject(both)
leg := p.mustBuild(amd, "1.2.3", "x", "--revision", rev)
_, err := run("merge", "--config", p.config, "--out", filepath.Join(GinkgoT().TempDir(), "m"), "--version", "1.2.3", leg)
expectCode(err, "E_PLATFORMS")
})
It("refuses duplicate legs, local-check legs, other versions and extra platforms", func() {
p := newProject(both)
a := p.mustBuild(amd, "1.2.3", "x")
b := p.mustBuild(arm, "1.2.3", "x")
out := func() string { return filepath.Join(GinkgoT().TempDir(), "m") }
_, err := run("merge", "--config", p.config, "--out", out(), "--version", "1.2.3", a, a, b)
expectCode(err, "E_PLATFORMS")
local := p.mustBuild(arm, "1.2.3", "x", "--local-check")
_, err = run("merge", "--config", p.config, "--out", out(), "--version", "1.2.3", a, local)
expectCode(err, "E_PLATFORMS")
_, err = run("merge", "--config", p.config, "--out", out(), "--version", "1.2.4", a, b)
Expect(err).To(MatchError(ContainSubstring("E_VERSION")))
onlyAmd := newProject(map[string][]string{amd: nil})
_, err = run("merge", "--config", onlyAmd.config, "--out", out(), "--version", "1.2.3", a, b)
expectCode(err, "E_PLATFORMS")
})
It("refuses a merged layout that lost a platform after the merge", func() {
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
cfg, _ := loadPlatforms(p.config)
// Enabling one more platform makes the merged layout partial.
more := newProject(map[string][]string{amd: nil, arm: nil, "darwin/arm64": nil})
moreCfg, err := loadPlatforms(more.config)
Expect(err).NotTo(HaveOccurred())
_, _, err = verifyMerged(m, moreCfg)
expectCode(err, "E_PLATFORMS")
_, _, err = verifyMerged(m, cfg)
Expect(err).NotTo(HaveOccurred())
})
})
var _ = Describe("add-on repository", func() {
It("accepts the add-on namespace and refuses every other go-skynet repository", func() {
for _, ok := range []string{"quay.io/go-skynet/local-ai-addons", "quay.io/go-skynet/local-ai-addons/tables"} {
_, err := parseRepository(ok)
Expect(err).NotTo(HaveOccurred(), ok)
}
for _, bad := range []string{"quay.io/go-skynet/local-ai-addons-evil", "quay.io/go-skynet/local-ai-backends", "quay.io/go-skynet/ci-cache", "quay.io/go-skynet/local-ai", "quay.io/other/local-ai-addons"} {
_, err := parseRepository(bad)
if bad == "quay.io/other/local-ai-addons" {
Expect(err).NotTo(HaveOccurred(), bad)
continue
}
Expect(err).To(HaveOccurred(), bad)
}
})
})
var _ = Describe("catalog index reader", func() {
const entry = "- id: tables\n name: Tables\n version: 1.0.0\n channel: latest\n artifact: oci://registry.example/tables:1.0.0\n digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n os: linux\n arch: amd64\n"
It("reads a YAML list and refuses JSON, unknown fields and a second document", func() {
got, err := readCatalogIndex([]byte(entry))
Expect(err).NotTo(HaveOccurred())
Expect(got).To(HaveLen(1))
Expect(got[0].ID).To(Equal("tables"))
for name, body := range map[string]string{
"json": `[{"id":"tables"}]`,
"unknown field": entry + " extra: 1\n",
"second document": entry + "---\n" + entry,
"mapping": "addonId: tables\n",
} {
_, err := readCatalogIndex([]byte(body))
Expect(err).To(HaveOccurred(), name)
}
})
})
var _ = Describe("publication", func() {
ctx := context.Background()
It("refuses missing and backend repositories before any request", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
for _, repo := range []string{"", "quay.io/go-skynet/local-ai-backends", "docker.io/localai/localai", "quay.io/go-skynet/ci-cache", "Not Valid/Repo"} {
_, err := run("publish", "--config", p.config, "--layout", m, "--repository", repo, "--tag", "1.2.3")
Expect(err).To(HaveOccurred(), repo)
}
Expect(reg.writeCount()).To(BeZero())
})
It("pushes platform digests then the index, and a retry is digest-identical", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
out, err := run("publish", "--config", p.config, "--layout", m, "--repository", reg.repo("addons/tables"), "--tag", "1.2.3")
Expect(err).NotTo(HaveOccurred())
Expect(out).To(ContainSubstring("digest=sha256:"))
first := out
before := reg.writeCount()
out, err = run("publish", "--config", p.config, "--layout", m, "--repository", reg.repo("addons/tables"), "--tag", "1.2.3")
Expect(err).NotTo(HaveOccurred())
Expect(out).To(Equal(first))
_ = before
repo, _ := name.NewRepository(reg.repo("addons/tables"))
d, err := remote.Head(repo.Tag("tables-1.2.3"))
Expect(err).NotTo(HaveOccurred())
Expect(first).To(ContainSubstring(d.Digest.String()))
children, err := childDigests(ctx, repo, d.Digest)
Expect(err).NotTo(HaveOccurred())
Expect(children).To(HaveLen(2))
})
It("rejects overwriting an immutable version with different content", func() {
reg := newRegistry()
p := newProject(both)
repoName := reg.repo("addons/tables")
a := p.merged("1.2.3", rev, nil)
_, err := run("publish", "--config", p.config, "--layout", a, "--repository", repoName, "--tag", "1.2.3")
Expect(err).NotTo(HaveOccurred())
b := p.merged("1.2.3", rev, map[string]string{amd: "different bytes"})
_, err = run("publish", "--config", p.config, "--layout", b, "--repository", repoName, "--tag", "1.2.3")
expectCode(err, "E_IMMUTABLE")
repo, _ := name.NewRepository(repoName)
d, err := remote.Head(repo.Tag("tables-1.2.3"))
Expect(err).NotTo(HaveOccurred())
_, da, _ := verifyMerged(a, mustCfg(p))
Expect(d.Digest).To(Equal(da))
})
It("adopts published content only for the same version and commit", func() {
reg := newRegistry()
p := newProject(both)
repoName := reg.repo("addons/tables")
a := p.merged("1.2.3", rev, nil)
_, err := run("publish", "--config", p.config, "--layout", a, "--repository", repoName, "--tag", "1.2.3")
Expect(err).NotTo(HaveOccurred())
same := p.merged("1.2.3", rev, map[string]string{amd: "rebuilt bytes"})
out, err := run("publish", "--config", p.config, "--layout", same, "--repository", repoName, "--tag", "1.2.3", "--adopt-existing")
Expect(err).NotTo(HaveOccurred())
_, da, _ := verifyMerged(a, mustCfg(p))
Expect(out).To(ContainSubstring(da.String()))
other := p.merged("1.2.3", "fedcba9876543210fedcba9876543210fedcba98", map[string]string{amd: "rebuilt bytes"})
_, err = run("publish", "--config", p.config, "--layout", other, "--repository", repoName, "--tag", "1.2.3", "--adopt-existing")
expectCode(err, "E_IMMUTABLE")
})
It("refuses reserved aliases and mismatched tags as immutable tags", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
for _, tag := range []string{"latest", "development", "branch-x-0123456789ab", "9.9.9", "sha-aaaaaaaaaaaa", ""} {
_, err := run("publish", "--config", p.config, "--layout", m, "--repository", reg.repo("addons/tables"), "--tag", tag)
expectCode(err, "E_RESERVED")
}
Expect(reg.writeCount()).To(BeZero())
_, err := run("publish", "--config", p.config, "--layout", m, "--repository", reg.repo("addons/tables"), "--tag", "sha-0123456789ab")
Expect(err).NotTo(HaveOccurred())
})
It("publishes nothing from a partial or local-check layout", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
more := newProject(map[string][]string{amd: nil, arm: nil, "darwin/arm64": nil})
_, err := run("publish", "--config", more.config, "--layout", m, "--repository", reg.repo("addons/tables"), "--tag", "1.2.3")
expectCode(err, "E_PLATFORMS")
Expect(reg.writeCount()).To(BeZero())
})
})
func mustCfg(p *project) *PlatformsFile {
GinkgoHelper()
c, err := loadPlatforms(p.config)
Expect(err).NotTo(HaveOccurred())
return c
}
var _ = Describe("alias promotion", func() {
var (
reg *countingRegistry
p *project
repo name.Repository
ctx = context.Background()
)
publish := func(version, revision, content, tag string) v1.Hash {
GinkgoHelper()
m := p.merged(version, revision, map[string]string{amd: content})
_, err := run("publish", "--config", p.config, "--layout", m, "--repository", reg.repo("addons/tables"), "--tag", tag)
Expect(err).NotTo(HaveOccurred())
_, d, err := verifyMerged(m, mustCfg(p))
Expect(err).NotTo(HaveOccurred())
return d
}
head := func(tag string) v1.Hash {
GinkgoHelper()
d, err := remote.Head(repo.Tag("tables-" + tag))
Expect(err).NotTo(HaveOccurred())
return d.Digest
}
BeforeEach(func() {
reg = newRegistry()
p = newProject(both)
var err error
repo, err = name.NewRepository(reg.repo("addons/tables"))
Expect(err).NotTo(HaveOccurred())
})
It("moves development forward and leaves it alone for an older commit", func() {
gitDir, shas := gitHistory(3)
old := publish("0.0.0-dev."+shas[0][:12], shas[0], "c0", "sha-"+shas[0][:12])
mid := publish("0.0.0-dev."+shas[1][:12], shas[1], "c1", "sha-"+shas[1][:12])
newer := publish("0.0.0-dev."+shas[2][:12], shas[2], "c2", "sha-"+shas[2][:12])
res, err := promoteAlias(ctx, repo, "tables", "development", mid, "", shas[1], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
Expect(head("development")).To(Equal(mid))
res, err = promoteAlias(ctx, repo, "tables", "development", newer, "", shas[2], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
// A slower run of the oldest commit finishes last.
res, err = promoteAlias(ctx, repo, "tables", "development", old, "", shas[0], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("stale"))
Expect(head("development")).To(Equal(newer))
res, err = promoteAlias(ctx, repo, "tables", "development", newer, "", shas[2], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("unchanged"))
})
It("accepts a diverged branch head and refuses one commit with two contents", func() {
gitDir, shas := gitHistory(2)
work := filepath.Dir(gitDir)
git := func(args ...string) string {
cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
out, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(out))
return strings.TrimSpace(string(out))
}
git("checkout", "-q", "-b", "rewritten", "HEAD~1")
git("commit", "-q", "--allow-empty", "-m", "rewritten")
diverged := git("rev-parse", "HEAD")
a := publish("0.0.0-dev."+shas[1][:12], shas[1], "x", "sha-"+shas[1][:12])
res, err := promoteAlias(ctx, repo, "tables", "development", a, "", shas[1], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
d := publish("0.0.0-dev."+diverged[:12], diverged, "z", "sha-"+diverged[:12])
res, err = promoteAlias(ctx, repo, "tables", "development", d, "", diverged, gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
same := publish("0.0.1", diverged, "y", "0.0.1")
_, err = promoteAlias(ctx, repo, "tables", "development", same, "", diverged, gitDir)
expectCode(err, "E_IMMUTABLE")
Expect(head("development")).To(Equal(d))
})
Context("when the commit the alias names is not in the checkout", func() {
const gone = "1111111111111111111111111111111111111111"
var gitDir, current string
BeforeEach(func() {
var shas []string
gitDir, shas = gitHistory(1)
current = shas[0]
})
It("treats a force-pushed branch as the new truth for a push to the same ref", func() {
ref := "refs/heads/feature/x"
tag := branchTag(ref)
oldD := publish("0.0.0-dev."+gone[:12], gone, "old", "sha-"+gone[:12])
_, err := promoteAlias(ctx, repo, "tables", tag, oldD, "", gone, gitDir)
Expect(err).NotTo(HaveOccurred())
newD := publish("0.0.0-dev."+current[:12], current, "new", "sha-"+current[:12])
res, err := promoteAliasFrom(ctx, repo, "tables", tag, newD, "", current, gitDir, promotionSource{Ref: ref, Event: "push"})
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
Expect(head(tag)).To(Equal(newD))
})
It("blocks the alias for anything but a push to the channel's own ref", func() {
ref := "refs/heads/feature/x"
tag := branchTag(ref)
oldD := publish("0.0.0-dev."+gone[:12], gone, "old", "sha-"+gone[:12])
_, err := promoteAlias(ctx, repo, "tables", tag, oldD, "", gone, gitDir)
Expect(err).NotTo(HaveOccurred())
newD := publish("0.0.0-dev."+current[:12], current, "new", "sha-"+current[:12])
for _, src := range []promotionSource{
{},
{Ref: ref, Event: "release"},
{Ref: "refs/heads/other", Event: "push"},
{Ref: "refs/tags/v1.0.0", Event: "push"},
{Ref: ref, Event: ""},
} {
_, err := promoteAliasFrom(ctx, repo, "tables", tag, newD, "", current, gitDir, src)
expectCode(err, "E_UNREACHABLE")
Expect(head(tag)).To(Equal(oldD))
}
})
It("refuses development when its commit is unreachable, even for a push", func() {
oldD := publish("0.0.0-dev."+gone[:12], gone, "old", "sha-"+gone[:12])
_, err := promoteAlias(ctx, repo, "tables", "development", oldD, "", gone, gitDir)
Expect(err).NotTo(HaveOccurred())
newD := publish("0.0.0-dev."+current[:12], current, "new", "sha-"+current[:12])
_, err = promoteAliasFrom(ctx, repo, "tables", "development", newD, "", current, gitDir, promotionSource{Ref: "refs/heads/master", Event: "push"})
expectCode(err, "E_UNREACHABLE")
Expect(head("development")).To(Equal(oldD))
})
It("fails on any git error that is not a plain not-ancestor answer", func() {
bin := GinkgoT().TempDir()
for name, body := range map[string]string{
"exit2": "echo boom >&2; exit 2",
"exit128": "echo 'fatal: not a git repository' >&2; exit 128",
} {
dir := filepath.Join(bin, name)
Expect(os.MkdirAll(dir, 0o755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "git"), []byte("#!/bin/sh\n"+body+"\n"), 0o755)).To(Succeed())
GinkgoT().Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
_, err := isAncestor(ctx, gitDir, current, gone)
Expect(err).To(HaveOccurred(), name)
Expect(errors.Is(err, errCommitUnreachable)).To(BeFalse(), name)
}
})
It("tells an unreachable commit from a plain not-ancestor", func() {
_, err := isAncestor(ctx, gitDir, current, gone)
Expect(errors.Is(err, errCommitUnreachable)).To(BeTrue())
_, err = isAncestor(ctx, gitDir, gone, current)
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, errCommitUnreachable)).To(BeFalse(), "a missing commit of this run is not a rewritten branch")
ok, err := isAncestor(ctx, gitDir, current, current)
Expect(err).NotTo(HaveOccurred())
Expect(ok).To(BeTrue())
})
})
It("moves latest only to a newer stable version", func() {
v1d := publish("1.0.0", rev, "one", "1.0.0")
v2d := publish("1.1.0", rev, "two", "1.1.0")
res, err := promoteAlias(ctx, repo, "tables", "latest", v2d, "1.1.0", rev, "")
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
res, err = promoteAlias(ctx, repo, "tables", "latest", v1d, "1.0.0", rev, "")
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("stale"))
Expect(head("latest")).To(Equal(v2d))
_, err = promoteAlias(ctx, repo, "tables", "latest", v2d, "1.2.0-rc.1", rev, "")
expectCode(err, "E_RESERVED")
other := publish("1.1.0", "fedcba9876543210fedcba9876543210fedcba98", "three", "sha-fedcba987654")
_, err = promoteAlias(ctx, repo, "tables", "latest", other, "1.1.0", rev, "")
expectCode(err, "E_IMMUTABLE")
Expect(head("latest")).To(Equal(v2d))
})
It("refuses reserved or foreign alias names and unpublished digests, leaving aliases unchanged", func() {
d := publish("1.0.0", rev, "one", "1.0.0")
for _, alias := range []string{"1.0.0", "sha-0123456789ab", "main", "branch-", "Latest", ""} {
_, err := promoteAlias(ctx, repo, "tables", alias, d, "1.0.0", rev, "")
expectCode(err, "E_RESERVED")
}
missing, _ := v1.NewHash("sha256:" + strings.Repeat("a", 64))
before := reg.writeCount()
_, err := promoteAlias(ctx, repo, "tables", "latest", missing, "1.0.0", rev, "")
Expect(err).To(HaveOccurred())
Expect(reg.writeCount()).To(Equal(before))
_, err = remote.Head(repo.Tag("tables-latest"))
Expect(err).To(HaveOccurred())
})
It("promotes a branch channel under its hashed name", func() {
gitDir, shas := gitHistory(1)
tag := branchTag("refs/heads/feature/x")
d := publish("0.0.0-dev."+shas[0][:12], shas[0], "b", "sha-"+shas[0][:12])
res, err := promoteAlias(ctx, repo, "tables", tag, d, "", shas[0], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("promoted"))
Expect(head(tag)).To(Equal(d))
_, err = promoteAlias(ctx, repo, "tables", "branch-feature-x", d, "", shas[0], gitDir)
expectCode(err, "E_RESERVED")
})
})
var _ = Describe("catalog template", func() {
It("renders digest-pinned entries with an explicit add-on signer identity", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
repoName := reg.repo("addons/tables")
out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
render := func(extra ...string) error {
args := append([]string{"catalog", "--config", p.config, "--template", "../../../gallery/addons/tables.yaml.tmpl", "--merged", m, "--repository", repoName, "--tag", "1.2.3", "--channel", "latest", "--owner-repo", "example/localai", "--signer-ref", "refs/tags/v1.2.3", "--out", out}, extra...)
_, err := run(args...)
return err
}
Expect(render()).To(Succeed())
b, err := os.ReadFile(out)
Expect(err).NotTo(HaveOccurred())
Expect(string(b)).To(HavePrefix("- id: "))
entries, err := readCatalogIndex(b)
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(HaveLen(2))
cfg := mustCfg(p)
_, _, err = verifyMerged(m, cfg)
Expect(err).NotTo(HaveOccurred())
for _, e := range entries {
Expect(e.Digest).To(MatchRegexp(`^sha256:[0-9a-f]{64}$`))
Expect(e.Artifact).To(Equal("oci://" + repoName + ":tables-1.2.3"))
Expect(e.Policy.RequireSignature).To(BeTrue())
Expect(e.Policy.Identity).To(Equal("https://github.com/example/localai/.github/workflows/addons-package.yml@refs/tags/v1.2.3"))
Expect(e.Policy.Issuer).To(Equal("https://token.actions.githubusercontent.com"))
Expect(e.Channel).To(Equal("latest"))
Expect(e.UI.MainPage).To(Equal("databases"))
Expect(e.Policy.Identity).NotTo(ContainSubstring("backend"))
}
Expect(entries[0].Digest).NotTo(Equal(entries[1].Digest))
Expect(entries[0].Arch).NotTo(Equal(entries[1].Arch))
})
It("renders the development channel as mutable entries: the moving tag, no digest", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("0.0.0-dev.0123456789ab", rev, nil)
repoName := reg.repo("addons/tables")
out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
_, err := run("catalog", "--config", p.config, "--template", "../../../gallery/addons/tables.yaml.tmpl", "--merged", m, "--repository", repoName, "--tag", "sha-0123456789ab", "--channel", "development", "--owner-repo", "example/localai", "--signer-ref", "refs/heads/master", "--out", out)
Expect(err).NotTo(HaveOccurred())
b, err := os.ReadFile(out)
Expect(err).NotTo(HaveOccurred())
Expect(string(b)).NotTo(ContainSubstring("digest:"))
entries, err := readCatalogIndex(b)
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(HaveLen(2))
for _, e := range entries {
Expect(e.Mutable).To(BeTrue())
Expect(e.Digest).To(BeEmpty())
Expect(e.Artifact).To(Equal("oci://" + repoName + ":tables-development"))
Expect(e.Channel).To(Equal("development"))
Expect(e.Policy.RequireSignature).To(BeTrue())
}
})
It("renders the development entries of every enabled platform from the manifest, without a package", func() {
p := newProject(both)
out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
_, err := run("catalog", "--config", p.config, "--root", p.root, "--template", "../../../gallery/addons/tables.yaml.tmpl", "--from-manifest", "--repository", newRegistry().repo("addons"), "--channel", "development", "--owner-repo", "example/localai", "--signer-ref", "refs/heads/master", "--out", out)
Expect(err).NotTo(HaveOccurred())
b, err := os.ReadFile(out)
Expect(err).NotTo(HaveOccurred())
entries, err := readCatalogIndex(b)
Expect(err).NotTo(HaveOccurred())
var platforms []string
for _, e := range entries {
Expect(e.Mutable).To(BeTrue())
Expect(e.Digest).To(BeEmpty())
Expect(e.Artifact).To(HaveSuffix(":tables-development"))
Expect(e.Policy.Identity).To(HaveSuffix("/addons-package.yml@refs/heads/master"))
platforms = append(platforms, e.OS+"/"+e.Arch)
}
Expect(platforms).To(ConsistOf("linux/amd64", "linux/arm64"))
})
It("refuses --from-manifest for a pinned channel or together with a merged layout", func() {
p := newProject(both)
out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
args := []string{"catalog", "--config", p.config, "--root", p.root, "--template", "../../../gallery/addons/tables.yaml.tmpl", "--from-manifest", "--repository", newRegistry().repo("addons"), "--owner-repo", "example/localai", "--signer-ref", "refs/tags/v1.2.3", "--tag", "1.2.3", "--out", out}
_, err := run(append(args, "--channel", "latest")...)
Expect(err).To(MatchError(ContainSubstring("development channel only")))
_, err = run(append(args, "--channel", "development", "--merged", p.root)...)
Expect(err).To(MatchError(ContainSubstring("development channel only")))
})
It("renders an entry for every first-party add-on from its own template", func() {
for _, id := range []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk", "telegram", "slack", "office", "sandbox", "web"} {
p := newProjectFor(id, both)
m := p.merged("1.2.3", rev, nil)
repoName := newRegistry().repo("addons")
out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
_, err := run("catalog", "--config", p.config, "--template", "../../../gallery/addons/"+id+".yaml.tmpl", "--merged", m, "--repository", repoName, "--tag", "1.2.3", "--channel", "latest", "--owner-repo", "example/localai", "--signer-ref", "refs/tags/v1.2.3", "--out", out)
Expect(err).NotTo(HaveOccurred(), id)
b, err := os.ReadFile(out)
Expect(err).NotTo(HaveOccurred())
entries, err := readCatalogIndex(b)
Expect(err).NotTo(HaveOccurred(), id)
Expect(entries).To(HaveLen(2), id)
for _, e := range entries {
Expect(e.ID).To(Equal(id))
if id == "office" {
Expect(e.Name).To(Equal("Simple Office"))
}
Expect(e.Artifact).To(Equal("oci://" + repoName + ":" + id + "-1.2.3"))
Expect(e.Policy.RequireSignature).To(BeTrue())
}
}
})
It("reports the chat tools of the manifest on each entry and not the admin tools", func() {
counts := map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "office": 11, "sandbox": 5, "web": 5}
for id, want := range counts {
p := newProjectFor(id, both)
m := p.merged("1.2.3", rev, nil)
out := filepath.Join(GinkgoT().TempDir(), "catalog.yaml")
_, err := run("catalog", "--config", p.config, "--template", "../../../gallery/addons/"+id+".yaml.tmpl", "--merged", m, "--repository", newRegistry().repo("addons"), "--tag", "1.2.3", "--channel", "latest", "--owner-repo", "example/localai", "--signer-ref", "refs/tags/v1.2.3", "--out", out)
Expect(err).NotTo(HaveOccurred(), id)
b, err := os.ReadFile(out)
Expect(err).NotTo(HaveOccurred())
entries, err := readCatalogIndex(b)
Expect(err).NotTo(HaveOccurred(), id)
Expect(entries).To(HaveLen(2), id)
for _, e := range entries {
Expect(e.ToolCount).To(Equal(want), id)
Expect(e.Tools).To(HaveLen(want), id)
}
if want == 0 {
Expect(string(b)).NotTo(ContainSubstring("toolCount"))
}
if id == "sandbox" {
Expect(entries[0].Shadows).To(Equal([]string{"bash"}), "the Sandbox's bash replaces the built-in bash")
}
if id == "web" {
Expect(entries[0].Shadows).To(Equal([]string{"web_search", "web_fetch"}), "the Web add-on replaces the built-in web tools")
}
if id == "office" {
Expect(entries[0].SkillShadows).To(Equal([]string{"xlsx", "docx", "pptx", "pdf"}), "the Office skills replace the built-in office skills")
}
if id == "tables" {
// The admin tool is declared next to the chat tools and is not one of them.
Expect(entries[0].Tools).NotTo(ContainElement("databases-delete"))
Expect(entries[0].Tools).To(ContainElement("databases-create"))
}
}
})
It("caps the tool names at MaxEntryTools and keeps the full count", func() {
var tools []string
for i := 0; i < MaxEntryTools+8; i++ {
tools = append(tools, fmt.Sprintf("tool-%c%c", 'a'+i/26, 'a'+i%26))
}
text := toolFieldsYAML(tools, "")
Expect(text).To(HavePrefix(fmt.Sprintf("toolCount: %d\n", MaxEntryTools+8)))
Expect(strings.Count(text, "\n - ")).To(Equal(MaxEntryTools))
Expect(toolFieldsYAML(nil, "")).To(BeEmpty())
})
It("lists the built-in skills an add-on replaces", func() {
Expect(skillFieldsYAML([]string{"xlsx", "pdf"}, " ")).To(Equal(" skillShadows:\n - \"xlsx\"\n - \"pdf\"\n"))
Expect(skillFieldsYAML(nil, "")).To(BeEmpty())
})
It("lists the built-in tools an add-on replaces after its tools", func() {
Expect(toolFieldsYAML([]string{"bash", "status"}, " ", "bash")).To(Equal(" toolCount: 2\n tools:\n - \"bash\"\n - \"status\"\n shadows:\n - \"bash\"\n"))
})
It("adds the tool fields to an existing index from the repository manifests, and changes nothing else", func() {
raw, err := os.ReadFile("../../../gallery/addons/index.yaml")
Expect(err).NotTo(HaveOccurred())
stripped := regexp.MustCompile(`(?m)^ toolCount:.*\n tools:\n( - .*\n)+`).ReplaceAll(raw, nil)
Expect(string(stripped)).NotTo(ContainSubstring("toolCount"))
index := filepath.Join(GinkgoT().TempDir(), "index.yaml")
Expect(os.WriteFile(index, stripped, 0o600)).To(Succeed())
_, err = run("catalog-tools", "--index", index, "--addons", "../../../addons")
Expect(err).NotTo(HaveOccurred())
again, err := os.ReadFile(index)
Expect(err).NotTo(HaveOccurred())
Expect(string(again)).To(Equal(string(raw)), "the committed index is what the generator writes")
_, err = run("catalog-tools", "--index", index, "--addons", "../../../addons")
Expect(err).NotTo(HaveOccurred())
twice, _ := os.ReadFile(index)
Expect(string(twice)).To(Equal(string(again)))
entries, err := readCatalogIndex(again)
Expect(err).NotTo(HaveOccurred())
got := map[string]int{}
for _, e := range entries {
got[e.ID] = e.ToolCount
}
Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "office": 11, "sandbox": 5, "web": 5}))
})
It("refuses missing inputs, backend repositories, bad channels and unknown placeholders", func() {
reg := newRegistry()
p := newProject(both)
m := p.merged("1.2.3", rev, nil)
out := filepath.Join(GinkgoT().TempDir(), "c.yaml")
base := func(over map[string]string) error {
vals := map[string]string{"--config": p.config, "--template": "../../../gallery/addons/tables.yaml.tmpl", "--merged": m, "--repository": reg.repo("addons/tables"), "--tag": "1.2.3", "--channel": "latest", "--owner-repo": "example/localai", "--signer-ref": "refs/tags/v1.2.3", "--out": out}
for k, v := range over {
vals[k] = v
}
var args []string
for k, v := range vals {
args = append(args, k, v)
}
_, err := run(append([]string{"catalog"}, args...)...)
return err
}
Expect(base(nil)).To(Succeed())
Expect(base(map[string]string{"--repository": ""})).To(HaveOccurred())
Expect(base(map[string]string{"--repository": "quay.io/go-skynet/x"})).To(HaveOccurred())
Expect(base(map[string]string{"--channel": "stable"})).To(HaveOccurred())
Expect(base(map[string]string{"--channel": "1.2.3"})).To(HaveOccurred())
Expect(base(map[string]string{"--owner-repo": ""})).To(HaveOccurred())
Expect(base(map[string]string{"--signer-ref": "refs/pull/1/merge"})).To(HaveOccurred())
Expect(base(map[string]string{"--tag": "latest"})).To(HaveOccurred())
bad := filepath.Join(GinkgoT().TempDir(), "bad.tmpl")
tmpl, _ := os.ReadFile("../../../gallery/addons/tables.yaml.tmpl")
Expect(os.WriteFile(bad, bytes.ReplaceAll(tmpl, []byte("{{arch}}"), []byte("{{unknown}}")), 0o644)).To(Succeed())
Expect(base(map[string]string{"--template": bad})).To(HaveOccurred())
weak := filepath.Join(GinkgoT().TempDir(), "weak.tmpl")
Expect(os.WriteFile(weak, bytes.ReplaceAll(tmpl, []byte("requireSignature: true"), []byte("requireSignature: false")), 0o644)).To(Succeed())
Expect(base(map[string]string{"--template": weak})).To(HaveOccurred())
})
It("carries placeholders only: no fabricated URL, registry or namespace", func() {
b, err := os.ReadFile("../../../gallery/addons/tables.yaml.tmpl")
Expect(err).NotTo(HaveOccurred())
text := string(b)
for _, bad := range []string{"http://", "https://", "quay.io", "docker.io", "ghcr.io", "go-skynet"} {
Expect(text).NotTo(ContainSubstring(bad))
}
for _, need := range []string{"{{repository}}", "{{digest}}", "{{identity}}", "{{issuer}}", "{{channel}}", "{{os}}", "{{arch}}", "{{version}}"} {
Expect(text).To(ContainSubstring(need))
}
})
})
var _ = Describe("workflow policy", func() {
const file = "../../../.github/workflows/addons-package.yml"
var base string
BeforeEach(func() {
b, err := os.ReadFile(file)
Expect(err).NotTo(HaveOccurred())
base = string(b)
})
mutate := func(old, new string) []string {
GinkgoHelper()
Expect(strings.Count(base, old)).To(BeNumerically(">=", 1), "mutation anchor %q not found", old)
return checkWorkflow([]byte(strings.Replace(base, old, new, 1)))
}
expect := func(problems []string, code string) {
GinkgoHelper()
Expect(strings.Join(problems, "\n")).To(ContainSubstring(code + ":"))
}
It("accepts the checked-in workflow", func() {
Expect(checkWorkflow([]byte(base))).To(BeEmpty())
})
It("rejects pull_request_target", func() {
expect(mutate(" release:\n types: [published]", " pull_request_target:\n types: [opened]\n release:\n types: [published]"), "P_PR_TARGET")
})
It("rejects secrets in a pull request validation job", func() {
expect(mutate(" run: sh scripts/addons/package_test.sh", " env:\n T: ${{ secrets.ADDON_OCI_PASSWORD }}\n run: sh scripts/addons/package_test.sh"), "P_UNTRUSTED_SECRETS")
})
It("rejects id-token write outside the publishing job", func() {
expect(mutate(" permissions:\n contents: read\n strategy:", " permissions:\n contents: read\n id-token: write\n strategy:"), "P_UNTRUSTED_SECRETS")
})
It("lets only the release-index job write the contents of the repository", func() {
anchor := " permissions:\n contents: write\n actions: read\n"
expect(mutate(anchor, " permissions:\n contents: write\n actions: read\n id-token: write\n"), "P_UNTRUSTED_SECRETS")
expect(mutate(anchor, " permissions:\n contents: write\n actions: write\n"), "P_UNTRUSTED_SECRETS")
expect(mutate(" permissions:\n contents: read\n env:\n ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n steps:\n - uses: actions/checkout@v7\n with:\n persist-credentials: false\n - uses: actions/setup-go@v6\n with:\n go-version-file: go.mod\n - uses: actions/download-artifact@v8\n with:\n pattern: merged-*", " permissions:\n contents: write\n env:\n ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n steps:\n - uses: actions/checkout@v7\n with:\n persist-credentials: false\n - uses: actions/setup-go@v6\n with:\n go-version-file: go.mod\n - uses: actions/download-artifact@v8\n with:\n pattern: merged-*"), "P_UNTRUSTED_SECRETS")
})
It("hands the guest image digest of the sandbox-image job to the package leg", func() {
Expect(base).To(ContainSubstring("needs: [plan, validate, sandbox-image]"))
Expect(base).To(ContainSubstring("SANDBOX_GUEST_IMAGE: ${{ matrix.addon == 'sandbox' && needs.sandbox-image.outputs.image || '' }}"))
})
It("rejects a registry login or publish in an untrusted job", func() {
expect(mutate(" - name: Run the packaging checks\n if: matrix.addon == 'tables'\n run: sh scripts/addons/package_test.sh", " - name: Run the packaging checks\n run: go run ./scripts/addons/cmd/addonpkg publish --layout x"), "P_UNTRUSTED_SECRETS")
})
It("rejects publish that does not wait for every platform leg", func() {
expect(mutate(" needs: [plan, validate, package]\n if: needs.plan.outputs.publish == 'true'", " needs: [plan, validate]\n if: needs.plan.outputs.publish == 'true'"), "P_NEEDS")
})
It("rejects publish that can run after a failed leg", func() {
expect(mutate(" needs: [plan, validate, package]\n if: needs.plan.outputs.publish == 'true'", " needs: [plan, package]\n if: always() && needs.plan.outputs.publish == 'true'"), "P_TRUSTED_GATE")
})
It("rejects publish that does not need validate", func() {
expect(mutate(" needs: [plan, validate, package]\n if: needs.plan.outputs.publish == 'true'", " needs: [plan, package]\n if: needs.plan.outputs.publish == 'true'"), "P_NEEDS")
})
It("rejects a package leg that does not need validate", func() {
expect(mutate(" needs: [plan, validate, sandbox-image]\n if: needs.plan.outputs.has_platforms == 'true'", " needs: [plan, sandbox-image]\n if: needs.plan.outputs.has_platforms == 'true'"), "P_NEEDS")
})
It("rejects a validate job that cannot stop the release", func() {
anchor := " name: validate package rules\n"
expect(mutate(anchor, anchor+" continue-on-error: true\n"), "P_VALIDATE_GATE")
expect(mutate(anchor, anchor+" continue-on-error: ${{ github.event_name != 'push' }}\n"), "P_VALIDATE_GATE")
expect(mutate(anchor, anchor+" if: always()\n"), "P_VALIDATE_GATE")
expect(mutate(anchor, anchor+" if: failure() || success()\n"), "P_VALIDATE_GATE")
expect(mutate(" run: sh scripts/addons/package_test.sh", " continue-on-error: true\n run: sh scripts/addons/package_test.sh"), "P_VALIDATE_GATE")
})
It("rejects a package or promote job that runs after a failed validate", func() {
expect(mutate(" if: needs.plan.outputs.has_platforms == 'true'", " if: always() && needs.plan.outputs.has_platforms == 'true'"), "P_VALIDATE_GATE")
expect(mutate(" if: needs.plan.outputs.publish == 'true' && needs.plan.outputs.aliases != ''", " if: always() && needs.plan.outputs.publish == 'true' && needs.plan.outputs.aliases != ''"), "P_VALIDATE_GATE")
})
It("keeps validate unprivileged on every event", func() {
var doc struct {
Jobs map[string]map[string]any `yaml:"jobs"`
}
Expect(yaml.Unmarshal([]byte(base), &doc)).To(Succeed())
v := doc.Jobs["validate"]
Expect(v).NotTo(HaveKey("if"))
Expect(v).NotTo(HaveKey("environment"))
Expect(fmt.Sprint(v)).NotTo(ContainSubstring("secrets."))
Expect(fmt.Sprint(v)).NotTo(ContainSubstring("id-token"))
})
It("rejects promotion that does not wait for signing", func() {
expect(mutate(" needs: [plan, validate, publish]", " needs: [plan, validate]"), "P_NEEDS")
})
It("rejects alias promotion before signing", func() {
expect(mutate(" id: sign\n", " id: verify\n"), "P_ORDER")
})
It("rejects an alias command inside the publishing job", func() {
expect(mutate(" --layout merged --repository \"$ADDON_OCI_REPOSITORY\" --tag \"$IMMUTABLE\" --adopt-existing | tee push.txt", " --layout merged --repository \"$ADDON_OCI_REPOSITORY\" --tag \"$IMMUTABLE\" --adopt-existing | tee push.txt\n go run ./scripts/addons/cmd/addonpkg promote --alias development"), "P_ORDER")
})
It("rejects a literal latest tag", func() {
expect(mutate(" echo \"has_platforms=$has\"", " echo \"has_platforms=$has\"\n echo \"aliases=latest\""), "P_LATEST")
})
It("rejects backend registries and backend workflows", func() {
expect(mutate("ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}", "ADDON_OCI_REPOSITORY: quay.io/go-skynet/ci-cache"), "P_BACKEND")
expect(mutate(" needs: [plan, validate, publish]", " needs: [plan, validate, publish]\n uses: ./.github/workflows/backend_merge.yml"), "P_BACKEND")
})
It("rejects a signer identity that is a pattern or the backend workflow", func() {
expect(mutate(`--certificate-identity "$IDENTITY"`, `--certificate-identity-regexp ".*"`), "P_SIGNER")
expect(mutate("addons-package.yml@${{ github.ref }}", "backend_merge.yml@${{ github.ref }}"), "P_SIGNER")
})
It("rejects signing by tag or without referrers and the new bundle format", func() {
expect(mutate(`"${ADDON_OCI_REPOSITORY}@${DIGEST}"`, `"${ADDON_OCI_REPOSITORY}:1.2.3"`), "P_SIGNER")
expect(mutate(" --registry-referrers-mode=oci-1-1 \\\n", ""), "P_SIGNER")
})
It("rejects missing serialization per channel", func() {
expect(mutate(" group: addons-promote-${{ matrix.addon }}-${{ needs.plan.outputs.channel }}\n cancel-in-progress: false", " group: addons-promote-${{ matrix.addon }}-${{ needs.plan.outputs.channel }}\n cancel-in-progress: true"), "P_CONCURRENCY")
expect(mutate("addons-promote-${{ matrix.addon }}-${{ needs.plan.outputs.channel }}", "addons-promote-shared"), "P_CONCURRENCY")
})
It("rejects publication that is shared between add-ons", func() {
expect(mutate("addons-promote-${{ matrix.addon }}-", "addons-promote-"), "P_CONCURRENCY")
expect(mutate("addons-publish-${{ matrix.addon }}-", "addons-publish-"), "P_CONCURRENCY")
expect(mutate(" addon: ${{ fromJSON(needs.plan.outputs.addons) }}\n env:\n ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n #", " #"), "P_MATRIX")
expect(mutate(" ADDON: ${{ matrix.addon }}\n # cosign", " ADDON: ${{ matrix.addon }}\n ADDON_OCI_BASE: x\n # cosign"), "P_REPOSITORY")
expect(mutate(" COSIGN_EXPERIMENTAL: '1'\n", " COSIGN_EXPERIMENTAL: '1'\n outputs:\n digest: x\n"), "P_ORDER")
})
It("rejects a workflow that names one add-on by path or skips an add-on in the matrix", func() {
expect(mutate("node \"addons/$ADDON/ui/build.mjs\"\n - name: Fill", "node addons/tables/ui/build.mjs\n - name: Fill"), "P_ADDON_PATH")
expect(mutate(" addon: [tables, voice, face, fine-tuning, quantization, studio, talk, telegram, slack, office, sandbox, web]\n", " platform: [x]\n"), "P_MATRIX")
})
It("rejects a plan that does not refuse an add-on without an enabled platform", func() {
expect(mutate("has no enabled platform; refusing to publish", "has no platform"), "P_FAIL_CLOSED")
})
It("rejects a plan that does not exit 1 for an add-on without an enabled platform on a publishing event", func() {
expect(mutate(" echo \"add-on $id has no enabled platform; refusing to publish\" >&2\n exit 1", " echo \"add-on $id has no enabled platform; refusing to publish\" >&2\n continue"), "P_FAIL_CLOSED")
expect(mutate(" if [ \"$publish\" = \"true\" ]; then\n echo \"add-on $id", " if [ \"$publish\" = \"never\" ]; then\n echo \"add-on $id"), "P_FAIL_CLOSED")
})
It("rejects a plan that treats any matrix error as no legs", func() {
expect(mutate(" elif grep -q 'no enabled platform' \"$err\"; then", " elif true; then"), "P_FAIL_CLOSED")
expect(mutate(" echo \"cannot read the platform matrix of add-on $id\" >&2\n cat \"$err\" >&2\n exit 1", " continue"), "P_FAIL_CLOSED")
})
It("rejects a repository rule that derives a repository for each add-on", func() {
expect(mutate(" env:\n ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n # cosign", " env:\n ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n # cosign"), "P_REPOSITORY")
expect(mutate("ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n steps:\n - uses: actions/checkout@v7\n with:\n fetch-depth: 0", "ADDON_OCI_REPOSITORY: quay.io/example/x\n ADDON: ${{ matrix.addon }}\n steps:\n - uses: actions/checkout@v7\n with:\n fetch-depth: 0"), "P_REPOSITORY")
})
It("rejects a publish or promote matrix that stops the other add-ons on a failure", func() {
expect(mutate(" # A leg that is pushing must finish, so one failure never cancels another.\n fail-fast: false", " fail-fast: true"), "P_FAIL_FAST")
expect(mutate(" permissions:\n contents: read\n strategy:\n fail-fast: false\n matrix:\n addon: ${{ fromJSON(needs.plan.outputs.addons) }}\n env:\n ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n steps:", " permissions:\n contents: read\n strategy:\n fail-fast: true\n matrix:\n addon: ${{ fromJSON(needs.plan.outputs.addons) }}\n env:\n ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n steps:"), "P_FAIL_FAST")
})
It("rejects a publish job that downloads the layouts of other add-ons", func() {
expect(mutate("pattern: layout-${{ matrix.addon }}-*", "pattern: layout-*"), "P_ARTIFACTS")
expect(mutate("pattern: layout-${{ matrix.addon }}-*", "pattern: layout-${{ matrix.addon }}*"), "P_ARTIFACTS")
})
It("rejects a promote job that does not check the digest format", func() {
expect(mutate(" sha256:????????????????????????????????????????????????????????????????) ;;", " sha256:*) ;;"), "P_DIGEST")
expect(mutate("the digest of $ADDON is missing or malformed", "the digest of $ADDON is wrong"), "P_DIGEST")
})
It("rejects a plan that does not fail closed on missing configuration", func() {
expect(mutate(`if [ -z "$ADDON_OCI_REPOSITORY" ]; then`, `if [ -n "$OTHER" ]; then`), "P_FAIL_CLOSED")
})
It("rejects a checkout of the pull request head or a persisted token", func() {
expect(mutate("persist-credentials: false", "persist-credentials: true"), "P_CHECKOUT")
expect(mutate(" - uses: actions/checkout@v7\n with:\n persist-credentials: false", " - uses: actions/checkout@v7\n with:\n ref: ${{ github.event.pull_request.head.sha }}\n persist-credentials: false"), "P_CHECKOUT")
})
It("rejects write permission at workflow level and a literal platform matrix", func() {
expect(mutate("permissions:\n contents: read\n\njobs:", "permissions:\n contents: write\n\njobs:"), "P_PERMISSIONS")
expect(mutate(" matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}", " matrix:\n include:\n - platform: linux/amd64\n runner: ubuntu-24.04\n slug: linux-amd64"), "P_MATRIX")
})
It("rejects a publishing job without an environment or a gate", func() {
expect(mutate(" if: needs.plan.outputs.publish == 'true' && needs.plan.outputs.aliases != ''", " if: needs.plan.outputs.aliases != ''"), "P_TRUSTED_GATE")
})
It("does not trust the backend workflow identity in the documented verification", func() {
Expect(strings.Contains(base, "backend_merge")).To(BeFalse())
})
})
// firstPartyAddons are the add-ons the release workflow packages. The list is
// written out so a new add-on is a deliberate change here and in the workflow.
var firstPartyAddons = []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk", "telegram", "slack", "office", "sandbox", "web"}
func readWorkflow() string {
GinkgoHelper()
b, err := os.ReadFile("../../../.github/workflows/addons-package.yml")
Expect(err).ToNot(HaveOccurred())
return string(b)
}
var _ = Describe("the add-on package workflow", func() {
It("has no hard-coded add-on path", func() {
text := readWorkflow()
for i, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
continue
}
Expect(line).ToNot(ContainSubstring("addons/tables/"), "line %d names addons/tables/ outside the add-on list: %s", i+1, trimmed)
}
})
Context("for every first-party add-on", func() {
var doc struct {
Jobs map[string]struct {
Strategy struct {
Matrix any `yaml:"matrix"`
} `yaml:"strategy"`
Steps []map[string]any `yaml:"steps"`
} `yaml:"jobs"`
}
matrixOf := func(job string) map[string]any { m, _ := doc.Jobs[job].Strategy.Matrix.(map[string]any); return m }
BeforeEach(func() {
Expect(yaml.Unmarshal([]byte(readWorkflow()), &doc)).To(Succeed())
})
It("lists the add-ons in the validate matrix", func() {
// validate lists the add-ons literally. The other jobs take the add-ons
// that have an enabled platform from the plan.
var got []string
items, _ := matrixOf("validate")["addon"].([]any)
for _, x := range items {
got = append(got, fmt.Sprint(x))
}
Expect(strings.Join(got, ",")).To(Equal(strings.Join(firstPartyAddons, ",")), "validate matrix lists %v, want %v", got, firstPartyAddons)
})
for _, job := range []string{"publish", "promote"} {
It("gives "+job+" an add-on matrix", func() {
Expect(matrixOf(job)).To(HaveKey("addon"), "%s has no addon matrix", job)
})
}
It("takes the package legs from the plan", func() {
Expect(fmt.Sprint(doc.Jobs["package"].Strategy.Matrix)).To(ContainSubstring("needs.plan.outputs.matrix"),
"package does not take its legs from the plan")
})
It("loops the plan over the same list", func() {
plan := readWorkflow()
Expect(plan).To(ContainSubstring("for id in "+strings.Join(firstPartyAddons, " ")+"; do"),
"plan does not loop over exactly %v", firstPartyAddons)
})
for _, id := range firstPartyAddons {
It("has consistent package files for "+id, func() {
file := "../../../addons/" + id + "/package/platforms.yaml"
cfg, err := loadPlatforms(file)
Expect(err).ToNot(HaveOccurred(), id)
Expect(cfg.Addon).To(Equal(id), "platforms file names add-on %q", cfg.Addon)
Expect(cfg.Manifest).To(Equal("addons/"+id+"/manifest.json"), "platforms file names manifest %q", cfg.Manifest)
Expect(cfg.UI).To(Equal("addons/"+id+"/ui/dist"), "ui is %q", cfg.UI)
_, err = os.Stat("../../../addons/" + id + "/cmd/" + cfg.Executable + "/main.go")
Expect(err).ToNot(HaveOccurred(), "no executable source for %q", cfg.Executable)
})
}
})
})
// documentedAddonRepository is the value the documentation gives for the
// ADDON_OCI_REPOSITORY variable: the one repository of all add-ons.
const documentedAddonRepository = "quay.io/go-skynet/local-ai-addons"
// workflowStepScript returns the shell script and env of a named step of a job.
func workflowStepScript(workflow, job, step string) (script string) {
GinkgoHelper()
var doc struct {
Jobs map[string]struct {
Steps []struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Run string `yaml:"run"`
} `yaml:"steps"`
} `yaml:"jobs"`
}
Expect(yaml.Unmarshal([]byte(workflow), &doc)).To(Succeed())
for _, st := range doc.Jobs[job].Steps {
if st.Name == step || st.ID == step {
return st.Run
}
}
Fail(fmt.Sprintf("job %s has no step %q", job, step))
return ""
}
var _ = Describe("workflow behavior", func() {
var workflow string
BeforeEach(func() {
b, err := os.ReadFile("../../../.github/workflows/addons-package.yml")
Expect(err).NotTo(HaveOccurred())
workflow = string(b)
})
It("publishes every add-on to the one documented repository, with its id as the tag prefix", func() {
docs, err := os.ReadFile("../../../docs/content/features/addons.md")
Expect(err).NotTo(HaveOccurred())
Expect(string(docs)).To(ContainSubstring("`" + documentedAddonRepository + "`"))
for _, job := range []string{"publish", "promote"} {
Expect(workflow).NotTo(ContainSubstring("ADDON_OCI_BASE"))
Expect(workflow).NotTo(ContainSubstring("Choose the repository of this add-on"))
_ = job
}
for _, id := range firstPartyAddons {
out, err := run("target", "--repository", documentedAddonRepository, "--addon", id, "--tag", "sha-99d1dca2c869")
Expect(err).NotTo(HaveOccurred())
Expect(strings.TrimSpace(out)).To(Equal("target=" + documentedAddonRepository + ":" + id + "-sha-99d1dca2c869"))
}
_, err = run("target", "--repository", documentedAddonRepository, "--addon", "Bad_ID", "--tag", "sha-99d1dca2c869")
Expect(err).To(HaveOccurred())
_, err = run("target", "--repository", "quay.io/go-skynet/local-ai-backends", "--addon", "tables", "--tag", "sha-99d1dca2c869")
Expect(err).To(HaveOccurred())
})
Describe("the plan step", func() {
const platforms = "schema: 1\naddon: %s\nexecutable: %s\nmanifest: addons/%s/manifest.json\nui: addons/%s/ui/dist\nplatforms:\n - platform: linux/amd64\n enabled: %t\n runner: ubuntu-24.04\n native_check: executable-start\n"
var tool string
BeforeEach(func() {
tool = filepath.Join(GinkgoT().TempDir(), "addonpkg")
cmd := exec.Command("go", "build", "-o", tool, "./scripts/addons/cmd/addonpkg")
cmd.Dir = "../../.."
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
out, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(out))
})
// plan runs the plan step in a scratch checkout. enabled names the
// add-ons with an enabled platform; broken maps an add-on to the
// content of its platform file ("" removes the file).
plan := func(event, ref string, enabled []string, broken map[string]string) (string, string, error) {
GinkgoHelper()
root, err := filepath.Abs("../../..")
Expect(err).NotTo(HaveOccurred())
dir := GinkgoT().TempDir()
Expect(os.Symlink(filepath.Join(root, "scripts"), filepath.Join(dir, "scripts"))).To(Succeed())
for _, id := range firstPartyAddons {
Expect(os.MkdirAll(filepath.Join(dir, "addons", id, "package"), 0o755)).To(Succeed())
content := fmt.Sprintf(platforms, id, id, id, id, contains(enabled, id))
if b, ok := broken[id]; ok {
content = b
}
if content != "" {
Expect(os.WriteFile(filepath.Join(dir, "addons", id, "package", "platforms.yaml"), []byte(content), 0o644)).To(Succeed())
}
}
bin := filepath.Join(dir, "bin")
Expect(os.MkdirAll(bin, 0o755)).To(Succeed())
stub := "#!/bin/sh\n[ \"$1\" = run ] || exit 99\nshift 2\nexec \"" + tool + "\" \"$@\"\n"
Expect(os.WriteFile(filepath.Join(bin, "go"), []byte(stub), 0o755)).To(Succeed())
output := filepath.Join(dir, "github-output")
Expect(os.WriteFile(output, nil, 0o644)).To(Succeed())
cmd := exec.Command("sh", "-c", workflowStepScript(workflow, "plan", "plan"))
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"EVENT="+event, "REF="+ref, "SHA="+rev, "DRAFT=false", "PRERELEASE=false",
"ADDON_OCI_REPOSITORY="+documentedAddonRepository, "GITHUB_OUTPUT="+output)
out, runErr := cmd.CombinedOutput()
written, err := os.ReadFile(output)
Expect(err).NotTo(HaveOccurred())
return string(written), string(out), runErr
}
all := firstPartyAddons
It("fails on a publishing event when an add-on has no enabled platform", func() {
_, out, err := plan("push", "refs/heads/master", []string{"tables", "face"}, nil)
Expect(err).To(HaveOccurred())
Expect(out).To(ContainSubstring("add-on voice has no enabled platform; refusing to publish"))
})
It("publishes when every add-on has an enabled platform", func() {
written, out, err := plan("push", "refs/heads/master", all, nil)
Expect(err).NotTo(HaveOccurred(), out)
Expect(written).To(ContainSubstring("publish=true"))
Expect(written).To(ContainSubstring("has_platforms=true"))
for _, id := range all {
Expect(written).To(ContainSubstring(`"addon":"` + id + `"`))
}
})
It("gives an add-on without an enabled platform no legs on an event that publishes nothing", func() {
written, out, err := plan("pull_request", "refs/pull/1/merge", []string{"tables"}, nil)
Expect(err).NotTo(HaveOccurred(), out)
Expect(written).To(ContainSubstring("publish=false"))
Expect(written).To(ContainSubstring(`addons=["tables"]`))
written, out, err = plan("pull_request", "refs/pull/1/merge", nil, nil)
Expect(err).NotTo(HaveOccurred(), out)
Expect(written).To(ContainSubstring("has_platforms=false"))
})
It("fails on a missing or malformed platform file on every event", func() {
for _, c := range []struct{ event, ref string }{{"pull_request", "refs/pull/1/merge"}, {"push", "refs/heads/feature"}, {"push", "refs/heads/master"}} {
for name, content := range map[string]string{"missing": "", "malformed": "schema: [", "unsupported": "schema: 9\naddon: voice\n"} {
_, out, err := plan(c.event, c.ref, all, map[string]string{"voice": content})
Expect(err).To(HaveOccurred(), "%s file on %s %s", name, c.event, c.ref)
Expect(out).To(ContainSubstring("cannot read the platform matrix of add-on voice"))
}
}
})
})
})
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
var _ = Describe("package skills", func() {
It("lays the skills of the add-on in their own layer and verifies them with the install rules", func() {
p := newProjectFor("office", map[string][]string{"linux/amd64": nil})
dir := p.mustBuild("linux/amd64", "1.2.3", "office binary")
Expect(p.verify(dir, "linux/amd64", "1.2.3")).To(Succeed())
l, _, err := loadLeg(dir)
Expect(err).NotTo(HaveOccurred())
var roles []string
for _, d := range l.manifest.Layers {
roles = append(roles, d.Annotations[AnnotationLayer])
}
Expect(roles).To(Equal([]string{roleExecutable, roleUI, roleSkills, roleManifest}))
files, err := l.openLayers()
Expect(err).NotTo(HaveOccurred())
var names []string
for _, f := range files {
if f.role == roleSkills {
names = append(names, f.name)
}
}
Expect(names).To(ConsistOf("skills/xlsx/SKILL.md", "skills/docx/SKILL.md", "skills/pptx/SKILL.md", "skills/pdf/SKILL.md"))
})
It("refuses to build when the skills folder and the manifest disagree, or a skill breaks a rule", func() {
p := newProjectFor("office", map[string][]string{"linux/amd64": nil})
Expect(os.WriteFile(filepath.Join(p.root, "addons/office/skills/xlsx/run.py"), []byte("print(1)\n"), 0o644)).To(Succeed())
_, err := p.build("linux/amd64", "1.2.3", exe("office binary"))
Expect(err).To(MatchError(ContainSubstring("no scripts")))
Expect(os.Remove(filepath.Join(p.root, "addons/office/skills/xlsx/run.py"))).To(Succeed())
Expect(os.RemoveAll(filepath.Join(p.root, "addons/office/skills/pdf"))).To(Succeed())
_, err = p.build("linux/amd64", "1.2.3", exe("office binary"))
Expect(err).To(MatchError(ContainSubstring("no skills/pdf folder")))
})
})