From 1c44afaae4c328e4873da6bd720564bf963d3d12 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 29 Mar 2026 20:34:56 +0200 Subject: [PATCH] feat: enhance CI/CD pipeline and add release process documentation --- .drone.yml | 58 ++++++++++- CHANGELOG.md | 46 +++++++++ Certificate_test.go | 163 ++++++++++++++++++++++++++---- README.md | 9 +- docs/RELEASING.md | 122 ++++++++++++++++++++++ go.mod | 7 +- go.sum | 14 +-- scripts/check-coverage.sh | 32 ++++++ scripts/generate-release-notes.sh | 76 ++++++++++++++ 9 files changed, 494 insertions(+), 33 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/RELEASING.md create mode 100755 scripts/check-coverage.sh create mode 100755 scripts/generate-release-notes.sh diff --git a/.drone.yml b/.drone.yml index 088eedb..f0efce3 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2,11 +2,63 @@ kind: pipeline type: docker name: go-lib/certs +trigger: + event: + - push + - tag + ref: + include: + - refs/heads/** + - refs/tags/v* + steps: - name: test - image: golang:1.20.1 + image: golang:1.25.8 commands: - go get ./... - - go test ./... + - go vet ./... + - mkdir -p .build + - go test -v -coverprofile .build/coverage.out ./... + - go tool cover -func .build/coverage.out | tee .build/coverage.txt + - bash scripts/check-coverage.sh .build/coverage.out 80 - go install golang.org/x/vuln/cmd/govulncheck@latest - - govulncheck -v ./... + - govulncheck -json ./... > .build/vulncheck.json + +- name: release-notes + image: golang:1.25.8 + commands: + - git fetch --tags --force || true + - bash scripts/generate-release-notes.sh + when: + event: + - tag + status: + - success + +- name: package + image: golang:1.25.8 + commands: + - tar czf .build/sources.tar.gz --exclude=.build --exclude=.git --exclude=.drone.yml . + when: + event: + - tag + status: + - success + +- name: release + image: plugins/gitea-release + settings: + base_url: https://scm.yoorie.de + api_key: + from_secret: gitea_token + files: + - .build/coverage.txt + - .build/sources.tar.gz + - .build/release-notes.md + title: ${DRONE_TAG} + note_from_file: .build/release-notes.md + when: + event: + - tag + status: + - success diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f66ef15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + + + +All notable changes to this project will be documented in this file. + +The format follows conventional changelog categories. + +## [Unreleased] + +## [v0.0.3] - 2026-03-29 + +### Added + +- Added release process documentation in docs/RELEASING.md. +- Added repository changelog file. +- Added GoConvey-based regression tests for certificate generation. +- Added coverage gate script: scripts/check-coverage.sh. +- Added release notes generator script: scripts/generate-release-notes.sh. +- Added tag-based Drone release flow with artifact publishing. + +### Changed + +- Updated Drone pipeline to run on push and tag events. +- Extended CI quality gates with go vet, coverage reporting, and govulncheck. +- Improved README documentation links and release guidance. + +## [v0.0.2] - 2021-03-03 + +### Added + +- Added initial build configuration for the repository. + +### Changed + +- Updated project README. + +## v0.0.1 + +### Added + +- Initial project setup. + +[Unreleased]: https://scm.yoorie.de/git/go-lib/certs/compare/v0.0.3...main +[v0.0.3]: https://scm.yoorie.de/git/go-lib/certs/compare/v0.0.2...v0.0.3 +[v0.0.2]: https://scm.yoorie.de/git/go-lib/certs/releases/tag/v0.0.2 diff --git a/Certificate_test.go b/Certificate_test.go index 3fcaaa4..e2071e7 100644 --- a/Certificate_test.go +++ b/Certificate_test.go @@ -1,27 +1,156 @@ package certs import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "net" + "os" + "os/exec" + "strings" "testing" "time" - "gotest.tools/assert" + . "github.com/smartystreets/goconvey/convey" ) -func TestGenerateTLSConfig(t *testing.T) { - gc := &GenerateCertificate{ - Organization: "yoorie.de", - Host: "127.0.0.1", - ValidFor: 10 * 365 * 24 * time.Hour, - IsCA: false, - EcdsaCurve: "P256", - Ed25519Key: true, - } - result, err := gc.GenerateTLSConfig() +const testOrganization = "yoorie.de" - assert.Assert(t, err == nil) - assert.Assert(t, result != nil) - assert.Equal(t, 1, len(result.Certificates)) - cert := result.Certificates[0] - assert.Assert(t, len(cert.Certificate) > 0) - assert.Assert(t, len(cert.Certificate[0]) > 0) +func parseLeafCertificate(t *testing.T, gc *GenerateCertificate) *x509.Certificate { + t.Helper() + + result, err := gc.GenerateTLSConfig() + if err != nil { + t.Fatalf("GenerateTLSConfig returned error: %v", err) + } + if result == nil || len(result.Certificates) != 1 { + t.Fatalf("expected one certificate, got %#v", result) + } + if len(result.Certificates[0].Certificate) == 0 { + t.Fatal("expected leaf certificate bytes") + } + + leaf, err := x509.ParseCertificate(result.Certificates[0].Certificate[0]) + if err != nil { + t.Fatalf("ParseCertificate returned error: %v", err) + } + + return leaf +} + +func TestPublicKey(t *testing.T) { + Convey("publicKey returns the matching public key type", t, func() { + gc := &GenerateCertificate{} + + rsaKey, err := rsa.GenerateKey(rand.Reader, 1024) + So(err, ShouldBeNil) + So(gc.publicKey(rsaKey), ShouldResemble, &rsaKey.PublicKey) + + ecdsaKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + So(err, ShouldBeNil) + So(gc.publicKey(ecdsaKey), ShouldResemble, &ecdsaKey.PublicKey) + + _, ed25519Key, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + So(gc.publicKey(ed25519Key), ShouldResemble, ed25519Key.Public().(ed25519.PublicKey)) + + So(gc.publicKey(struct{}{}), ShouldBeNil) + }) +} + +func TestGenerateTLSConfig(t *testing.T) { + Convey("GenerateTLSConfig creates valid certificates for supported key types", t, func() { + Convey("RSA certificates include configured SANs and CA settings", func() { + validFrom := "Jan 2 15:04:05 2006" + leaf := parseLeafCertificate(t, &GenerateCertificate{ + Organization: testOrganization, + Host: "127.0.0.1,example.com", + ValidFrom: validFrom, + ValidFor: 24 * time.Hour, + IsCA: true, + RSABits: 1024, + }) + + expectedNotBefore, err := time.Parse("Jan 2 15:04:05 2006", validFrom) + So(err, ShouldBeNil) + + So(leaf.PublicKeyAlgorithm, ShouldEqual, x509.RSA) + So(leaf.Subject.Organization, ShouldResemble, []string{testOrganization}) + So(leaf.IsCA, ShouldBeTrue) + So(leaf.NotBefore.Equal(expectedNotBefore), ShouldBeTrue) + So(leaf.NotAfter.Sub(leaf.NotBefore), ShouldEqual, 24*time.Hour) + So(leaf.DNSNames, ShouldResemble, []string{"example.com"}) + So(len(leaf.IPAddresses), ShouldEqual, 1) + So(leaf.IPAddresses[0].Equal(net.ParseIP("127.0.0.1")), ShouldBeTrue) + }) + + Convey("Ed25519 certificates are supported when explicitly requested", func() { + leaf := parseLeafCertificate(t, &GenerateCertificate{ + Organization: testOrganization, + Host: "localhost", + ValidFor: 12 * time.Hour, + Ed25519Key: true, + }) + + So(leaf.PublicKeyAlgorithm, ShouldEqual, x509.Ed25519) + So(leaf.IsCA, ShouldBeFalse) + So(leaf.DNSNames, ShouldResemble, []string{"localhost"}) + }) + + Convey("all supported ECDSA curves generate server certificates", func() { + curves := []string{"P224", "P256", "P384", "P521"} + + for _, curve := range curves { + leaf := parseLeafCertificate(t, &GenerateCertificate{ + Organization: testOrganization, + Host: "certs.example.test", + ValidFor: 6 * time.Hour, + EcdsaCurve: curve, + }) + + So(leaf.PublicKeyAlgorithm, ShouldEqual, x509.ECDSA) + So(leaf.DNSNames, ShouldResemble, []string{"certs.example.test"}) + } + }) + }) +} + +func TestGenerateTLSConfigFatalScenarios(t *testing.T) { + if testCase := os.Getenv("CERTS_FATAL_TEST_CASE"); testCase != "" { + gc := &GenerateCertificate{RSABits: 1024, ValidFor: time.Hour} + + switch testCase { + case "invalid-curve": + gc.EcdsaCurve = "invalid" + case "invalid-valid-from": + gc.ValidFrom = "not-a-date" + default: + t.Fatalf("unknown fatal test case: %s", testCase) + } + + _, _ = gc.GenerateTLSConfig() + return + } + + Convey("GenerateTLSConfig terminates on invalid input", t, func() { + testCases := []struct { + name string + message string + }{ + {name: "invalid-curve", message: "Unrecognized elliptic curve"}, + {name: "invalid-valid-from", message: "Failed to parse creation date"}, + } + + for _, testCase := range testCases { + cmd := exec.Command(os.Args[0], "-test.run", "^TestGenerateTLSConfigFatalScenarios$") + cmd.Env = append(os.Environ(), "CERTS_FATAL_TEST_CASE="+testCase.name) + + output, err := cmd.CombinedOutput() + So(err, ShouldNotBeNil) + So(strings.Contains(string(output), testCase.message), ShouldBeTrue) + } + }) } diff --git a/README.md b/README.md index 3ec7fac..0056d30 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ -
- # Go TLS Library [![Build Status](https://drone.yoorie.de/api/badges/go-lib/certs/status.svg)](https://drone.yoorie.de/go-lib/certs) ## Documentation -Is missed so far and will be created soon. +Available project documentation: + +- [Changelog](CHANGELOG.md) +- [Definition of Done](docs/DEFINITION_OF_DONE.md) +- [Releasing](docs/RELEASING.md) --- Copyright © 2023 yoorie.de - diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..2a30aac --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,122 @@ + + +# Releasing go-lib/certs + +This document describes the process for creating a release of the +`go-lib/certs` library. + +The repository currently has the released tag `v0.0.2`. The next +release created from the current main branch should therefore use the +next patch version, for example `v0.0.3`, unless you intentionally +introduce a breaking or feature-level version bump. + +## Overview + +Releases in this project are managed via Git tags. When you push a +tag matching the pattern `v*` (for example, `v0.0.3`), the Drone +pipeline automatically: + +1. Runs quality checks (tests, coverage gate, vet, vulnerability scan) +2. Generates release notes from commits +3. Creates a source archive (`sources.tar.gz`) +4. Publishes a release to Gitea with attached artifacts + +## Versioning + +This library follows semantic versioning: `MAJOR.MINOR.PATCH` +(for example, `v1.2.3`). + +- `v1.0.0`: breaking API changes +- `v1.1.0`: backward-compatible features +- `v1.0.1`: backward-compatible fixes + +## Prerequisites + +Before creating a release, ensure: + +1. Working tree is clean (`git status`) +2. Tests pass locally (`go test ./...`) +3. Coverage is at least 80% +4. Dependencies are tidy (`go mod tidy`) +5. Documentation is up to date (README/docs) + +## Create a Release + +### 1. Prepare (optional) + +Update docs, examples, or changelog if needed: + +```bash +git add README.md CHANGELOG.md docs/ +git commit -m "docs: prepare release" +``` + +### 2. Create tag + +```bash +git tag -a v0.0.3 -m "Release v0.0.3" +``` + +### 3. Push tag + +```bash +git push origin v0.0.3 +``` + +### 4. Verify pipeline and release + +After pushing the tag, verify in Drone and Gitea: + +- Pipeline succeeded for tag build +- Gitea release exists with artifacts: + - `.build/coverage.txt` + - `.build/sources.tar.gz` + - `.build/release-notes.md` + +## Install Released Version + +```bash +# latest +go get scm.yoorie.de/go-lib/certs + +# specific +go get scm.yoorie.de/go-lib/certs@v0.0.3 +``` + +## Troubleshooting + +### Coverage below threshold + +Run locally and inspect uncovered code: + +```bash +go test -v -coverprofile .build/coverage.out ./... +go tool cover -func .build/coverage.out +``` + +### Release step fails + +Common causes: + +- Missing Drone secret `gitea_token` +- Tag does not match `v*` +- Earlier pipeline step failed + +### Wrong tag pushed + +```bash +git tag -d v0.0.3 +git push origin :refs/tags/v0.0.3 +``` + +Then create and push the corrected tag. + +## Release Checklist + +- [ ] Tests pass locally +- [ ] Coverage is at least 80% +- [ ] `go vet ./...` passes +- [ ] `govulncheck ./...` is clean or reviewed +- [ ] Tag `vX.Y.Z` created and pushed +- [ ] Drone pipeline succeeded +- [ ] Gitea release contains expected artifacts diff --git a/go.mod b/go.mod index 3be8b67..78d6e93 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,10 @@ module scm.yoorie.de/go-lib/certs go 1.18 -require gotest.tools v2.2.0+incompatible +require github.com/smartystreets/goconvey v1.8.1 require ( - github.com/google/go-cmp v0.5.9 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/smarty/assertions v1.15.0 // indirect ) diff --git a/go.sum b/go.sum index bc7511f..7fea79b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..cf7e777 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Check test coverage against minimum threshold. + +set -euo pipefail + +COVERAGE_FILE="${1:-.build/coverage.out}" +MIN_COVERAGE="${2:-80}" + +if [ ! -f "$COVERAGE_FILE" ]; then + echo "Error: coverage file not found: $COVERAGE_FILE" + exit 1 +fi + +# Extract total coverage percentage from go tool cover output. +COVERAGE=$(go tool cover -func "$COVERAGE_FILE" | awk '/^total:/ { match($0, /[0-9.]+%/); print substr($0, RSTART, RLENGTH-1) }') + +if [ -z "$COVERAGE" ]; then + echo "Error: failed to parse coverage from $COVERAGE_FILE" + exit 1 +fi + +echo "Total coverage: ${COVERAGE}%" + +# Compare as integer part to keep shell arithmetic simple and portable. +COVERAGE_INT=${COVERAGE%.*} + +if [ "$COVERAGE_INT" -lt "$MIN_COVERAGE" ]; then + echo "Coverage ${COVERAGE}% is below minimum ${MIN_COVERAGE}%" + exit 1 +fi + +echo "Coverage check passed" diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh new file mode 100755 index 0000000..6d51f2b --- /dev/null +++ b/scripts/generate-release-notes.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Generate release notes from commits since the previous tag. + +set -euo pipefail + +CURRENT_TAG="${1:-${DRONE_TAG:-}}" +OUTPUT_FILE="${2:-.build/release-notes.md}" + +if [ -z "$CURRENT_TAG" ]; then + echo "Error: current tag not provided" + exit 1 +fi + +# In CI, the tag name may be available but not present as a local ref. +CURRENT_REF="$CURRENT_TAG" +if ! git rev-parse --verify -q "${CURRENT_REF}^{commit}" >/dev/null; then + CURRENT_REF="${DRONE_COMMIT:-HEAD}" +fi + +PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || echo "") + +mkdir -p "$(dirname "$OUTPUT_FILE")" + +{ + echo "# Release $CURRENT_TAG" + echo + + if [ -n "$PREVIOUS_TAG" ]; then + echo "Changes since $PREVIOUS_TAG:" + echo + + git log "$PREVIOUS_TAG..$CURRENT_REF" --pretty=format:"%s" | while read -r commit; do + if [[ $commit =~ ^([a-z]+)(\(.+\))?:\ (.+)$ ]]; then + TYPE="${BASH_REMATCH[1]}" + SCOPE="${BASH_REMATCH[2]}" + MESSAGE="${BASH_REMATCH[3]}" + + case "$TYPE" in + feat) + echo "- Feature${SCOPE}: $MESSAGE" ;; + fix) + echo "- Fix${SCOPE}: $MESSAGE" ;; + docs) + echo "- Docs${SCOPE}: $MESSAGE" ;; + ci) + echo "- CI${SCOPE}: $MESSAGE" ;; + test) + echo "- Test${SCOPE}: $MESSAGE" ;; + refactor) + echo "- Refactor${SCOPE}: $MESSAGE" ;; + perf) + echo "- Performance${SCOPE}: $MESSAGE" ;; + *) + echo "- $commit" ;; + esac + else + echo "- $commit" + fi + done + else + echo "Initial release" + echo + git log "$CURRENT_REF" --pretty=format:"- %s" | head -20 + fi + + echo + echo "---" + if [ -n "$PREVIOUS_TAG" ]; then + echo "See all changes: https://scm.yoorie.de/git/go-lib/certs/compare/$PREVIOUS_TAG...$CURRENT_TAG" + else + echo "See all changes: https://scm.yoorie.de/git/go-lib/certs/releases/tag/$CURRENT_TAG" + fi +} > "$OUTPUT_FILE" + +echo "Release notes generated: $OUTPUT_FILE" +cat "$OUTPUT_FILE"