diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8204db7..fbdf513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,3 +14,14 @@ jobs: go-version-file: go.mod - run: go build ./... - run: go test ./... -v -race -count=1 + wfctl-strict-contracts: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Validate strict plugin contracts + run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d05e744..3d0adc5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,8 +2,11 @@ version: 2 before: hooks: - - "cp plugin.json plugin.json.orig" - - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + - "mkdir -p dist" + - "cp plugin.json dist/plugin.json" + - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' dist/plugin.json && rm -f dist/plugin.json.bak" + - "sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' dist/plugin.json && rm -f dist/plugin.json.bak" + - "go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file dist/plugin.json --strict-contracts" builds: - main: ./cmd/{{ .ProjectName }} @@ -23,7 +26,8 @@ archives: - formats: [tar.gz] name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" files: - - plugin.json + - src: dist/plugin.json + dst: plugin.json checksum: name_template: checksums.txt diff --git a/go.mod b/go.mod index 13be262..a7b6a3a 100644 --- a/go.mod +++ b/go.mod @@ -220,7 +220,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/internal/plugin.go b/internal/plugin.go index ac1b498..893b5a7 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -14,6 +14,67 @@ var Version = "0.0.0" type discordPlugin struct{} +type contractDescriptor struct { + Kind string + Type string + Config string + Output string +} + +type moduleRegistration struct { + typeName string + config string + create func(name string, config map[string]any) (sdk.ModuleInstance, error) +} + +type stepRegistration struct { + typeName string + config string + output string + create func() sdk.StepInstance +} + +type triggerRegistration struct { + typeName string + config string + output string + create func(config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) +} + +var discordModuleRegistrations = []moduleRegistration{ + { + typeName: "discord.provider", + config: "discord.v1.ProviderConfig", + create: func(name string, config map[string]any) (sdk.ModuleInstance, error) { + return newDiscordProvider(name, config) + }, + }, +} + +var discordStepRegistrations = []stepRegistration{ + {"step.discord_send_message", "discord.v1.SendMessageConfig", "discord.v1.SendMessageOutput", func() sdk.StepInstance { return &sendMessageStep{} }}, + {"step.discord_send_embed", "discord.v1.SendEmbedConfig", "discord.v1.SendEmbedOutput", func() sdk.StepInstance { return &sendEmbedStep{} }}, + {"step.discord_edit_message", "discord.v1.EditMessageConfig", "discord.v1.EditMessageOutput", func() sdk.StepInstance { return &editMessageStep{} }}, + {"step.discord_delete_message", "discord.v1.DeleteMessageConfig", "discord.v1.DeleteMessageOutput", func() sdk.StepInstance { return &deleteMessageStep{} }}, + {"step.discord_add_reaction", "discord.v1.AddReactionConfig", "discord.v1.AddReactionOutput", func() sdk.StepInstance { return &addReactionStep{} }}, + {"step.discord_upload_file", "discord.v1.UploadFileConfig", "discord.v1.UploadFileOutput", func() sdk.StepInstance { return &uploadFileStep{} }}, + {"step.discord_create_thread", "discord.v1.CreateThreadConfig", "discord.v1.CreateThreadOutput", func() sdk.StepInstance { return &createThreadStep{} }}, + {"step.discord_voice_join", "discord.v1.VoiceJoinConfig", "discord.v1.VoiceJoinOutput", func() sdk.StepInstance { return &voiceJoinStep{} }}, + {"step.discord_voice_leave", "discord.v1.VoiceLeaveConfig", "discord.v1.VoiceLeaveOutput", func() sdk.StepInstance { return &voiceLeaveStep{} }}, + {"step.discord_voice_play", "discord.v1.VoicePlayConfig", "discord.v1.VoicePlayOutput", func() sdk.StepInstance { return &voicePlayStep{} }}, +} + +var discordTriggerRegistrations = []triggerRegistration{ + { + typeName: "trigger.discord", + config: "discord.v1.TriggerConfig", + output: "discord.v1.TriggerPayload", + create: func(config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { + return newDiscordTrigger(config, cb) + }, + }, +} + // New returns a new discordPlugin instance. func New() *discordPlugin { return &discordPlugin{} } @@ -29,76 +90,87 @@ func (p *discordPlugin) Manifest() sdk.PluginManifest { // ModuleTypes returns the module type names this plugin provides. func (p *discordPlugin) ModuleTypes() []string { - return []string{"discord.provider"} + types := make([]string, 0, len(discordModuleRegistrations)) + for _, registration := range discordModuleRegistrations { + types = append(types, registration.typeName) + } + return types } // StepTypes returns the step type names this plugin provides. func (p *discordPlugin) StepTypes() []string { - return []string{ - "step.discord_send_message", - "step.discord_send_embed", - "step.discord_edit_message", - "step.discord_delete_message", - "step.discord_add_reaction", - "step.discord_upload_file", - "step.discord_create_thread", - "step.discord_voice_join", - "step.discord_voice_leave", - "step.discord_voice_play", + types := make([]string, 0, len(discordStepRegistrations)) + for _, registration := range discordStepRegistrations { + types = append(types, registration.typeName) } + return types } // TriggerTypes returns the trigger type names this plugin provides. func (p *discordPlugin) TriggerTypes() []string { - return []string{"trigger.discord"} + types := make([]string, 0, len(discordTriggerRegistrations)) + for _, registration := range discordTriggerRegistrations { + types = append(types, registration.typeName) + } + return types +} + +func (p *discordPlugin) contractDescriptors() []contractDescriptor { + descriptors := make([]contractDescriptor, 0, len(discordModuleRegistrations)+len(discordStepRegistrations)+len(discordTriggerRegistrations)) + for _, registration := range discordModuleRegistrations { + descriptors = append(descriptors, contractDescriptor{ + Kind: "module", + Type: registration.typeName, + Config: registration.config, + }) + } + for _, registration := range discordStepRegistrations { + descriptors = append(descriptors, contractDescriptor{ + Kind: "step", + Type: registration.typeName, + Config: registration.config, + Output: registration.output, + }) + } + for _, registration := range discordTriggerRegistrations { + descriptors = append(descriptors, contractDescriptor{ + Kind: "trigger", + Type: registration.typeName, + Config: registration.config, + Output: registration.output, + }) + } + return descriptors } // CreateModule creates a module instance of the given type. func (p *discordPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { - switch typeName { - case "discord.provider": - return newDiscordProvider(name, config) - default: - return nil, fmt.Errorf("discord plugin: unknown module type %q", typeName) + for _, registration := range discordModuleRegistrations { + if registration.typeName == typeName { + return registration.create(name, config) + } } + return nil, fmt.Errorf("discord plugin: unknown module type %q", typeName) } // CreateStep creates a step instance of the given type. func (p *discordPlugin) CreateStep(typeName, name string, config map[string]any) (sdk.StepInstance, error) { // Steps need access to a provider session; they'll resolve it at Execute time // via a shared registry keyed by module name. - switch typeName { - case "step.discord_send_message": - return &sendMessageStep{}, nil - case "step.discord_send_embed": - return &sendEmbedStep{}, nil - case "step.discord_edit_message": - return &editMessageStep{}, nil - case "step.discord_delete_message": - return &deleteMessageStep{}, nil - case "step.discord_add_reaction": - return &addReactionStep{}, nil - case "step.discord_upload_file": - return &uploadFileStep{}, nil - case "step.discord_create_thread": - return &createThreadStep{}, nil - case "step.discord_voice_join": - return &voiceJoinStep{}, nil - case "step.discord_voice_leave": - return &voiceLeaveStep{}, nil - case "step.discord_voice_play": - return &voicePlayStep{}, nil - default: - return nil, fmt.Errorf("discord plugin: unknown step type %q", typeName) + for _, registration := range discordStepRegistrations { + if registration.typeName == typeName { + return registration.create(), nil + } } + return nil, fmt.Errorf("discord plugin: unknown step type %q", typeName) } // CreateTrigger creates a trigger instance of the given type. func (p *discordPlugin) CreateTrigger(typeName string, config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { - switch typeName { - case "trigger.discord": - return newDiscordTrigger(config, cb) - default: - return nil, fmt.Errorf("discord plugin: unknown trigger type %q", typeName) + for _, registration := range discordTriggerRegistrations { + if registration.typeName == typeName { + return registration.create(config, cb) + } } + return nil, fmt.Errorf("discord plugin: unknown trigger type %q", typeName) } diff --git a/internal/plugin_test.go b/internal/plugin_test.go index a40c348..2180575 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -2,7 +2,16 @@ package internal import ( "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestManifest(t *testing.T) { @@ -102,11 +111,198 @@ func TestCreateStepAllTypes(t *testing.T) { } } -func TestStepTypesMatchManifest(t *testing.T) { - // Verify the step count matches plugin.json capabilities +func TestRuntimeTypesMatchManifestCapabilities(t *testing.T) { + p := New() + manifest := loadPluginManifest(t) + + tests := []struct { + name string + got []string + want []string + }{ + {"module types", p.ModuleTypes(), manifest.Capabilities.ModuleTypes}, + {"step types", p.StepTypes(), manifest.Capabilities.StepTypes}, + {"trigger types", p.TriggerTypes(), manifest.Capabilities.TriggerTypes}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, want := stringSet(tt.got), stringSet(tt.want); !setsEqual(got, want) { + t.Fatalf("%s = %v, want manifest %v", tt.name, got, want) + } + }) + } +} + +func TestPluginContractsMatchRuntimeTypes(t *testing.T) { p := New() - if len(p.StepTypes()) != 10 { - t.Errorf("expected 10 step types to match plugin.json, got %d", len(p.StepTypes())) + manifest := loadPluginManifest(t) + + want := map[string]bool{} + for _, moduleType := range p.ModuleTypes() { + want["module:"+moduleType] = true + } + for _, stepType := range p.StepTypes() { + want["step:"+stepType] = true + } + for _, triggerType := range p.TriggerTypes() { + want["trigger:"+triggerType] = true + } + wantDescriptors := map[string]struct { + config string + output string + }{} + for _, descriptor := range p.contractDescriptors() { + wantDescriptors[descriptor.Kind+":"+descriptor.Type] = struct { + config string + output string + }{descriptor.Config, descriptor.Output} + } + + got := map[string]bool{} + for _, contract := range manifest.Contracts { + key := contract.Kind + ":" + contract.Type + got[key] = true + if contract.Mode != "strict" { + t.Fatalf("%s mode = %q, want strict", key, contract.Mode) + } + wantDescriptor, ok := wantDescriptors[key] + if !ok { + t.Fatalf("%s has no expected descriptor", key) + } + if contract.Config != wantDescriptor.config { + t.Fatalf("%s config = %q, want %q", key, contract.Config, wantDescriptor.config) + } + if contract.Output != wantDescriptor.output { + t.Fatalf("%s output = %q, want %q", key, contract.Output, wantDescriptor.output) + } + } + if !setsEqual(got, want) { + t.Fatalf("contracts = %v, want runtime types %v", got, want) + } +} + +func TestPluginDownloadsMatchGoReleaserMatrix(t *testing.T) { + manifest := loadPluginManifest(t) + release := loadGoReleaserConfig(t) + if len(release.Builds) != 1 { + t.Fatalf("goreleaser builds = %d, want 1", len(release.Builds)) + } + build := release.Builds[0] + + want := map[string]string{} + for _, goos := range build.Goos { + for _, goarch := range build.Goarch { + key := goos + "/" + goarch + want[key] = fmt.Sprintf("https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v%s/workflow-plugin-discord-%s-%s.tar.gz", manifest.Version, goos, goarch) + } + } + + got := map[string]string{} + for _, download := range manifest.Downloads { + got[download.OS+"/"+download.Arch] = download.URL + } + if !setsEqual(stringSetFromMap(got), stringSetFromMap(want)) { + t.Fatalf("download matrix = %v, want %v", got, want) + } + for key, wantURL := range want { + if gotURL := got[key]; gotURL != wantURL { + t.Fatalf("download %s = %q, want %q", key, gotURL, wantURL) + } + } +} + +func TestGoReleaserValidatesRewrittenPluginManifest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("GoReleaser hooks are shell commands") + } + + config := loadGoReleaserConfig(t) + if len(config.Before.Hooks) == 0 { + t.Fatal(".goreleaser.yaml must define before hooks") + } + + releaseVersion := "9.8.7" + tmp := t.TempDir() + copyFile(t, filepath.Join(repoRoot(t), "plugin.json"), filepath.Join(tmp, "plugin.json")) + originalManifest := loadPluginManifestFrom(t, filepath.Join(tmp, "plugin.json")) + binDir := t.TempDir() + validationLog := filepath.Join(tmp, "validation.log") + writeExecutable(t, filepath.Join(binDir, "go"), `#!/bin/sh +if [ "$1" = "run" ]; then + shift + shift + exec wfctl "$@" +fi +echo "unexpected go invocation: $*" >&2 +exit 42 +`) + writeExecutable(t, filepath.Join(binDir, "wfctl"), `#!/bin/sh +file="" +strict=0 +prev="" +for arg in "$@"; do + if [ "$prev" = "--file" ]; then + file="$arg" + fi + if [ "$arg" = "--strict-contracts" ]; then + strict=1 + fi + prev="$arg" +done +if [ "$1" != "plugin" ] || [ "$2" != "validate" ]; then + echo "unexpected wfctl invocation: $*" >&2 + exit 43 +fi +if [ "$strict" != "1" ]; then + echo "missing --strict-contracts" >&2 + exit 44 +fi +if [ -z "$file" ]; then + echo "missing --file" >&2 + exit 45 +fi +grep -q "\"version\": \"$RELEASE_VERSION\"" "$file" || exit 46 +grep -q "/releases/download/v$RELEASE_VERSION/" "$file" || exit 47 +if grep -q "/releases/download/v0.1.0/" "$file"; then + exit 48 +fi +grep -q "\"mode\": \"strict\"" "$file" || exit 49 +printf 'validated %s\n' "$file" >> "$VALIDATION_LOG" +`) + + for _, hook := range config.Before.Hooks { + cmd := exec.CommandContext(context.Background(), "sh", "-c", strings.ReplaceAll(hook, "{{ .Version }}", releaseVersion)) + cmd.Dir = tmp + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "RELEASE_VERSION="+releaseVersion, + "VALIDATION_LOG="+validationLog, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run GoReleaser hook %q: %v\n%s", hook, err, output) + } + } + + sourceManifest := loadPluginManifestFrom(t, filepath.Join(tmp, "plugin.json")) + if sourceManifest.Version != originalManifest.Version { + t.Fatalf("source manifest version = %q after hooks, want original %q", sourceManifest.Version, originalManifest.Version) + } + manifest := loadPluginManifestFrom(t, filepath.Join(tmp, "dist", "plugin.json")) + if manifest.Version != releaseVersion { + t.Fatalf("manifest version = %q, want %q", manifest.Version, releaseVersion) + } + for _, download := range manifest.Downloads { + wantPrefix := fmt.Sprintf("https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v%s/", releaseVersion) + if !strings.HasPrefix(download.URL, wantPrefix) { + t.Fatalf("download URL %q does not use release version %s", download.URL, releaseVersion) + } + } + if data, err := os.ReadFile(validationLog); err != nil || !strings.Contains(string(data), "validated dist/plugin.json") { + t.Fatalf("strict validation was not invoked; log=%q err=%v", data, err) + } + if _, err := os.Stat(filepath.Join(tmp, "plugin.json.orig")); !os.IsNotExist(err) { + t.Fatalf("plugin.json.orig should not be created, stat err=%v", err) } } @@ -118,6 +314,123 @@ func TestCreateTriggerMissingToken(t *testing.T) { } } +type pluginManifest struct { + Version string `json:"version"` + Capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } `json:"capabilities"` + Contracts []struct { + Kind string `json:"kind"` + Type string `json:"type"` + Mode string `json:"mode"` + Config string `json:"config"` + Output string `json:"output"` + } `json:"contracts"` + Downloads []struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` + } `json:"downloads"` +} + +type goReleaserConfig struct { + Before struct { + Hooks []string `yaml:"hooks"` + } `yaml:"before"` + Builds []struct { + Goos []string `yaml:"goos"` + Goarch []string `yaml:"goarch"` + } `yaml:"builds"` +} + +func loadPluginManifest(t *testing.T) pluginManifest { + t.Helper() + return loadPluginManifestFrom(t, filepath.Join(repoRoot(t), "plugin.json")) +} + +func loadPluginManifestFrom(t *testing.T, path string) pluginManifest { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var manifest pluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return manifest +} + +func copyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + t.Fatalf("write %s: %v", dst, err) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func loadGoReleaserConfig(t *testing.T) goReleaserConfig { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml")) + if err != nil { + t.Fatalf("read .goreleaser.yaml: %v", err) + } + var config goReleaserConfig + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + return config +} + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Dir(filepath.Dir(file)) +} + +func stringSet(values []string) map[string]bool { + set := make(map[string]bool, len(values)) + for _, value := range values { + set[value] = true + } + return set +} + +func stringSetFromMap(values map[string]string) map[string]bool { + set := make(map[string]bool, len(values)) + for value := range values { + set[value] = true + } + return set +} + +func setsEqual(left, right map[string]bool) bool { + if len(left) != len(right) { + return false + } + for key := range left { + if !right[key] { + return false + } + } + return true +} + // withTestProvider registers a fake discordProvider for Execute-level tests. func withTestProvider(t *testing.T, name string) { t.Helper() diff --git a/plugin.json b/plugin.json index 04220ca..2fb2286 100644 --- a/plugin.json +++ b/plugin.json @@ -25,5 +25,112 @@ "step.discord_voice_play" ], "triggerTypes": ["trigger.discord"] - } + }, + "contracts": [ + { + "kind": "module", + "type": "discord.provider", + "mode": "strict", + "config": "discord.v1.ProviderConfig" + }, + { + "kind": "step", + "type": "step.discord_send_message", + "mode": "strict", + "config": "discord.v1.SendMessageConfig", + "output": "discord.v1.SendMessageOutput" + }, + { + "kind": "step", + "type": "step.discord_send_embed", + "mode": "strict", + "config": "discord.v1.SendEmbedConfig", + "output": "discord.v1.SendEmbedOutput" + }, + { + "kind": "step", + "type": "step.discord_edit_message", + "mode": "strict", + "config": "discord.v1.EditMessageConfig", + "output": "discord.v1.EditMessageOutput" + }, + { + "kind": "step", + "type": "step.discord_delete_message", + "mode": "strict", + "config": "discord.v1.DeleteMessageConfig", + "output": "discord.v1.DeleteMessageOutput" + }, + { + "kind": "step", + "type": "step.discord_add_reaction", + "mode": "strict", + "config": "discord.v1.AddReactionConfig", + "output": "discord.v1.AddReactionOutput" + }, + { + "kind": "step", + "type": "step.discord_upload_file", + "mode": "strict", + "config": "discord.v1.UploadFileConfig", + "output": "discord.v1.UploadFileOutput" + }, + { + "kind": "step", + "type": "step.discord_create_thread", + "mode": "strict", + "config": "discord.v1.CreateThreadConfig", + "output": "discord.v1.CreateThreadOutput" + }, + { + "kind": "step", + "type": "step.discord_voice_join", + "mode": "strict", + "config": "discord.v1.VoiceJoinConfig", + "output": "discord.v1.VoiceJoinOutput" + }, + { + "kind": "step", + "type": "step.discord_voice_leave", + "mode": "strict", + "config": "discord.v1.VoiceLeaveConfig", + "output": "discord.v1.VoiceLeaveOutput" + }, + { + "kind": "step", + "type": "step.discord_voice_play", + "mode": "strict", + "config": "discord.v1.VoicePlayConfig", + "output": "discord.v1.VoicePlayOutput" + }, + { + "kind": "trigger", + "type": "trigger.discord", + "mode": "strict", + "config": "discord.v1.TriggerConfig", + "output": "discord.v1.TriggerPayload" + } + ], + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-arm64.tar.gz" + } + ] }