From 71b3b5c57d2133f117dadbfbb2c199042fb06bd3 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Mon, 25 May 2026 10:20:48 +0200 Subject: [PATCH 1/5] fix: forward AllowStale option to the Consul query Options.AllowStale and the allow_stale URL parameter were parsed and validated but never copied into driver.Config, so stale reads were never requested. Forward the field through OpenVariable and add a public-API regression test. Closes #26 Co-Authored-By: Claude Opus 4.7 --- consulvar/allowstale_test.go | 64 ++++++++++++++++++++++++++++++++++++ consulvar/consulvar.go | 1 + 2 files changed, 65 insertions(+) create mode 100644 consulvar/allowstale_test.go diff --git a/consulvar/allowstale_test.go b/consulvar/allowstale_test.go new file mode 100644 index 0000000..1f8fb60 --- /dev/null +++ b/consulvar/allowstale_test.go @@ -0,0 +1,64 @@ +package consulvar_test + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gocloud.dev/runtimevar" + + "github.com/peczenyj/runtimevar-consul/consulvar" +) + +// staleRecordingServer serves a single KV pair and records whether any request +// carried the Consul "stale" query parameter. +func staleRecordingServer(t *testing.T) (client *api.Client, sawStale *atomic.Bool) { + t.Helper() + sawStale = &atomic.Bool{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("stale") { + sawStale.Store(true) + } + w.Header().Set("X-Consul-Index", "1") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"Key":"k","CreateIndex":1,"ModifyIndex":1,"Value":"`+ + base64.StdEncoding.EncodeToString([]byte("v"))+`"}]`) + })) + t.Cleanup(srv.Close) + + cfg := api.DefaultConfig() + cfg.Address = strings.TrimPrefix(srv.URL, "http://") + c, err := api.NewClient(cfg) + require.NoError(t, err) + return c, sawStale +} + +// https://github.com/peczenyj/runtimevar-consul/issues/26 +// AllowStale set on Options must reach the Consul query as ?stale. +func TestOpenVariable_ForwardsAllowStale_Issue26(t *testing.T) { + client, sawStale := staleRecordingServer(t) + + v, err := consulvar.OpenVariable(client, "k", &consulvar.Options{ + Decoder: runtimevar.StringDecoder, + AllowStale: true, + }) + require.NoError(t, err) + defer v.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + snap, err := v.Watch(ctx) + require.NoError(t, err) + assert.Equal(t, "v", snap.Value.(string)) + assert.True(t, sawStale.Load(), "Options.AllowStale must reach the Consul query as ?stale") +} diff --git a/consulvar/consulvar.go b/consulvar/consulvar.go index aebc3da..630c711 100644 --- a/consulvar/consulvar.go +++ b/consulvar/consulvar.go @@ -85,6 +85,7 @@ func OpenVariable(client *api.Client, key string, opts *Options) (*runtimevar.Va Decoder: decoder, Datacenter: opts.Datacenter, Namespace: opts.Namespace, + AllowStale: opts.AllowStale, WaitTime: opts.WaitTime, }) return runtimevar.New(w), nil From 576b1eafa8f259a1244e41b913489905eb2dc950 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Mon, 25 May 2026 10:20:48 +0200 Subject: [PATCH 2/5] fix: reset backoff counter after a successful poll The consecutive-failure counter driving exponential backoff was reset only on the returning paths, not on the poll-again paths that loop after a successful Get. A transport error following a healthy but unchanged poll therefore escalated the backoff instead of restarting at 1s. Reset the counter after any successful Get. Closes #27 Co-Authored-By: Claude Opus 4.7 --- .../internal/driver/watch_internal_test.go | 34 +++++++++++++++++++ consulvar/internal/driver/watcher.go | 5 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/consulvar/internal/driver/watch_internal_test.go b/consulvar/internal/driver/watch_internal_test.go index 1296818..758f623 100644 --- a/consulvar/internal/driver/watch_internal_test.go +++ b/consulvar/internal/driver/watch_internal_test.go @@ -317,6 +317,40 @@ func TestWatchVariable_Mocked_TransportError(t *testing.T) { assert.Equal(t, time.Second, wait) } +// https://github.com/peczenyj/runtimevar-consul/issues/27 +// A successful poll must reset the consecutive-failure counter, so a later +// transport error restarts the backoff at 1s rather than escalating. +func TestWatchVariable_BackoffResetsAfterSuccessfulPoll_Issue27(t *testing.T) { + mk := consulmock.NewConsulKV(t) + mc := consulmock.NewConsulClient(t) + w := &Watcher{ + client: mc, + key: "k", + decoder: runtimevar.StringDecoder, + } + + mc.EXPECT().KV().Return(mk) + + boom := errors.New("network failure") + // Call A: transport error -> failures becomes 1, backoff 1s. + mk.EXPECT().Get("k", mock.Anything).Return(nil, nil, boom).Once() + // Call B, first poll: a successful but unchanged read (ModifyIndex == prev). + mk.EXPECT().Get("k", mock.Anything).Return( + &api.KVPair{Key: "k", Value: []byte("v"), ModifyIndex: 5}, + &api.QueryMeta{LastIndex: 5}, + nil, + ).Once() + // Call B, second poll: transport error again. + mk.EXPECT().Get("k", mock.Anything).Return(nil, nil, boom).Once() + + _, waitA := w.WatchVariable(context.Background(), nil) + require.Equal(t, time.Second, waitA) + + _, waitB := w.WatchVariable(context.Background(), &state{modifyIndex: 5}) + assert.Equal(t, time.Second, waitB, + "a successful poll must reset backoff; expected 1s, the schedule must not escalate to 2s") +} + func TestWatchVariable_Mocked_IndexReset(t *testing.T) { mk := consulmock.NewConsulKV(t) mc := consulmock.NewConsulClient(t) diff --git a/consulvar/internal/driver/watcher.go b/consulvar/internal/driver/watcher.go index fbe43a3..55dec3d 100644 --- a/consulvar/internal/driver/watcher.go +++ b/consulvar/internal/driver/watcher.go @@ -33,6 +33,9 @@ func (w *Watcher) WatchVariable(ctx context.Context, prev driver.State) (driver. w.failures++ return &state{err: err, updated: now}, backoff(w.failures) } + // A successful round-trip clears the backoff schedule, including on the + // poll-again paths below that loop without returning a new state. + w.failures = 0 // Index-reset safety: per Consul docs, if meta.LastIndex < waitIndex the // cluster's index has reset and the comparison is invalid. @@ -53,7 +56,6 @@ func (w *Watcher) WatchVariable(ctx context.Context, prev driver.State) (driver. waitIndex = nextBlockingIndex(meta.LastIndex) continue } - w.failures = 0 return &state{err: nfErr, modifyIndex: meta.LastIndex, meta: meta, updated: now}, 0 } @@ -63,7 +65,6 @@ func (w *Watcher) WatchVariable(ctx context.Context, prev driver.State) (driver. } v, decErr := w.decoder.Decode(ctx, kv.Value) - w.failures = 0 if decErr != nil { if prevErr != nil && prevErr.Error() == decErr.Error() { waitIndex = kv.ModifyIndex From 4cd7557b8dffc4e799356580d5cd45831a5f811d Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Mon, 25 May 2026 10:22:34 +0200 Subject: [PATCH 3/5] docs: fix README Development section - Repair the unbalanced code fences so the install snippet and command list render correctly; the `# task:` / `# git-cliff:` comments now live inside the install block (refs #30). - Point the golangci-lint install at v2.12.2 to match `.golangci.yml` (version: "2"), the `golangci-lint fmt` task, and CI (refs #29). - State the real Go floor (1.25, from the go.mod directive) instead of 1.26, and drop the false "dependencies pull the floor up" claim (refs #28). - Describe what `task ci` actually does rather than claiming it pins the toolchain to a minimum Go that nothing enforces (refs #31). Co-Authored-By: Claude Opus 4.7 --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4e8cdae..d9ceffb 100644 --- a/README.md +++ b/README.md @@ -46,20 +46,19 @@ Install the toolchain: ```bash go install gotest.tools/gotestsum@latest -# See https://golangci-lint.run/welcome/install/#local-installation -curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.5 +# golangci-lint v2 — see https://golangci-lint.run/welcome/install/#local-installation +curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.12.2 go install github.com/vektra/mockery/v2@latest # only needed to regenerate mocks -``` -# task: https://taskfile.dev/installation/ +# task: https://taskfile.dev/installation/ # git-cliff: https://git-cliff.org/docs/installation ``` -Requires Go 1.26 or newer (set by the `go` directive in `go.mod`; the dependencies pull the floor up to it). +Requires Go 1.25 or newer (the `go` directive in `go.mod`). Common commands (the `Makefile` delegates to `task`): ```bash -task ci # full pre-push gate: tidy + lint + build + tests on the minimum Go +task ci # full pre-push gate: tidy:check + lint + build + unit + integration task test # unit tests task test:integration # unit + integration (requires Docker) task lint @@ -67,7 +66,7 @@ task format task changelog:unreleased ``` -Run `task ci` before pushing. GitHub Actions runs on `stable` Go only, so `task ci` is the only place the version floor is enforced: it pins the toolchain to the minimum supported Go, so a dependency that raises the floor fails here rather than slipping through CI unnoticed. +Run `task ci` before pushing — it mirrors the GitHub Actions checks. Both CI and `task ci` run on whatever Go toolchain is installed; the minimum supported version is recorded by the `go` directive in `go.mod` and is not otherwise enforced. ## Commit messages From 2a0eadac259ca7a157db44ebaa606f495927d12a Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Mon, 25 May 2026 10:22:34 +0200 Subject: [PATCH 4/5] docs: align AGENTS.md Go version floor to 1.25 go.mod declares go 1.25.0 and no dependency raises the floor; AGENTS.md claimed 1.26 in two places (refs #28). Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47be3f9..d23af4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## Project Overview -- **Main Technology:** Go (>= 1.26) +- **Main Technology:** Go (>= 1.25) - **Primary Dependency:** [`github.com/hashicorp/consul/api`](https://github.com/hashicorp/consul) and [`gocloud.dev/runtimevar`](https://gocloud.dev). - **Architecture:** - `consulvar/`: Public API and `runtimevar` URL opener registration. @@ -42,7 +42,7 @@ The project uses `task` (Taskfile) as the task runner. - **Dependency Management:** - Maintain `go.mod` and `go.sum` via `task tidy`. - Always run `go mod vendor` after updating dependencies. - - Avoid raising the minimum Go version floor (currently 1.26) unless necessary. + - Avoid raising the minimum Go version floor (currently 1.25) unless necessary. - **Tooling:** - `gotestsum` for test execution and reporting. - `golangci-lint` for static analysis. From 8bffd7442d5e4d4b144c6211f34a776703fa0c74 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Mon, 25 May 2026 10:46:58 +0200 Subject: [PATCH 5/5] ci: add SLSA build provenance to releases Tagged releases now package a deterministic source archive (runtimevar-consul-.tar.gz) plus SHA256SUMS, attach them to the GitHub Release, and generate Sigstore-signed SLSA v1.0 provenance for the archive via actions/attest-build-provenance. This satisfies SLSA Build L1 (scripted build + provenance); the hosted runner and signed, non-falsifiable provenance also cover the bulk of L2. Permissions are scoped per-job (id-token + attestations write added for keyless signing). SECURITY.md documents verification via `gh attestation verify`. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 22 +++++++++++++++++++++- SECURITY.md | 17 +++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e0fbf4..ae21917 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,16 +6,33 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: release: name: Publish GitHub Release runs-on: ubuntu-latest + permissions: + contents: write # create the release and upload assets + id-token: write # OIDC token for Sigstore keyless signing + attestations: write # write the SLSA provenance attestation steps: - uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Build source archive + id: archive + run: | + archive="runtimevar-consul-${GITHUB_REF_NAME}.tar.gz" + git archive --format=tar.gz \ + --prefix="runtimevar-consul-${GITHUB_REF_NAME}/" \ + -o "${archive}" "${GITHUB_REF_NAME}" + sha256sum "${archive}" > SHA256SUMS + echo "archive=${archive}" >> "${GITHUB_OUTPUT}" + - name: Generate SLSA provenance attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: ${{ steps.archive.outputs.archive }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -24,5 +41,8 @@ jobs: generate_release_notes: true draft: false prerelease: false + files: | + ${{ steps.archive.outputs.archive }} + SHA256SUMS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/SECURITY.md b/SECURITY.md index 11958e6..1997189 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,3 +5,20 @@ If you discover a security vulnerability within this project, please send an e-mail to **tiago.peczenyj+github@gmail.com**. All security vulnerabilities will be promptly addressed. We request that you do not report security-related issues through public GitHub issues. + +## Verifying release provenance + +Each tagged release publishes a source archive (`runtimevar-consul-vX.Y.Z.tar.gz`) +and a `SHA256SUMS` file on the [Releases](https://github.com/peczenyj/runtimevar-consul/releases) +page. The archive ships with [SLSA](https://slsa.dev) build provenance, signed +keylessly via [Sigstore](https://www.sigstore.dev/), generated by the release +workflow on GitHub Actions. + +To verify a downloaded archive with the [GitHub CLI](https://cli.github.com/): + +```bash +gh attestation verify runtimevar-consul-vX.Y.Z.tar.gz --repo peczenyj/runtimevar-consul +``` + +A successful check confirms the archive was produced by this repository's +release workflow for the given tag, and has not been tampered with since.