diff --git a/.github/workflows/addons-package.yml b/.github/workflows/addons-package.yml index 043bb97b0..33c5bf53f 100644 --- a/.github/workflows/addons-package.yml +++ b/.github/workflows/addons-package.yml @@ -13,12 +13,14 @@ name: 'add-on packages' # push of a v 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//package/platforms.yaml), -# its own package legs and its own repository. Tables publishes to -# ADDON_OCI_REPOSITORY, which is the repository /tables. Each other -# add-on publishes to /, 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//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: +# -sha- for a commit, - for a release and +# -development or -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 /. 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 /. - 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 diff --git a/core/services/addons/packages/install_test.go b/core/services/addons/packages/install_test.go index 5f90a0ce1..383bfabba 100644 --- a/core/services/addons/packages/install_test.go +++ b/core/services/addons/packages/install_test.go @@ -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://:-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()) diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 31d654e86..da5841e2e 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -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/.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 `/`, 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: +`-sha-` for a commit, `-` for a release, and +`-development` or `-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 --addon --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://:`) and reads the +repository with any number of path elements, so an index that still names the old +repository-per-add-on form (`oci:///:`) 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 | `` | `latest`, after complete publication | | Published pre-release | `` | none | +The names in this table are the part after the add-on id. The registry tag is `-`, +for example `tables-sha-4edf30cda051` or `voice-development`. + Names are in disjoint namespaces: `latest` and `development` are reserved, `` 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 diff --git a/gallery/addons/README.md b/gallery/addons/README.md index 5a3bb343c..58572b623 100644 --- a/gallery/addons/README.md +++ b/gallery/addons/README.md @@ -14,7 +14,7 @@ go run ./scripts/addons/cmd/addonpkg catalog \ --template gallery/addons/tables.yaml.tmpl \ --merged \ --repository // \ - --tag > \ + --tag > \ --channel \ --owner-repo / \ --signer-ref \ @@ -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://:-`, 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://:` for the immutable tag; +- names `oci://:-` 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 diff --git a/gallery/addons/index.yaml b/gallery/addons/index.yaml index 7b0a2bdb3..374d19306 100644 --- a/gallery/addons/index.yaml +++ b/gallery/addons/index.yaml @@ -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" diff --git a/pkg/addons/packaging/catalog.go b/pkg/addons/packaging/catalog.go index 3db465ee9..abd6d3913 100644 --- a/pkg/addons/packaging/catalog.go +++ b/pkg/addons/packaging/catalog.go @@ -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-") + tag := fs.String("tag", "", "immutable tag the artifact reference names, without the add-on id prefix: the version or sha-") 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 +} diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go index 9b2ca28b9..a51a35fa7 100644 --- a/pkg/addons/packaging/packaging_test.go +++ b/pkg/addons/packaging/packaging_test.go @@ -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() { diff --git a/pkg/addons/packaging/policy.go b/pkg/addons/packaging/policy.go index 723d8ae63..19a651066 100644 --- a/pkg/addons/packaging/policy.go +++ b/pkg/addons/packaging/policy.go @@ -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: /, 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 / 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. diff --git a/pkg/addons/packaging/registry.go b/pkg/addons/packaging/registry.go index 0db07bd0b..8a211b6a0 100644 --- a/pkg/addons/packaging/registry.go +++ b/pkg/addons/packaging/registry.go @@ -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 - 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 -. 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 +} diff --git a/pkg/addons/packaging/run.go b/pkg/addons/packaging/run.go index a851f6027..1df0a5bf0 100644 --- a/pkg/addons/packaging/run.go +++ b/pkg/addons/packaging/run.go @@ -27,6 +27,7 @@ var commands = map[string]func(args []string) error{ "merge": cmdMerge, "publish": cmdPublish, "promote": cmdPromote, + "target": cmdTarget, "catalog": cmdCatalog, "policy": cmdPolicy, }