From d9d8cc7b6df4c6c9c774f4968ef9e9dc118fd9da Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 21 Jun 2026 18:42:14 +1000 Subject: [PATCH 1/5] fix: preserve empty list vs null distinction for optional list attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API returns null for empty arrays, but OpenTofu requires that the state match the plan exactly — an empty list [] in the plan must remain [] in the state, not become null. This caused "inconsistent result after apply" errors on every resource with empty optional list fields like mutable_patterns and ban_tags. --- internal/provider/helpers.go | 13 +++++++- internal/provider/helpers_test.go | 46 ++++++++++++++++++++++++++-- internal/provider/resource_remote.go | 11 +++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 1564e36..4a1a0f4 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -16,7 +16,7 @@ func listToStrings(ctx context.Context, l types.List) []string { } func stringsToList(ctx context.Context, ss []string) types.List { - if len(ss) == 0 { + if ss == nil { return types.ListNull(types.StringType) } elems := make([]types.String, len(ss)) @@ -26,3 +26,14 @@ func stringsToList(ctx context.Context, ss []string) types.List { list, _ := types.ListValueFrom(ctx, types.StringType, elems) return list } + +// preserveListNullEmptySemantics keeps the prior null/empty distinction when +// the API returns null for a field that was previously an empty list. +// Without this, OpenTofu reports "inconsistent result after apply" because +// the plan/state had [] but the provider returned null. +func preserveListNullEmptySemantics(prior, current types.List) types.List { + if current.IsNull() && !prior.IsNull() && len(prior.Elements()) == 0 { + return prior + } + return current +} diff --git a/internal/provider/helpers_test.go b/internal/provider/helpers_test.go index 619aaa7..8a06ed8 100644 --- a/internal/provider/helpers_test.go +++ b/internal/provider/helpers_test.go @@ -63,8 +63,11 @@ func TestListToStrings_WithValues(t *testing.T) { func TestStringsToList_EmptySlice(t *testing.T) { ctx := context.Background() result := stringsToList(ctx, []string{}) - if !result.IsNull() { - t.Fatalf("expected null list for empty slice, got %v", result) + if result.IsNull() { + t.Fatalf("expected empty list for empty slice, got null") + } + if len(result.Elements()) != 0 { + t.Fatalf("expected 0 elements, got %d", len(result.Elements())) } } @@ -103,3 +106,42 @@ func TestStringsToList_SingleValue(t *testing.T) { t.Fatalf("expected [\"solo\"], got %v", back) } } + +func TestPreserveListNullEmptySemantics(t *testing.T) { + ctx := context.Background() + emptyList, _ := types.ListValueFrom(ctx, types.StringType, []types.String{}) + nullList := types.ListNull(types.StringType) + populatedList := stringsToList(ctx, []string{"a"}) + + t.Run("prior empty + current null → preserves empty", func(t *testing.T) { + result := preserveListNullEmptySemantics(emptyList, nullList) + if result.IsNull() { + t.Fatal("expected empty list, got null") + } + if len(result.Elements()) != 0 { + t.Fatalf("expected 0 elements, got %d", len(result.Elements())) + } + }) + + t.Run("prior null + current null → stays null", func(t *testing.T) { + result := preserveListNullEmptySemantics(nullList, nullList) + if !result.IsNull() { + t.Fatal("expected null, got non-null") + } + }) + + t.Run("prior empty + current populated → uses current", func(t *testing.T) { + result := preserveListNullEmptySemantics(emptyList, populatedList) + elems := listToStrings(ctx, result) + if len(elems) != 1 || elems[0] != "a" { + t.Fatalf("expected [a], got %v", elems) + } + }) + + t.Run("prior populated + current null → stays null", func(t *testing.T) { + result := preserveListNullEmptySemantics(populatedList, nullList) + if !result.IsNull() { + t.Fatal("expected null (prior was populated, not empty — no preservation)") + } + }) +} diff --git a/internal/provider/resource_remote.go b/internal/provider/resource_remote.go index edfbcc7..25587c2 100644 --- a/internal/provider/resource_remote.go +++ b/internal/provider/resource_remote.go @@ -181,6 +181,7 @@ func (r *remoteResource) Create(ctx context.Context, req resource.CreateRequest, } state := r.apiToModel(ctx, created) + reconcileOptionalLists(&plan, &state) resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } @@ -203,6 +204,7 @@ func (r *remoteResource) Read(ctx context.Context, req resource.ReadRequest, res } newState := r.apiToModel(ctx, remote) + reconcileOptionalLists(&state, &newState) resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) } @@ -223,6 +225,7 @@ func (r *remoteResource) Update(ctx context.Context, req resource.UpdateRequest, } state := r.apiToModel(ctx, updated) + reconcileOptionalLists(&plan, &state) resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } @@ -243,6 +246,14 @@ func (r *remoteResource) ImportState(ctx context.Context, req resource.ImportSta resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) } +func reconcileOptionalLists(prior, current *remoteResourceModel) { + current.Patterns = preserveListNullEmptySemantics(prior.Patterns, current.Patterns) + current.Blocklist = preserveListNullEmptySemantics(prior.Blocklist, current.Blocklist) + current.MutablePatterns = preserveListNullEmptySemantics(prior.MutablePatterns, current.MutablePatterns) + current.ImmutablePatterns = preserveListNullEmptySemantics(prior.ImmutablePatterns, current.ImmutablePatterns) + current.BanTags = preserveListNullEmptySemantics(prior.BanTags, current.BanTags) +} + func (r *remoteResource) modelToAPI(ctx context.Context, m remoteResourceModel) remoteAPI { api := remoteAPI{ Name: m.Name.ValueString(), From 40aa86bc6887cac389ac9444d2a77a49d99eddad Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 21 Jun 2026 18:55:30 +1000 Subject: [PATCH 2/5] feat: add tag bumping features enable the use of make minor, major and patch to automatically bump tagged releases --- Makefile | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index aabeb4f..5753bcf 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: build install test lint fmt clean tidy +.PHONY: build install test lint fmt clean tidy patch minor major BINARY := terraform-provider-artifactapi -VERSION ?= 0.0.1 +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") OS_ARCH := linux_amd64 INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/artifactapi/$(VERSION)/$(OS_ARCH) @@ -34,3 +34,21 @@ clean: tidy: go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW From 6446997a12f21610490d78bc0610cc1441e570fa Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 21 Jun 2026 22:07:47 +1000 Subject: [PATCH 3/5] fix: strip v prefix from version in make install path Terraform plugin directory expects bare version numbers (e.g. 0.0.2) but git tags use v-prefixed versions (e.g. v0.0.2). --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5753bcf..308f327 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ BINARY := terraform-provider-artifactapi VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") OS_ARCH := linux_amd64 -INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/artifactapi/$(VERSION)/$(OS_ARCH) +INSTALL_VERSION := $(shell echo $(VERSION) | sed 's/^v//') +INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/artifactapi/$(INSTALL_VERSION)/$(OS_ARCH) GO_VERSION_REQUIRED := 1.23 GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') From d2da94cb521bf193db207e88958c3ef21b62fa73 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 21 Jun 2026 22:16:59 +1000 Subject: [PATCH 4/5] fix: pul_request tests only - update tests to run on pull request only --- .woodpecker/build.yml | 2 +- .woodpecker/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml index 77b69fc..9c181f7 100644 --- a/.woodpecker/build.yml +++ b/.woodpecker/build.yml @@ -1,5 +1,5 @@ when: - - event: [push, pull_request] + - event: pull_request steps: - name: build diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml index 4a56955..bb94e07 100644 --- a/.woodpecker/test.yml +++ b/.woodpecker/test.yml @@ -1,5 +1,5 @@ when: - - event: [push, pull_request] + - event: pull_request steps: - name: lint From 3d776f9e0fbfab2fec30d5a889a61e4751027fce Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 21 Jun 2026 22:27:06 +1000 Subject: [PATCH 5/5] feat: add pre-commit to build jobs ensure pre-commit is run by woodpecker in ci --- .woodpecker/pre-commit.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .woodpecker/pre-commit.yaml diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2