ci(addons): publish every add-on to one repository (#12881)

All add-ons now publish to ADDON_OCI_REPOSITORY as tags of one
repository. Each tag starts with the add-on id: <id>-sha-<short sha> for
a commit, <id>-<version> for a release, <id>-development and
<id>-latest for the channel aliases.

The publish step, the alias promotion and the catalog renderer add the
prefix, so the per-add-on repository step of the workflow is gone. A new
addonpkg target command prints the reference an add-on publishes to
without a network request. The workflow policy now refuses a derived
repository.

The host parser already reads a tag reference and a repository with a
nested path, so both the new form and the old per-repository form
install. A test covers each.

The artifact fields in gallery/addons/index.yaml were changed by hand to
the tag form. The digests are unchanged and stay until the add-ons are
published again.


Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-24 18:26:40 +02:00
1 parent 34306b852d
commit b8f5453cc1
10 files changed
+222 -136

No files matched your search

+16 -40
View File
@@ -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.
@@ -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
@@ -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'
@@ -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 }}-*
@@ -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"
@@ -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
@@ -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 }}
@@ -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
@@ -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())
+20 -10
View File
@@ -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`
@@ -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`.
@@ -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
+4 -3
View File
@@ -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> \
@@ -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
+7 -7
View File
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+17 -3
View File
@@ -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")
@@ -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,
@@ -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)
@@ -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
}
+49 -55
View File
@@ -565,7 +565,7 @@ var _ = Describe("publication", func() {
Expect(out).To(Equal(first))
_ = before
repo, _ := name.NewRepository(reg.repo("addons/tables"))
d, err := remote.Head(repo.Tag("1.2.3"))
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)
@@ -583,7 +583,7 @@ var _ = Describe("publication", func() {
_, 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("1.2.3"))
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))
@@ -652,7 +652,7 @@ var _ = Describe("alias promotion", func() {
}
head := func(tag string) v1.Hash {
GinkgoHelper()
d, err := remote.Head(repo.Tag(tag))
d, err := remote.Head(repo.Tag("tables-" + tag))
Expect(err).NotTo(HaveOccurred())
return d.Digest
}
@@ -669,19 +669,19 @@ var _ = Describe("alias promotion", func() {
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, "development", mid, "", shas[1], gitDir)
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, "development", newer, "", shas[2], gitDir)
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, "development", old, "", shas[0], gitDir)
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, "development", newer, "", shas[2], gitDir)
res, err = promoteAlias(ctx, repo, "tables", "development", newer, "", shas[2], gitDir)
Expect(err).NotTo(HaveOccurred())
Expect(res).To(Equal("unchanged"))
})
@@ -699,15 +699,15 @@ var _ = Describe("alias promotion", func() {
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, "development", a, "", shas[1], gitDir)
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, "development", d, "", diverged, gitDir)
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, "development", same, "", diverged, gitDir)
_, err = promoteAlias(ctx, repo, "tables", "development", same, "", diverged, gitDir)
expectCode(err, "E_IMMUTABLE")
Expect(head("development")).To(Equal(d))
})
@@ -723,10 +723,10 @@ var _ = Describe("alias promotion", 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, tag, oldD, "", gone, gitDir)
_, 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, tag, newD, "", current, gitDir, promotionSource{Ref: ref, Event: "push"})
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))
@@ -735,7 +735,7 @@ var _ = Describe("alias promotion", 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, tag, oldD, "", gone, gitDir)
_, 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{
@@ -745,17 +745,17 @@ var _ = Describe("alias promotion", func() {
{Ref: "refs/tags/v1.0.0", Event: "push"},
{Ref: ref, Event: ""},
} {
_, err := promoteAliasFrom(ctx, repo, tag, newD, "", current, gitDir, src)
_, 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, "development", oldD, "", gone, gitDir)
_, 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, "development", newD, "", current, gitDir, promotionSource{Ref: "refs/heads/master", Event: "push"})
_, 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))
})
@@ -788,43 +788,43 @@ var _ = Describe("alias promotion", func() {
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, "latest", v2d, "1.1.0", rev, "")
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, "latest", v1d, "1.0.0", rev, "")
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, "latest", v2d, "1.2.0-rc.1", rev, "")
_, 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, "latest", other, "1.1.0", rev, "")
_, 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, alias, d, "1.0.0", rev, "")
_, 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, "latest", missing, "1.0.0", rev, "")
_, 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("latest"))
_, 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, tag, d, "", shas[0], gitDir)
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, "branch-feature-x", d, "", shas[0], gitDir)
_, err = promoteAlias(ctx, repo, "tables", "branch-feature-x", d, "", shas[0], gitDir)
expectCode(err, "E_RESERVED")
})
})
@@ -853,7 +853,7 @@ var _ = Describe("catalog template", func() {
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 + ":1.2.3"))
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"))
@@ -868,7 +868,7 @@ var _ = Describe("catalog template", func() {
for _, id := range []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk"} {
p := newProjectFor(id, both)
m := p.merged("1.2.3", rev, nil)
repoName := newRegistry().repo("addons/" + id)
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)
@@ -879,6 +879,7 @@ var _ = Describe("catalog template", func() {
Expect(entries).To(HaveLen(2), id)
for _, e := range entries {
Expect(e.ID).To(Equal(id))
Expect(e.Artifact).To(Equal("oci://" + repoName + ":" + id + "-1.2.3"))
Expect(e.Policy.RequireSignature).To(BeTrue())
}
}
@@ -1028,8 +1029,8 @@ var _ = Describe("workflow policy", func() {
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_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n #", " #"), "P_MATRIX")
expect(mutate("repo=\"${ADDON_OCI_BASE%/*}/$ADDON\"", "repo=\"$ADDON_OCI_BASE\""), "P_REPOSITORY")
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() {
@@ -1047,13 +1048,13 @@ var _ = Describe("workflow policy", 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 does not keep the configured repository for tables", func() {
expect(mutate(" if [ \"$ADDON\" = tables ]; then\n repo=\"$ADDON_OCI_BASE\"", " if [ \"$ADDON\" = never ]; then\n repo=\"$ADDON_OCI_BASE\""), "P_REPOSITORY")
expect(mutate(" repo=\"$ADDON_OCI_BASE\"\n else", " repo=\"${ADDON_OCI_BASE%/*}/$ADDON\"\n else"), "P_REPOSITORY")
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_BASE: ${{ 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_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}\n ADDON: ${{ matrix.addon }}\n steps:"), "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")
@@ -1165,8 +1166,8 @@ var _ = Describe("the add-on package workflow", func() {
})
// documentedAddonRepository is the value the documentation gives for the
// ADDON_OCI_REPOSITORY variable: the Tables repository in the add-on namespace.
const documentedAddonRepository = "quay.io/go-skynet/local-ai-addons/tables"
// 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) {
@@ -1198,31 +1199,24 @@ var _ = Describe("workflow behavior", func() {
workflow = string(b)
})
It("derives a repository that the add-on tools accept for every add-on from the documented variable", func() {
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"} {
script := workflowStepScript(workflow, job, "Choose the repository of this add-on")
for _, id := range firstPartyAddons {
env := filepath.Join(GinkgoT().TempDir(), "github-env")
Expect(os.WriteFile(env, nil, 0o644)).To(Succeed())
cmd := exec.Command("sh", "-c", script)
cmd.Env = append(os.Environ(), "ADDON_OCI_BASE="+documentedAddonRepository, "ADDON="+id, "GITHUB_ENV="+env)
out, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(out))
written, err := os.ReadFile(env)
Expect(err).NotTo(HaveOccurred())
repo := strings.TrimSpace(strings.TrimPrefix(string(written), "ADDON_OCI_REPOSITORY="))
if id == "tables" {
Expect(repo).To(Equal(documentedAddonRepository), "%s keeps the configured repository", job)
} else {
Expect(repo).To(Equal("quay.io/go-skynet/local-ai-addons/"+id), job)
}
_, err = parseRepository(repo)
Expect(err).NotTo(HaveOccurred(), "%s: %s for %s", job, repo, id)
}
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() {
+6 -8
View File
@@ -229,23 +229,21 @@ func checkWorkflow(data []byte) []string {
add("P_MATRIX", "%s must run once for each add-on: strategy.matrix.addon is missing", n)
}
}
// Publication is per add-on: the groups, the repository and the digest
// must not be shared between add-ons.
// Publication is per add-on: the groups and the digest must not be shared
// between add-ons. The repository is shared; the tag carries the id.
for _, n := range []string{"publish", "promote"} {
j := job(n)
c, _ := j["concurrency"].(map[string]any)
if !strings.Contains(fmt.Sprint(c["group"]), "matrix.addon") {
add("P_CONCURRENCY", "%s must serialize per add-on: the concurrency group needs matrix.addon", n)
}
if t := text(j); !strings.Contains(t, `ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"`) || !strings.Contains(t, `${ADDON_OCI_BASE%/*}/$ADDON`) {
add("P_REPOSITORY", "%s must choose the repository of the add-on: <namespace>/<id>, and the configured repository for tables", n)
if t := text(j); strings.Contains(t, "ADDON_OCI_BASE") || strings.Contains(t, "ADDON_OCI_REPOSITORY%/*") || strings.Contains(t, "ADDON_OCI_REPOSITORY=$repo") {
add("P_REPOSITORY", "%s must use the configured repository as it is: every add-on is a tag of one repository", n)
}
}
// Tables keeps the configured repository and every other add-on derives its
// own from the namespace.
for _, n := range []string{"publish", "promote"} {
if !regexp.MustCompile(`if \[ "\$ADDON" = tables \]; then\s+repo="\$ADDON_OCI_BASE"\s+else\s+repo="\$\{ADDON_OCI_BASE%/\*\}/\$ADDON"\s+fi`).MatchString(text(job(n))) {
add("P_REPOSITORY", "%s must keep ADDON_OCI_REPOSITORY for tables and derive <namespace>/<id> for every other add-on", n)
if !strings.Contains(text(job(n)), "ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}") {
add("P_REPOSITORY", "%s must read ADDON_OCI_REPOSITORY from the repository variable", n)
}
// A leg that is pushing or moving an alias must finish, and one
// failing add-on must not stop another.
+61 -10
View File
@@ -24,8 +24,20 @@ import (
var (
immutableTagRE = regexp.MustCompile(`^(sha-[0-9a-f]{12}|(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+(\.[0-9A-Za-z-]+)*)?)$`)
branchAliasRE = regexp.MustCompile(`^branch-[a-z0-9]+(-[a-z0-9]+)*-[0-9a-f]{12}$`)
addonIDRE = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`)
)
// addonTag is the registry tag of one add-on in the shared add-on repository:
// the add-on id, a dash, then the immutable tag or the channel alias. Every
// add-on lives in the same repository, so the id prefix keeps their tags apart,
// for example fine-tuning-sha-99d1dca2c869 or tables-development.
func addonTag(id, tag string) (string, error) {
if !addonIDRE.MatchString(id) {
return "", fmt.Errorf("%q is not an add-on id", id)
}
return id + "-" + tag, nil
}
// backendRepositoryPrefixes are repositories that belong to the backend
// image pipeline. Add-on packages never go there, whatever the input says.
var backendRepositoryPrefixes = []string{
@@ -110,7 +122,11 @@ func publishIndex(ctx context.Context, dir string, cfg *PlatformsFile, repo name
} else if immutable != m.Version {
return zero, fail("E_RESERVED", "version tag %q differs from the layout version %q", immutable, m.Version)
}
tag := repo.Tag(immutable)
regTag, err := addonTag(cfg.Addon, immutable)
if err != nil {
return zero, err
}
tag := repo.Tag(regTag)
if _, err := guardImmutable(ctx, tag, digest); err != nil {
if adopt && strings.HasPrefix(err.Error(), "E_IMMUTABLE") {
// A rebuild of the same version and commit differs in bytes only
@@ -195,7 +211,7 @@ func adoptExisting(ctx context.Context, repo name.Repository, tag name.Tag, m *m
func cmdPublish(args []string) error {
fs := flag.NewFlagSet("publish", flag.ContinueOnError)
dir := fs.String("layout", "", "merged layout")
repository := fs.String("repository", "", "registry and repository, for example registry.example/owner/addon-tables")
repository := fs.String("repository", "", "shared add-on registry and repository; every add-on is a tag of it, prefixed with its id")
immutable := fs.String("tag", "", "immutable version or commit tag")
config := fs.String("config", "", "platform matrix file")
adopt := fs.Bool("adopt-existing", false, "keep a published index of the same version and commit when a rebuild differs")
@@ -239,8 +255,12 @@ type aliasState struct {
revision string
}
func readAlias(ctx context.Context, repo name.Repository, alias string) (*aliasState, error) {
ref := repo.Tag(alias)
func readAlias(ctx context.Context, repo name.Repository, addon, alias string) (*aliasState, error) {
regTag, err := addonTag(addon, alias)
if err != nil {
return nil, err
}
ref := repo.Tag(regTag)
d, err := remote.Get(ref, remoteOptions(ctx)...)
if err != nil {
if isNotFound(err) {
@@ -391,14 +411,14 @@ func (s promotionSource) pushToOwnBranch(alias string) bool {
// promoteAlias moves one moving tag to a digest that was already published,
// verified and signed. It never moves an alias backwards: a slower, older run
// finds a newer alias and leaves it alone.
func promoteAlias(ctx context.Context, repo name.Repository, alias string, digest v1.Hash, version, revision, gitDir string) (string, error) {
return promoteAliasFrom(ctx, repo, alias, digest, version, revision, gitDir, promotionSource{})
func promoteAlias(ctx context.Context, repo name.Repository, addon, alias string, digest v1.Hash, version, revision, gitDir string) (string, error) {
return promoteAliasFrom(ctx, repo, addon, alias, digest, version, revision, gitDir, promotionSource{})
}
// promoteAliasFrom is promoteAlias with the event that asks for the move. A
// branch channel whose current commit is gone from the checkout (the branch
// was force-pushed) accepts only a push to its own ref as the new truth.
func promoteAliasFrom(ctx context.Context, repo name.Repository, alias string, digest v1.Hash, version, revision, gitDir string, src promotionSource) (string, error) {
func promoteAliasFrom(ctx context.Context, repo name.Repository, addon, alias string, digest v1.Hash, version, revision, gitDir string, src promotionSource) (string, error) {
switch {
case alias == aliasLatest:
if !versionRE.MatchString(version) || strings.Contains(version, "-") {
@@ -411,7 +431,7 @@ func promoteAliasFrom(ctx context.Context, repo name.Repository, alias string, d
default:
return "", fail("E_RESERVED", "%q is not a channel alias", alias)
}
cur, err := readAlias(ctx, repo, alias)
cur, err := readAlias(ctx, repo, addon, alias)
if err != nil {
return "", err
}
@@ -462,7 +482,11 @@ func promoteAliasFrom(ctx context.Context, repo name.Repository, alias string, d
if err != nil {
return "", err
}
if err := remote.Tag(repo.Tag(alias), idx, remoteOptions(ctx)...); err != nil {
regTag, err := addonTag(addon, alias)
if err != nil {
return "", err
}
if err := remote.Tag(repo.Tag(regTag), idx, remoteOptions(ctx)...); err != nil {
return "", fmt.Errorf("tagging %s: %w", alias, err)
}
return promoted, nil
@@ -471,6 +495,7 @@ func promoteAliasFrom(ctx context.Context, repo name.Repository, alias string, d
func cmdPromote(args []string) error {
fs := flag.NewFlagSet("promote", flag.ContinueOnError)
repository := fs.String("repository", "", "registry and repository")
addon := fs.String("addon", "", "add-on id; the alias tag is <id>-<alias> in the shared repository")
alias := fs.String("alias", "", "latest, development or a branch channel tag")
digest := fs.String("digest", "", "published index digest")
version := fs.String("version", "", "release version of the digest")
@@ -497,7 +522,7 @@ func cmdPromote(args []string) error {
if _, err := os.Stat(*gitDir); err != nil && *alias != aliasLatest {
return fmt.Errorf("git directory: %w", err)
}
out, err := promoteAliasFrom(ctx, repo, *alias, h, *version, *revision, *gitDir, promotionSource{Ref: *ref, Event: *event})
out, err := promoteAliasFrom(ctx, repo, *addon, *alias, h, *version, *revision, *gitDir, promotionSource{Ref: *ref, Event: *event})
if err != nil {
return err
}
@@ -526,3 +551,29 @@ func childDigests(ctx context.Context, repo name.Repository, digest v1.Hash) ([]
}
return out, nil
}
// cmdTarget prints the registry reference one add-on publishes to: the shared
// repository and the tag <id>-<tag>. It does not touch the network, so the
// workflow and the tests can show the target before anything is pushed.
func cmdTarget(args []string) error {
fs := flag.NewFlagSet("target", flag.ContinueOnError)
repository := fs.String("repository", "", "shared add-on registry and repository")
addon := fs.String("addon", "", "add-on id")
tag := fs.String("tag", "", "immutable version or commit tag, or a channel alias")
if err := fs.Parse(args); err != nil {
return err
}
repo, err := parseRepository(*repository)
if err != nil {
return err
}
if *tag == "" {
return errors.New("--tag is required")
}
regTag, err := addonTag(*addon, *tag)
if err != nil {
return err
}
_, _ = fmt.Fprintf(stdout, "target=%s\n", repo.Tag(regTag))
return nil
}
+1
View File
@@ -27,6 +27,7 @@ var commands = map[string]func(args []string) error{
"merge": cmdMerge,
"publish": cmdPublish,
"promote": cmdPromote,
"target": cmdTarget,
"catalog": cmdCatalog,
"policy": cmdPolicy,
}