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
41 changes: 34 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,18 @@ gdam add @username/addon
gdam install
```

Install a specific addon version:
Install an exact, case-sensitive GitHub Release tag:

```sh
gdam add @username/addon@1.2.3
gdam add @username/addon@Release-1
```

Without a tag, the registry selects the newest stable (non-prerelease) Release.
Exact tags may select prereleases. Tags are opaque: `v1.2.3` and `1.2.3` are
different, and hash-looking text is accepted only when it names a registered
GitHub Release. Tags may contain Git ref characters, including `/`, as long as
the package specification remains unambiguous (tags cannot contain `@`).

Remove an addon:

```sh
Expand Down Expand Up @@ -85,26 +91,47 @@ authenticated with your secret key.

## Project Files

`gdam init` creates a `gdam.json` file in a Godot project. `gdam add`, `gdam remove`, and `gdam install` keep that manifest in sync with installed addons under `res://addons/`.
`gdam init` creates a `gdam.json` file in a Godot project. Each registered
dependency stores its exact Release tag in a `tag` field. Old manifests with a
`version` field are intentionally unsupported and must be recreated with
`gdam add @owner/addon@<exact-tag>`; GDAM never rewrites them automatically.
`gdam add`, `gdam remove`, and `gdam install` keep the manifest in sync with
installed addons under `res://addons/`.

Local development links are tracked separately with `gdam.link.json`, so a project can use an unpublished local addon without changing the published dependency manifest.

## Publishing Addons

Registry releases are installed from GitHub Release assets. Publish an addon version with a semver package version such as `1.2.3`, a GitHub release tag, and an asset name.

The tag can be any valid GitHub release tag. The release tag is required when publishing.
Registry releases are installed from GitHub Release assets. Publish one exact
GitHub Release tag and, optionally, an asset selector. There is no separate
semantic package version.

The asset name can be anything the publisher chooses. That ZIP should contain the addon files at the archive root, including `plugin.cfg`. GDAM installs the asset into its local convention, such as `res://addons/@username_addon/`, regardless of the asset filename.

For CI publishing, create a secret key from the owner settings page, store it as `GDAM_SECRET_KEY`, and publish releases with:

```sh
gdam publish @username/addon 1.2.3 v1.2.3 @owner_repo.zip
gdam publish @username/addon Release-1 @owner_repo.zip
```

Secret keys are scoped to one user or org and can only publish releases for existing addons under that owner. If `ASSET_NAME` is omitted, `gdam publish` uses `@owner_repo.zip` from `GITHUB_REPOSITORY` when available.

## Download integrity and limits

The registry supplies the GitHub Release ID, exact tag, commit SHA, asset ID,
asset name, SHA-256 digest, publication time, and prerelease state. Before each
install, GDAM rechecks that identity with GitHub, downloads through the immutable
asset-ID endpoint, and verifies the digest before extraction. Any release, tag,
commit, asset, digest, truncation, or archive-layout drift fails closed.

Registry requests time out after 30 seconds and response bodies are limited to
4 MiB. Asset downloads time out after two
minutes, follow at most five redirects, and are limited to 128 MiB. Authorization
is removed on cross-origin redirects. Extraction rejects absolute paths, `..`
traversal, backslashes, and symlinks; it permits at most 10,000 entries and 512
MiB total uncompressed content. ZIP assets must contain `plugin.cfg` at the
archive root.

## Development

Build the CLI:
Expand Down
14 changes: 7 additions & 7 deletions internal/commands/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,18 @@ func Add(ctx context.Context, opts AddOptions) error {
existing, hasExisting := m.Addons[pkg.Name()]
isLinked := hasExisting && pluginLinkEnabled(existing)

resolved, err := resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, pkg.Version)
resolved, err := resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, pkg.Tag)
if err != nil {
return fmt.Errorf("%w: %v", ErrUserInput, err)
}

if isLinked {
existing.Version = resolved.Version
existing.Tag = resolved.TagName
m = manifest.UpsertAddon(m, pkg.Name(), existing)
if err := manifest.Save(manifestPath, m); err != nil {
return err
}
fmt.Printf("updated %s@%s (linked)\n", pkg.Name(), resolved.Version)
fmt.Printf("updated %s@%s (linked)\n", pkg.Name(), resolved.TagName)
return nil
}

Expand All @@ -77,7 +77,7 @@ func Add(ctx context.Context, opts AddOptions) error {
defer os.RemoveAll(tmpDir)

gh := githubapi.NewClient(os.Getenv("GITHUB_TOKEN"))
pkgRootDir, err := preparePackageRoot(ctx, gh, resolved.GitHubOwner, resolved.GitHubRepo, resolved.ReleaseTag, resolved.AssetName, tmpDir)
pkgRootDir, err := preparePackageRoot(ctx, gh, resolved, tmpDir)
if err != nil {
return fmt.Errorf("%w: %v", ErrUserInput, err)
}
Expand Down Expand Up @@ -125,8 +125,8 @@ func Add(ctx context.Context, opts AddOptions) error {
link = existing.Link
}
m = manifest.UpsertAddon(m, pkg.Name(), manifest.Addon{
Version: resolved.Version,
Link: link,
Tag: resolved.TagName,
Link: link,
})
if err := manifest.Save(manifestPath, m); err != nil {
return err
Expand All @@ -148,6 +148,6 @@ func Add(ctx context.Context, opts AddOptions) error {
}
}

fmt.Printf("installed %s@%s (%s)\n", pkg.Name(), resolved.Version, resolved.ReleaseTag)
fmt.Printf("installed %s@%s\n", pkg.Name(), resolved.TagName)
return nil
}
2 changes: 1 addition & 1 deletion internal/commands/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestAdd_ReplacesExistingUnmanagedAddonDir(t *testing.T) {
if err != nil {
t.Fatalf("load gdam.json: %v", err)
}
if got := loaded.Addons["@user/addon"].Version; got != "1.2.3" {
if got := loaded.Addons["@user/addon"].Tag; got != "1.2.3" {
t.Fatalf("expected version 1.2.3, got %q", got)
}
}
25 changes: 10 additions & 15 deletions internal/commands/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"

"github.com/aviorstudio/gdam/internal/fsutil"
"github.com/aviorstudio/gdam/internal/gdamdb"
"github.com/aviorstudio/gdam/internal/githubapi"
"github.com/aviorstudio/gdam/internal/manifest"
"github.com/aviorstudio/gdam/internal/project"
Expand All @@ -20,12 +21,9 @@ type installCandidate struct {
pluginKey string
addonDir string
dst string
version string
tag string
editorPlugin bool
ghOwner string
ghRepo string
ref string
assetName string
resolved gdamdb.ResolvedAddon
prepRootDir string
}

Expand Down Expand Up @@ -71,7 +69,7 @@ func Install(ctx context.Context, opts InstallOptions) error {
continue
}

resolved, err := resolveManifestAddon(ctx, pluginKey, addon.Version)
resolved, err := resolveManifestAddon(ctx, pluginKey, addon.Tag)
if err != nil {
return fmt.Errorf("%w: unable to resolve %s: %v", ErrUserInput, pluginKey, err)
}
Expand All @@ -80,12 +78,9 @@ func Install(ctx context.Context, opts InstallOptions) error {
pluginKey: pluginKey,
addonDir: addonDirName,
dst: filepath.Join(addonsDir, addonDirName),
version: resolved.Version,
tag: resolved.TagName,
editorPlugin: resolved.EditorPlugin,
ghOwner: resolved.GitHubOwner,
ghRepo: resolved.GitHubRepo,
ref: resolved.ReleaseTag,
assetName: resolved.AssetName,
resolved: resolved,
})
}

Expand Down Expand Up @@ -119,7 +114,7 @@ func Install(ctx context.Context, opts InstallOptions) error {
return err
}

pkgRootDir, err := preparePackageRoot(ctx, gh, candidates[i].ghOwner, candidates[i].ghRepo, candidates[i].ref, candidates[i].assetName, pkgTmpDir)
pkgRootDir, err := preparePackageRoot(ctx, gh, candidates[i].resolved, pkgTmpDir)
if err != nil {
return fmt.Errorf("%w: %v", ErrUserInput, err)
}
Expand All @@ -128,7 +123,7 @@ func Install(ctx context.Context, opts InstallOptions) error {
return fmt.Errorf("%w: %v", ErrUserInput, err)
} else if !ok {
expected := "res://" + path.Join("addons", candidates[i].addonDir, "plugin.cfg")
return fmt.Errorf("%w: package is missing plugin.cfg in release asset %s (expected to install it to %s)", ErrUserInput, candidates[i].assetName, expected)
return fmt.Errorf("%w: package is missing plugin.cfg in release asset %s (expected to install it to %s)", ErrUserInput, candidates[i].resolved.AssetName, expected)
}

if err := fsutil.RemoveAll(candidates[i].dst); err != nil {
Expand Down Expand Up @@ -158,8 +153,8 @@ func Install(ctx context.Context, opts InstallOptions) error {
}
}

if candidates[i].version != "" {
fmt.Printf("installed %s@%s\n", candidates[i].pluginKey, candidates[i].version)
if candidates[i].tag != "" {
fmt.Printf("installed %s@%s\n", candidates[i].pluginKey, candidates[i].tag)
} else {
fmt.Printf("installed %s\n", candidates[i].pluginKey)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/commands/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestInstall_ReplacesExistingAddonDir(t *testing.T) {
projectDir := t.TempDir()

m := manifest.New()
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"})
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"})
if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil {
t.Fatalf("write gdam.json: %v", err)
}
Expand Down Expand Up @@ -63,7 +63,7 @@ func TestInstall_InstallsMissingAddonFromRegistry(t *testing.T) {
projectDir := t.TempDir()

m := manifest.New()
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"})
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"})
if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil {
t.Fatalf("write gdam.json: %v", err)
}
Expand Down Expand Up @@ -98,7 +98,7 @@ func TestInstall_ReplacesManagedAddonButKeepsUnmanagedAddons(t *testing.T) {
projectDir := t.TempDir()

m := manifest.New()
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"})
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"})
if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil {
t.Fatalf("write gdam.json: %v", err)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/commands/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ func Link(ctx context.Context, opts LinkOptions) error {
if err != nil {
return fmt.Errorf("%w: %v", ErrUserInput, err)
}
if pkg.Version != "" {
return fmt.Errorf("%w: link does not take a version (use @username/addon)", ErrUserInput)
if pkg.Tag != "" {
return fmt.Errorf("%w: link does not take a tag (use @username/addon)", ErrUserInput)
}
pluginKey := pkg.Name()

Expand All @@ -57,7 +57,7 @@ func Link(ctx context.Context, opts LinkOptions) error {
addon, pluginExists := m.Addons[pluginKey]
editorPlugin := false
if pluginExists {
editorPlugin = manifestAddonEditorPlugin(ctx, pluginKey, addon.Version)
editorPlugin = manifestAddonEditorPlugin(ctx, pluginKey, addon.Tag)
}

pathInput := strings.TrimSpace(opts.Path)
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestLink_ReplacesLegacyEditorPluginEntryForSameLocalPath(t *testing.T) {
Path: pluginDir,
},
})
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"})
m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"})
if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil {
t.Fatalf("write gdam.json: %v", err)
}
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestLink_DisablesLegacyEditorPluginEntryDerivedFromPath(t *testing.T) {
}

m := manifest.New()
m = manifest.UpsertAddon(m, "@aviorstudio/gd-playwright", manifest.Addon{Version: "1.2.3"})
m = manifest.UpsertAddon(m, "@aviorstudio/gd-playwright", manifest.Addon{Tag: "1.2.3"})
if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil {
t.Fatalf("write gdam.json: %v", err)
}
Expand Down
37 changes: 12 additions & 25 deletions internal/commands/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,25 @@ import (
"strings"

"github.com/aviorstudio/gdam/internal/gdamdb"
"github.com/aviorstudio/gdam/internal/semver"
"github.com/aviorstudio/gdam/internal/spec"
)

type PublishOptions struct {
Spec string
Version string
ReleaseTag string
AssetName string
Spec string
TagName string
AssetName string
}

func Publish(ctx context.Context, opts PublishOptions) error {
pkg, err := spec.ParsePackageSpec(opts.Spec)
if err != nil {
return fmt.Errorf("%w: %v", ErrUserInput, err)
}
if strings.TrimSpace(pkg.Version) != "" {
return fmt.Errorf("%w: publish version must be a separate argument", ErrUserInput)
if strings.TrimSpace(pkg.Tag) != "" {
return fmt.Errorf("%w: publish tag must be a separate argument", ErrUserInput)
}

version, ok := semver.Parse(opts.Version)
if !ok || len(version.Pre) > 0 {
return fmt.Errorf("%w: version must be in MAJOR.MINOR.PATCH format", ErrUserInput)
}

releaseTag := strings.TrimSpace(opts.ReleaseTag)
releaseTag := strings.TrimSpace(opts.TagName)
if releaseTag == "" {
return fmt.Errorf("%w: release tag is required", ErrUserInput)
}
Expand All @@ -41,9 +34,6 @@ func Publish(ctx context.Context, opts PublishOptions) error {
if assetName == "" {
assetName = defaultCIAssetName()
}
if assetName == "" {
return fmt.Errorf("%w: asset name is required when GITHUB_REPOSITORY is not set", ErrUserInput)
}

secretKey := strings.TrimSpace(os.Getenv("GDAM_SECRET_KEY"))
if secretKey == "" {
Expand All @@ -52,19 +42,16 @@ func Publish(ctx context.Context, opts PublishOptions) error {

db := gdamdb.NewDefaultClient()
if err := db.PublishRelease(ctx, gdamdb.PublishReleaseInput{
SecretKey: secretKey,
Owner: pkg.Owner,
Addon: pkg.Repo,
Major: version.Major,
Minor: version.Minor,
Patch: version.Patch,
ReleaseTag: releaseTag,
AssetName: assetName,
SecretKey: secretKey,
Owner: pkg.Owner,
Addon: pkg.Repo,
TagName: releaseTag,
AssetName: assetName,
}); err != nil {
return err
}

fmt.Printf("published %s@%d.%d.%d\n", pkg.Name(), version.Major, version.Minor, version.Patch)
fmt.Printf("published %s@%s\n", pkg.Name(), releaseTag)
return nil
}

Expand Down
16 changes: 8 additions & 8 deletions internal/commands/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,25 @@ import (
"github.com/aviorstudio/gdam/internal/spec"
)

var resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedVersion string) (gdamdb.ResolvedAddon, error) {
return gdamdb.NewDefaultClient().ResolveAddon(ctx, owner, addon, requestedVersion)
var resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedTag string) (gdamdb.ResolvedAddon, error) {
return gdamdb.NewDefaultClient().ResolveAddon(ctx, owner, addon, requestedTag)
}

var preparePackageRoot = prepareGitHubPackageRoot

func resolveManifestAddon(ctx context.Context, addonKey, requestedVersion string) (gdamdb.ResolvedAddon, error) {
func resolveManifestAddon(ctx context.Context, addonKey, requestedTag string) (gdamdb.ResolvedAddon, error) {
pkg, err := spec.ParsePackageSpec(addonKey)
if err != nil {
return gdamdb.ResolvedAddon{}, fmt.Errorf("invalid addon key %s: %v", addonKey, err)
}
if strings.TrimSpace(pkg.Version) != "" {
return gdamdb.ResolvedAddon{}, fmt.Errorf("invalid addon key %s: versions belong in the version field", addonKey)
if strings.TrimSpace(pkg.Tag) != "" {
return gdamdb.ResolvedAddon{}, fmt.Errorf("invalid addon key %s: tags belong in the tag field", addonKey)
}
return resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, strings.TrimSpace(requestedVersion))
return resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, strings.TrimSpace(requestedTag))
}

func manifestAddonEditorPlugin(ctx context.Context, addonKey, requestedVersion string) bool {
resolved, err := resolveManifestAddon(ctx, addonKey, requestedVersion)
func manifestAddonEditorPlugin(ctx context.Context, addonKey, requestedTag string) bool {
resolved, err := resolveManifestAddon(ctx, addonKey, requestedTag)
if err != nil {
return false
}
Expand Down
Loading