From 1fd859af5c5dcef4166a5d71416f8ae2b09071a1 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Mon, 15 Jun 2026 17:53:44 +0000 Subject: [PATCH] chore(ci): add shared-lib scaffolding and functional coverage gate (#4) --- .editorconfig | 11 +++++ .gitea/workflows/ci.yaml | 74 ++++++++++++++++++++++++++++- .gitea/workflows/pre-commit.yaml | 25 ++++++++++ .gitea/workflows/release.yaml | 9 ++++ .gitignore | 1 + .golangci.yml | 22 +++++++++ .pre-commit-config.yaml | 39 ++++++++++++++++ .testcoverage.yml | 13 ++++++ .version | 3 ++ CHANGELOG.md | 11 +++++ CLAUDE.md | 58 +++++++++++++++++++++++ cliff.toml | 80 ++++++++++++++++++++++++++++++++ 12 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/pre-commit.yaml create mode 100644 .gitea/workflows/release.yaml create mode 100644 .golangci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .testcoverage.yml create mode 100644 .version create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 cliff.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..04cd3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 2 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 332ab82..cceb306 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -20,7 +20,79 @@ jobs: go install mvdan.cc/gofumpt@latest test -z "$(gofumpt -l .)" - name: Run tests - run: go test -race ./... + run: go test -race -coverprofile=coverage.txt ./... + + - name: Filter test files from coverage + run: | + grep -v -E '_test\.go:' coverage.txt > coverage.filtered.txt || true + mv coverage.filtered.txt coverage.txt + + - name: Check coverage + id: coverage + run: | + go install github.com/vladopajic/go-test-coverage/v2@latest + go-test-coverage --config ./.testcoverage.yml --github-action-output + - name: Restore baseline coverage + uses: actions/cache/restore@v5 + with: + path: coverage-baseline.txt + key: coverage-baseline-${{ gitea.run_id }} + restore-keys: | + coverage-baseline- + - name: Compare coverage + run: | + CURRENT="${{ steps.coverage.outputs.total-coverage }}" + if [ -f coverage-baseline.txt ]; then + BASE=$(cat coverage-baseline.txt) + echo "Base coverage: ${BASE}%" + echo "Current coverage: ${CURRENT}%" + if [ "$(echo "$CURRENT < $BASE" | bc -l)" -eq 1 ]; then + echo "::error::Coverage decreased from ${BASE}% to ${CURRENT}%" + exit 1 + fi + echo "Coverage maintained or improved: ${BASE}% -> ${CURRENT}%" + else + echo "No baseline coverage found yet, skipping comparison" + echo "Current coverage: ${CURRENT}%" + fi + - name: Post coverage comment + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_URL: ${{ gitea.server_url }} + run: | + COVERAGE="${{ steps.coverage.outputs.total-coverage }}" + curl -X POST "${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/issues/${{ gitea.event.pull_request.number }}/comments" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"## Coverage Report\n\nTotal coverage: **${COVERAGE}%**\"}" + + coverage-baseline: + # Records main's coverage into the Actions cache for the next PR's + # regression gate to read. Post-merge only, not a required check, blocks + # nothing (cf. ADR-0010). + if: gitea.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 'stable' + - name: Compute coverage + id: coverage + run: | + go install github.com/vladopajic/go-test-coverage/v2@latest + go test -coverprofile=coverage.txt ./... + grep -v -E '_test\.go:' coverage.txt > coverage.filtered.txt || true + mv coverage.filtered.txt coverage.txt + go-test-coverage --config ./.testcoverage.yml --github-action-output + - name: Write baseline file + run: echo "${{ steps.coverage.outputs.total-coverage }}" > coverage-baseline.txt + - name: Save baseline to cache + uses: actions/cache/save@v5 + with: + path: coverage-baseline.txt + key: coverage-baseline-${{ gitea.run_id }} + vulnerabilities: if: gitea.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/.gitea/workflows/pre-commit.yaml b/.gitea/workflows/pre-commit.yaml new file mode 100644 index 0000000..e427748 --- /dev/null +++ b/.gitea/workflows/pre-commit.yaml @@ -0,0 +1,25 @@ +name: pre-commit +permissions: read-all + +on: + pull_request: + push: + branches: + - main + +jobs: + pre-commit: + runs-on: ubuntu-latest + env: + SKIP: no-commit-to-branch + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: stable + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports@latest + - uses: pre-commit/action@v3.0.1 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..ef6ec99 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,9 @@ +name: Release + +on: + push: + branches: [main] + +jobs: + release: + uses: unboundsoftware/shared-workflows/.gitea/workflows/Release.yml@main diff --git a/.gitignore b/.gitignore index 31bb341..ab2f1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .claude /release coverage.txt +coverage-baseline.txt diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5381cc5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" +run: + allow-parallel-runners: true +linters: + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6399513 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: + - --allow-multiple-documents + - id: check-added-large-files +- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.25.0 + hooks: + - id: commitlint + stages: [ commit-msg ] + additional_dependencies: [ '@commitlint/config-conventional' ] +- repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-mod-tidy + - id: go-imports + args: + - -local + - gitea.unbound.se/shiny/auth +- repo: https://github.com/lietu/go-pre-commit + rev: v1.0.0 + hooks: + - id: go-test + - id: gofumpt +- repo: https://github.com/golangci/golangci-lint + rev: v2.12.2 + hooks: + - id: golangci-lint-full +- repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..7aec8b6 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,13 @@ +# Coverage configuration for go-test-coverage +# https://github.com/vladopajic/go-test-coverage + +profile: coverage.txt + +threshold: + file: 0 + package: 0 + total: 0 + +exclude: + paths: + - _test\.go$ diff --git a/.version b/.version new file mode 100644 index 0000000..557859c --- /dev/null +++ b/.version @@ -0,0 +1,3 @@ +{ + "version": "v0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b61db2c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-06-15 + +### ๐Ÿš€ Features + +- Initial version: signed `user` header middleware (ADR-0005) and the `MissingDeployedSecrets` startup guard (ADR-0005/0006), extracted from the per-service copies. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dd21a2c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# auth + +Shared Go library with authentication primitives for all Shiny backend services. + +## Shared Documentation + +@../docs/claude/architecture.md +@../docs/claude/go-services.md +@../docs/claude/conventions.md + +## Library Information + +### Purpose + +Single home for the `user`-header auth and secret-startup-guard code that was +previously byte-identical-copied into every backend (the `auth` package and +`cmd/service/secrets_guard.go`). Enforces ADR-0005 (HMAC-signed `user` header, +keyless fail-open only in acctest) and ADR-0006 (fail closed when required +secrets are missing in `staging`/`production`). + +### Usage + +```go +import "gitea.unbound.se/shiny/auth" + +// Fail closed on missing deployed secrets before serving (ADR-0005/0006). +if missing := auth.MissingDeployedSecrets(environment, map[string]string{ + "USER_SIGNING_KEY": cfg.UserSigningKey, + "INTERNAL_API_KEY": cfg.InternalAPIKey, +}); len(missing) > 0 { + log.Fatalf("refusing to start: missing secrets in %s: %v", environment, missing) +} + +// Verify the gateway's signed user header and inject *User into the context. +handler = auth.UserMiddleware([]byte(cfg.UserSigningKey))(handler) + +// Read the authenticated user downstream. +user := auth.FromContext(ctx) +if user.HasRole("admin") { /* ... */ } +``` + +### Exported API + +- `UserMiddleware(signingKey []byte)` โ€” HTTP middleware verifying the + HMAC-signed `user` header; injects `*User` into the request context. +- `FromContext(ctx) *User`, `User.HasRole(...) bool`, `ContextKey`/`UserKey`. +- `MissingDeployedSecrets(environment string, secrets map[string]string) []string` + โ€” returns the sorted names of secrets that are empty in `staging`/`production` + (nil for any other environment, e.g. `development`/acctest). + +### Conventions + +Standard Shiny library scaffolding: `gofumpt`/`goimports -local`, golangci-lint, +gitleaks and conventional-commit checks via pre-commit; coverage-regression gate +in CI (`.testcoverage.yml`); releases auto-tagged from conventional commits by +the shared Release workflow. Bump the consuming services' `go.mod` after a +release. A breaking change to the signed-header or secret-guard contract is a +cross-service change โ€” see ADR-0005/0006 before changing it. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..ac04085 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,80 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# template for the changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest"