Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 16 additions & 40 deletions .github/workflows/addons-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ name: 'add-on packages'
# push of a v<semver> tag prepare the immutable version, move no alias
# release published promote latest, for a stable non-draft release only
#
# Every add-on has its own platform file (addons/<id>/package/platforms.yaml),
# its own package legs and its own repository. Tables publishes to
# ADDON_OCI_REPOSITORY, which is the repository <namespace>/tables. Each other
# add-on publishes to <namespace>/<id>, where the namespace is
# ADDON_OCI_REPOSITORY without its last path element. A publishing event fails
# for an add-on until one of its platforms is enabled.
# Every add-on has its own platform file (addons/<id>/package/platforms.yaml)
# and its own package legs. All add-ons publish to one OCI repository, the
# value of ADDON_OCI_REPOSITORY (the add-on registry organization). Each
# add-on is a set of tags of that repository, prefixed with its id:
# <id>-sha-<short sha> for a commit, <id>-<version> for a release and
# <id>-development or <id>-latest for a channel alias. Signatures and digests
# are per image, so add-ons never collide. A publishing event fails for an
# add-on until one of its platforms is enabled.
#
# Aliases move only in the promote job, after every platform leg passed, the
# index was pushed by digest, signed and verified.
Expand Down Expand Up @@ -148,6 +150,10 @@ jobs:
echo "ADDON_OCI_REPOSITORY is not configured; refusing to publish" >&2
exit 1
fi
case "$ADDON_OCI_REPOSITORY" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
fi
# One set of legs for each add-on, from its own platform file. Only
# the "no enabled platform" answer is tolerated, and only on an event
Expand Down Expand Up @@ -253,7 +259,7 @@ jobs:
matrix:
addon: ${{ fromJSON(needs.plan.outputs.addons) }}
env:
ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON: ${{ matrix.addon }}
# cosign gates --registry-referrers-mode=oci-1-1 behind this flag.
COSIGN_EXPERIMENTAL: '1'
Expand All @@ -264,22 +270,6 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Choose the repository of this add-on
# Tables publishes to the configured repository. Every other add-on
# publishes to <namespace>/<id>. The later steps read
# ADDON_OCI_REPOSITORY.
run: |
set -eu
case "$ADDON_OCI_BASE" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
if [ "$ADDON" = tables ]; then
repo="$ADDON_OCI_BASE"
else
repo="${ADDON_OCI_BASE%/*}/$ADDON"
fi
echo "ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
pattern: layout-${{ matrix.addon }}-*
Expand All @@ -305,6 +295,7 @@ jobs:
IMMUTABLE: ${{ needs.plan.outputs.immutable }}
run: |
set -eu
go run ./scripts/addons/cmd/addonpkg target --repository "$ADDON_OCI_REPOSITORY" --addon "$ADDON" --tag "$IMMUTABLE"
go run ./scripts/addons/cmd/addonpkg publish --config "addons/$ADDON/package/platforms.yaml" \
--layout merged --repository "$ADDON_OCI_REPOSITORY" --tag "$IMMUTABLE" --adopt-existing | tee push.txt
echo "digest=$(sed -n 's/^digest=//p' push.txt)" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -369,7 +360,7 @@ jobs:
matrix:
addon: ${{ fromJSON(needs.plan.outputs.addons) }}
env:
ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON: ${{ matrix.addon }}
steps:
- uses: actions/checkout@v7
Expand All @@ -379,21 +370,6 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Choose the repository of this add-on
# The same rule as in publish: Tables keeps the configured repository,
# every other add-on uses <namespace>/<id>.
run: |
set -eu
case "$ADDON_OCI_BASE" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
if [ "$ADDON" = tables ]; then
repo="$ADDON_OCI_BASE"
else
repo="${ADDON_OCI_BASE%/*}/$ADDON"
fi
echo "ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
name: digest-${{ matrix.addon }}
Expand All @@ -420,7 +396,7 @@ jobs:
esac
for alias in $(echo "$ALIASES" | tr ',' ' '); do
go run ./scripts/addons/cmd/addonpkg promote \
--repository "$ADDON_OCI_REPOSITORY" --alias "$alias" \
--repository "$ADDON_OCI_REPOSITORY" --addon "$ADDON" --alias "$alias" \
--digest "$DIGEST" --version "$VERSION" --revision "$SHA" \
--ref "$REF" --event "$EVENT" --git-dir .git
done
41 changes: 41 additions & 0 deletions core/services/addons/packages/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,47 @@ var _ = Describe("distribution adapters", func() {
Expect(e).To(HaveOccurred())
Expect(externalRequests.Load()).To(BeZero())
})
// The shared add-on repository names an add-on by a tag prefixed with its
// id (oci://<repo>:<id>-sha-<short sha>). The reference is pinned by
// digest, so the tag form decides nothing but the parse. The old
// repository-per-add-on form still parses and installs.
for _, form := range []struct{ title, path string }{
{"the shared repository with an id-prefixed tag", "/local-ai-addons:sample-sha-0123456789ab"},
{"the old repository per add-on", "/local-ai-addons/sample:sha-0123456789ab"},
} {
form := form
It("installs from "+form.title, func() {
m, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json")
Expect(e).NotTo(HaveOccurred())
data := archive(nil, m)
layer, e := tarball.LayerFromReader(bytes.NewReader(data))
Expect(e).NotTo(HaveOccurred())
img, e := mutate.AppendLayers(empty.Image, layer)
Expect(e).NotTo(HaveOccurred())
cfg, e := img.ConfigFile()
Expect(e).NotTo(HaveOccurred())
cfg.OS = "linux"
cfg.Architecture = "amd64"
img, e = mutate.ConfigFile(img, cfg)
Expect(e).NotTo(HaveOccurred())
server := httptest.NewServer(registry.New())
DeferCleanup(server.Close)
ref, e := name.ParseReference(strings.TrimPrefix(server.URL, "http://") + form.path)
Expect(e).NotTo(HaveOccurred())
Expect(remote.Write(ref, img)).To(Succeed())
digest, e := img.Digest()
Expect(e).NotTo(HaveOccurred())
i, r := fixture(data, func(r *catalog.Release, s *catalog.Source) {
r.Artifact = "oci://" + ref.Name()
r.Digest = digest.String()
})
staged, e := i.Stage(context.Background(), r, nil)
Expect(e).NotTo(HaveOccurred())
receipt, e := i.Commit(context.Background(), staged)
Expect(e).NotTo(HaveOccurred())
Expect(receipt.Digest).To(Equal(digest.String()))
})
}
It("rejects downloaded digest mismatch redirects cancellation and file count limits", func() {
m, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json")
Expect(e).NotTo(HaveOccurred())
Expand Down
30 changes: 20 additions & 10 deletions docs/content/features/addons.md
Original file line number Diff line number Diff line change
Expand Up @@ -3222,8 +3222,8 @@ platforms of that add-on. A platform is shipped only
while `enabled: true`, and it needs a native runner and the `executable-start` native
check. Until a platform's native check passes on a runner of that platform, it stays
listed with `enabled: false`. The workflow fails closed on an empty matrix and on a
missing registry/namespace input (`ADDON_OCI_REPOSITORY`). There is no default registry.
The add-on repository namespace is `quay.io/go-skynet/local-ai-addons`; every other
missing repository input (`ADDON_OCI_REPOSITORY`). There is no default registry.
The add-on repository is `quay.io/go-skynet/local-ai-addons`; every other
backend image repository is refused.

Every platform matrix lists four platforms: `linux/amd64` on `ubuntu-24.04`, `linux/arm64`
Expand All @@ -3235,15 +3235,22 @@ Each package also has a catalog template in `gallery/addons/<id>.yaml.tmpl`, so
listed in Find add-ons once a maintainer renders and publishes its entries. See
`gallery/addons/README.md` for the commands.

Every add-on has its own repository. Tables publishes to `ADDON_OCI_REPOSITORY`. Each
other add-on publishes to `<namespace>/<id>`, where the namespace is
`ADDON_OCI_REPOSITORY` without its last path element.
All add-ons publish to one OCI repository, the value of `ADDON_OCI_REPOSITORY`. Each
add-on is a set of tags of that repository, and every tag starts with the add-on id:
`<id>-sha-<short sha>` for a commit, `<id>-<version>` for a release, and
`<id>-development` or `<id>-latest` for a channel alias. For example, the Fine-tuning
build of one commit is
`oci://quay.io/go-skynet/local-ai-addons:fine-tuning-sha-99d1dca2c869`.

Set `ADDON_OCI_REPOSITORY` to the Tables repository inside the add-on namespace:
`quay.io/go-skynet/local-ai-addons/tables`. The other add-ons then publish to
`quay.io/go-skynet/local-ai-addons/voice`, `.../face`, `.../fine-tuning` and
`.../quantization`. The value has no tag and no digest. A value outside the add-on
namespace makes the add-on tools refuse the repository.
Set `ADDON_OCI_REPOSITORY` to `quay.io/go-skynet/local-ai-addons`. The value has no tag
and no digest. A value that is a backend image repository makes the add-on tools refuse
it. `addonpkg target --repository <repository> --addon <id> --tag <tag>` prints the
reference one add-on publishes to, without a network request.

The index entries pin each platform image by digest, so the tag only names the
artifact. The host accepts a tag reference (`oci://<repository>:<tag>`) and reads the
repository with any number of path elements, so an index that still names the old
repository-per-add-on form (`oci://<repository>/<id>:<tag>`) keeps working.

Publishing events (a push to `master`, a version tag and a published release) fail closed
for an add-on until at least one of its platforms is enabled in its `platforms.yaml`.
Expand All @@ -3265,6 +3272,9 @@ place that decides what an event may publish. Its output is what the workflow us
| Published, stable, non-draft release | `<semver>` | `latest`, after complete publication |
| Published pre-release | `<semver>` | none |

The names in this table are the part after the add-on id. The registry tag is `<id>-<name>`,
for example `tables-sha-4edf30cda051` or `voice-development`.

Names are in disjoint namespaces: `latest` and `development` are reserved, `<semver>`
and `sha-<12 hex>` are immutable, and every branch channel starts with `branch-`. The
branch channel is `branch-` + a label + `-` + the first 12 hex characters of the SHA-256
Expand Down
7 changes: 4 additions & 3 deletions gallery/addons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ go run ./scripts/addons/cmd/addonpkg catalog \
--template gallery/addons/tables.yaml.tmpl \
--merged <merged layout> \
--repository <registry>/<namespace>/<repository> \
--tag <immutable tag: the version or sha-<12 hex>> \
--tag <immutable tag without the id prefix: the version or sha-<12 hex>> \
--channel <latest | development | branch-...> \
--owner-repo <owner>/<repository> \
--signer-ref <refs/tags/vX.Y.Z | refs/heads/master> \
Expand All @@ -32,13 +32,14 @@ go run ./scripts/addons/cmd/addonpkg catalog \
```

Use `face`, `fine-tuning`, `quantization`, `studio` or `talk` in place of `voice` for the other packages.
Each package has its own repository, so pass the repository of that package.
All packages share one repository, so pass the same repository for each. The artifact of an
entry is `oci://<repository>:<id>-<tag>`, for example `...:voice-sha-50f0f3de26d2`.

The add-on packages live in `quay.io/go-skynet/local-ai-addons`; the template itself holds
no registry value. The result is a YAML list with one entry for each enabled platform. Each entry:

- pins the digest of the platform image, so a tag that moves later changes nothing;
- names `oci://<repository>:<tag>` for the immutable tag;
- names `oci://<repository>:<id>-<tag>` for the immutable tag of that add-on;
- carries the channel it is current for and the version of the package, so the
channel is metadata that maps to a digest;
- states the signature policy: signature required, issuer
Expand Down
14 changes: 7 additions & 7 deletions gallery/addons/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
description: "Train an adapter on your own data, then export it as a model you can chat with."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/fine-tuning:sha-99d1dca2c869"
artifact: "oci://quay.io/go-skynet/local-ai-addons:fine-tuning-sha-99d1dca2c869"
digest: "sha256:9e2168ea73c32427e9f7d3c071d2dd36ca37877a78dd3bdbacebd8b9fe6e270a"
os: "linux"
arch: "amd64"
Expand All @@ -26,7 +26,7 @@
description: "Shrink a model to a smaller quantized file, then import it so you can chat with it."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/quantization:sha-99d1dca2c869"
artifact: "oci://quay.io/go-skynet/local-ai-addons:quantization-sha-99d1dca2c869"
digest: "sha256:d1dd5b3cfc7e96cac2825cd8aaf86ebacb49d686f433f1969ad149928d0858ad"
os: "linux"
arch: "amd64"
Expand All @@ -44,7 +44,7 @@
description: "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them."
version: "0.0.0-dev.50f0f3de26d2"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/voice:sha-50f0f3de26d2"
artifact: "oci://quay.io/go-skynet/local-ai-addons:voice-sha-50f0f3de26d2"
digest: "sha256:1343e18fe7b05e6d65da57f6f86ebf75f2861f24ab029a9179935c98fb613583"
os: "linux"
arch: "amd64"
Expand All @@ -62,7 +62,7 @@
description: "Find out whose face is in a photo, or whether two photos show the same person."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/face:sha-99d1dca2c869"
artifact: "oci://quay.io/go-skynet/local-ai-addons:face-sha-99d1dca2c869"
digest: "sha256:a48ad28afc98b10f952e330243487a0fd3ab2aa4187478e8768712f6b77e86f6"
os: "linux"
arch: "amd64"
Expand All @@ -80,7 +80,7 @@
description: "Make images, video, 3D models, speech, sound and transformed audio with the models you have installed, and keep what you make."
version: "0.0.0-dev.422af83405a3"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/studio:sha-422af83405a3"
artifact: "oci://quay.io/go-skynet/local-ai-addons:studio-sha-422af83405a3"
digest: "sha256:1b06153044ba49ce235a56b0739376b75079f38b939ee62c9f3f6f308ddb8073"
os: "linux"
arch: "amd64"
Expand All @@ -98,7 +98,7 @@
description: "Have a spoken conversation with a model: your voice goes in, the answer is spoken back."
version: "0.0.0-dev.422af83405a3"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/talk:sha-422af83405a3"
artifact: "oci://quay.io/go-skynet/local-ai-addons:talk-sha-422af83405a3"
digest: "sha256:678569227df72b597f40859a3830bb3a8a17ec02a19050ad71c416b91f4e7951"
os: "linux"
arch: "amd64"
Expand All @@ -116,7 +116,7 @@
description: "Databases of tables you own or share: typed rows, filters, schema and sharing."
version: "0.0.0-dev.4edf30cda051"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/tables:sha-4edf30cda051"
artifact: "oci://quay.io/go-skynet/local-ai-addons:tables-sha-4edf30cda051"
digest: "sha256:97863f9c6e848e50cb11617aa1e02d69716e7003d11585388ab5e2547e39d795"
os: "linux"
arch: "amd64"
Expand Down
20 changes: 17 additions & 3 deletions pkg/addons/packaging/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func cmdCatalog(args []string) error {
dir := fs.String("merged", "", "merged layout")
config := fs.String("config", "", "platform matrix file")
repository := fs.String("repository", "", "registry and repository the package was published to")
tag := fs.String("tag", "", "immutable tag the artifact reference names: the version or sha-<commit>")
tag := fs.String("tag", "", "immutable tag the artifact reference names, without the add-on id prefix: the version or sha-<commit>")
channel := fs.String("channel", "", "channel name the entries are current for: latest, development or a branch channel")
ownerRepo := fs.String("owner-repo", "", "GitHub owner/repository that ran the workflow")
signerRef := fs.String("signer-ref", "", "ref of the signing workflow run")
Expand Down Expand Up @@ -152,7 +152,7 @@ func cmdCatalog(args []string) error {
}
}
values := map[string]string{
"addon": v.Manifest.ID, "version": m.Version, "tag": *tag, "channel": *channel,
"addon": v.Manifest.ID, "version": m.Version, "tag": addonTagOrEmpty(v.Manifest.ID, *tag), "channel": *channel,
"repository": repo.Name(), "digest": d.Digest.String(),
"os": d.Platform.OS, "arch": d.Platform.Architecture,
"issuer": OIDCIssuer, "identity": identity,
Expand All @@ -171,7 +171,11 @@ func cmdCatalog(args []string) error {
return fmt.Errorf("rendered entry: %v", err)
}
r := parsed[0]
if err := checkRelease(r, m.Version, d.Digest.String(), repo.Name(), *tag); err != nil {
regTag, err := addonTag(v.Manifest.ID, *tag)
if err != nil {
return err
}
if err := checkRelease(r, m.Version, d.Digest.String(), repo.Name(), regTag); err != nil {
return err
}
releases = append(releases, r)
Expand Down Expand Up @@ -241,3 +245,13 @@ func checkRelease(r catalogRelease, version, digest, repository, tag string) err
}
return nil
}

// addonTagOrEmpty is addonTag for the template values. An invalid id yields an
// empty tag, which checkRelease then refuses.
func addonTagOrEmpty(id, tag string) string {
t, err := addonTag(id, tag)
if err != nil {
return ""
}
return t
}
Loading
Loading