From 63766aa5d72f4b4281718fcceac5d6c615c109a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 15 Sep 2026 11:53:23 +0200 Subject: [PATCH 1/6] test: fail when the httpwg test suite is missing The suite files live in a git submodule. Every filesystem and decoding error was discarded, so an absent submodule produced zero test cases and a passing run instead of a failure. --- httpwg_test.go | 103 +++++++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 42 deletions(-) diff --git a/httpwg_test.go b/httpwg_test.go index 0b9f242..1673cd8 100644 --- a/httpwg_test.go +++ b/httpwg_test.go @@ -141,25 +141,68 @@ func valToDictionary(e interface{}) *Dictionary { return d } -func TestOfficialTestSuiteParsing(t *testing.T) { - const dir = "structured-field-tests/" - f, _ := os.Open(dir) - files, _ := f.Readdir(-1) +// listTestFiles returns the JSON test suite files in dir. +func listTestFiles(tb testing.TB, dir string) []string { + tb.Helper() - for _, fi := range files { - n := fi.Name() - if !strings.HasSuffix(n, ".json") { - continue + f, err := os.Open(dir) + if err != nil { + tb.Fatalf("%s: %s (is the structured-field-tests submodule checked out?)", dir, err) + } + + defer func() { _ = f.Close() }() + + entries, err := f.Readdir(-1) + if err != nil { + tb.Fatalf("%s: %s", dir, err) + } + + var names []string + + for _, fi := range entries { + if strings.HasSuffix(fi.Name(), ".json") { + names = append(names, fi.Name()) } + } + + if len(names) == 0 { + tb.Fatalf("%s: no JSON test file found (is the structured-field-tests submodule checked out?)", dir) + } - file, _ := os.Open(dir + n) - dec := json.NewDecoder(file) - dec.UseNumber() + return names +} - var tests []test - _ = dec.Decode(&tests) +// loadTests decodes the test cases contained in the given test suite file. +func loadTests(tb testing.TB, path string) []test { + tb.Helper() - for _, te := range tests { + file, err := os.Open(path) + if err != nil { + tb.Fatalf("%s: %s (is the structured-field-tests submodule checked out?)", path, err) + } + + defer func() { _ = file.Close() }() + + dec := json.NewDecoder(file) + dec.UseNumber() + + var tests []test + if err := dec.Decode(&tests); err != nil { + tb.Fatalf("%s: %s", path, err) + } + + if len(tests) == 0 { + tb.Fatalf("%s: no test case found", path) + } + + return tests +} + +func TestOfficialTestSuiteParsing(t *testing.T) { + const dir = "structured-field-tests/" + + for _, n := range listTestFiles(t, dir) { + for _, te := range loadTests(t, dir+n) { t.Run(n+"/"+te.Name, func(t *testing.T) { var ( expected, got StructuredFieldValue @@ -201,11 +244,7 @@ func TestOfficialTestSuiteParsing(t *testing.T) { } func BenchmarkParsingOfficialExamples(b *testing.B) { - file, _ := os.Open("structured-field-tests/examples.json") - dec := json.NewDecoder(file) - - var tests []test - _ = dec.Decode(&tests) + tests := loadTests(b, "structured-field-tests/examples.json") for n := 0; n < b.N; n++ { for _, te := range tests { @@ -222,12 +261,7 @@ func BenchmarkParsingOfficialExamples(b *testing.B) { } func BenchmarkSerializingOfficialExamples(b *testing.B) { - file, _ := os.Open("structured-field-tests/examples.json") - dec := json.NewDecoder(file) - dec.UseNumber() - - var tests []test - _ = dec.Decode(&tests) + tests := loadTests(b, "structured-field-tests/examples.json") var sfv []StructuredFieldValue @@ -258,23 +292,8 @@ func TestOfficialTestSuiteSerialization(t *testing.T) { const dir = "structured-field-tests/serialisation-tests/" - f, _ := os.Open(dir) - files, _ := f.Readdir(-1) - - for _, fi := range files { - n := fi.Name() - if !strings.HasSuffix(n, ".json") { - continue - } - - file, _ := os.Open(dir + n) - dec := json.NewDecoder(file) - dec.UseNumber() - - var tests []test - _ = dec.Decode(&tests) - - for _, te := range tests { + for _, n := range listTestFiles(t, dir) { + for _, te := range loadTests(t, dir+n) { var sfv StructuredFieldValue switch te.HeaderType { From 5ff42e59f83a67e806a48d85402eb851652d9943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 15 Sep 2026 11:53:36 +0200 Subject: [PATCH 2/6] fix: join multi-line field values with ", " RFC 9651 section 4.2 combines field lines with a comma and a space. Only the comma was used, so a string split across two lines parsed to "foo,bar" instead of "foo, bar". Caught by the two_lines cases of the refreshed test suite. --- dictionary.go | 2 +- item.go | 2 +- item_test.go | 2 +- list.go | 2 +- structured-field-tests | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dictionary.go b/dictionary.go index 7e73bcd..1e8b165 100644 --- a/dictionary.go +++ b/dictionary.go @@ -104,7 +104,7 @@ func (d *Dictionary) marshalSFV(b *strings.Builder) error { // https://httpwg.org/specs/rfc9651.html#parse-dictionary. func UnmarshalDictionary(v []string) (*Dictionary, error) { s := &scanner{ - data: strings.Join(v, ","), + data: strings.Join(v, ", "), } s.scanWhileSp() diff --git a/item.go b/item.go index 000c999..eb8b9ed 100644 --- a/item.go +++ b/item.go @@ -39,7 +39,7 @@ func (i Item) marshalSFV(b *strings.Builder) error { // https://httpwg.org/specs/rfc9651.html#parse-item. func UnmarshalItem(v []string) (Item, error) { s := &scanner{ - data: strings.Join(v, ","), + data: strings.Join(v, ", "), } s.scanWhileSp() diff --git a/item_test.go b/item_test.go index cf0537a..c17f1d6 100644 --- a/item_test.go +++ b/item_test.go @@ -79,7 +79,7 @@ func TestUnmarshalItem(t *testing.T) { }{ {[]string{"?1;foo;*bar=tok"}, i1, false}, {[]string{" ?1;foo;*bar=tok "}, i1, false}, - {[]string{`"foo`, `bar"`}, NewItem("foo,bar"), false}, + {[]string{`"foo`, `bar"`}, NewItem("foo, bar"), false}, {[]string{"é", ""}, Item{}, true}, {[]string{"tok;é"}, Item{}, true}, {[]string{" ?1;foo;*bar=tok é"}, Item{}, true}, diff --git a/list.go b/list.go index 7c20b0d..cc8a62c 100644 --- a/list.go +++ b/list.go @@ -36,7 +36,7 @@ func (l List) marshalSFV(b *strings.Builder) error { // https://httpwg.org/specs/rfc9651.html#parse-list. func UnmarshalList(v []string) (List, error) { s := &scanner{ - data: strings.Join(v, ","), + data: strings.Join(v, ", "), } s.scanWhileSp() diff --git a/structured-field-tests b/structured-field-tests index 7970aff..1e280c3 160000 --- a/structured-field-tests +++ b/structured-field-tests @@ -1 +1 @@ -Subproject commit 7970aff32a4e73452beedde861f4eb52ed4243b0 +Subproject commit 1e280c3ed9ffe0ca5fdb1d97219dddc389007677 From baf129fac5513d342b4b9d1819414e4c0b24e221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 15 Sep 2026 11:53:42 +0200 Subject: [PATCH 3/6] ci: modernize workflow and pin actions by commit SHA Checkout skipped the test suite submodule, which is what let the silent test failure reach main. Also drop the obsolete GO111MODULE variable and stop running the whole matrix twice on pull request branches. Add Dependabot so the pinned SHAs and the submodule stay current. --- .github/dependabot.yml | 10 ++++++++++ .github/workflows/ci.yaml | 34 +++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..864697c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: gitsubmodule + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1feecff..59bc6a7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,22 +2,32 @@ name: CI on: push: + branches: + - main pull_request: -env: - GO111MODULE: 'on' +permissions: + contents: read + +# Superseded pull request runs are worthless; main runs still upload coverage. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: lint: name: Lint runs-on: ubuntu-latest steps: - - name: Check out code - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@v5 - - name: Lint Go Code - uses: golangci/golangci-lint-action@v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false # golangci-lint-action manages its own cache + - name: Lint Go code + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 test: name: Test @@ -28,15 +38,17 @@ jobs: go-version: ['stable', 'oldstable'] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go-version }} - name: Test - run: go test -race ${{ matrix.go-version == 'stable' && '-covermode atomic -coverprofile=profile.cov' || ''}} + run: go test -race ${{ matrix.go-version == 'stable' && '-covermode atomic -coverprofile=profile.cov' || '' }} - name: Upload coverage results if: matrix.go-version == 'stable' - uses: shogo82148/actions-goveralls@v1 + uses: shogo82148/actions-goveralls@77a1912dca42260ee3e97f61bd13ea7ef40baa93 # v1.11.2 with: path-to-profile: profile.cov From 9a52d6d3dbccdb1b4407be7536c0d9dfac3521fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 15 Sep 2026 11:56:45 +0200 Subject: [PATCH 4/6] docs: reformat the Dictionary comment with gofmt --- dictionary.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dictionary.go b/dictionary.go index 1e8b165..9f6580c 100644 --- a/dictionary.go +++ b/dictionary.go @@ -8,8 +8,8 @@ import ( // Dictionary is an ordered map of name-value pairs. // See https://httpwg.org/specs/rfc9651.html#dictionary // Values can be: -// * Item (Section 3.3.) -// * Inner List (Section 3.1.1.) +// - Item (Section 3.3.) +// - Inner List (Section 3.1.1.) type Dictionary struct { names []string values map[string]Member From 649e739a212560352f9fdb0177d38bcb8161da09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 15 Sep 2026 11:56:45 +0200 Subject: [PATCH 5/6] ci: auto-merge Dependabot PRs when CI is green Mirrors the setup used in mercure and frankenphp: a seven day cooldown before an update is proposed, then auto-merge restricted to minor and patch GitHub Actions bumps. Submodule and major updates stay manual. --- .github/dependabot.yml | 8 +++++++ .github/workflows/dependabot.yaml | 40 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .github/workflows/dependabot.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 864697c..62f3e6c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,15 @@ updates: directory: / schedule: interval: weekly + commit-message: + prefix: ci + cooldown: + default-days: 7 - package-ecosystem: gitsubmodule directory: / schedule: interval: weekly + commit-message: + prefix: test + cooldown: + default-days: 7 diff --git a/.github/workflows/dependabot.yaml b/.github/workflows/dependabot.yaml new file mode 100644 index 0000000..303b6cc --- /dev/null +++ b/.github/workflows/dependabot.yaml @@ -0,0 +1,40 @@ +name: Dependabot Auto-Merge + +on: + pull_request_target: + branches: + - main + +permissions: {} + +jobs: + auto-merge: + runs-on: ubuntu-latest + environment: dependabot + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + # The default GITHUB_TOKEN lacks the `workflows` scope and is refused on + # PRs that touch .github/workflows; use the release app token instead. + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + # Only minor and patch GitHub Actions bumps auto-merge; majors and the + # test suite submodule wait for a human review. + - name: Auto-merge minor and patch GitHub Actions updates + if: steps.metadata.outputs.package-ecosystem == 'github_actions' && steps.metadata.outputs.update-type != 'version-update:semver-major' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + gh pr review --approve "${PR_URL}" + gh pr merge --auto --squash "${PR_URL}" From 5463572ea751eef2abfe03d389d3f8b08dd67916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 15 Sep 2026 14:58:09 +0200 Subject: [PATCH 6/6] ci: read the app token from the release environment RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY already live in the release environment of mercure and frankenphp, so reuse it rather than copying the private key into a second environment. --- .github/workflows/dependabot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependabot.yaml b/.github/workflows/dependabot.yaml index 303b6cc..ecb39ef 100644 --- a/.github/workflows/dependabot.yaml +++ b/.github/workflows/dependabot.yaml @@ -10,7 +10,7 @@ permissions: {} jobs: auto-merge: runs-on: ubuntu-latest - environment: dependabot + environment: release if: github.event.pull_request.user.login == 'dependabot[bot]' steps: # The default GITHUB_TOKEN lacks the `workflows` scope and is refused on