diff --git a/.githooks/pre-push b/.githooks/pre-push index aa76614..38bf458 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,68 +1,13 @@ #!/bin/bash -# Pre-push hook for pdfer -# Runs the full test suite before pushing -# Also enforces version bump requirement for main branch +# Pre-push hook for pdfer. +# Runs the full test suite before pushing — fast local feedback before CI. +# Version bumps are handled by the Release workflow, not at push time. set -e echo "Running pre-push checks..." - -# Check if pushing to main branch -PUSHING_TO_MAIN=false -while read local_ref local_sha remote_ref remote_sha; do - if [[ "$remote_ref" == "refs/heads/main" ]]; then - PUSHING_TO_MAIN=true - break - fi -done - -if [ "$PUSHING_TO_MAIN" = true ]; then - echo "Pushing to main branch - checking version bump..." - - # Get current version from working directory - if [ ! -f "pdfer.go" ]; then - echo "❌ Error: pdfer.go not found" - exit 1 - fi - - # Extract version using sed (more portable than grep -P) - CURRENT_VERSION=$(grep 'return "' pdfer.go | sed -n 's/.*return "\([^"]*\)".*/\1/p' | head -1) - if [ -z "$CURRENT_VERSION" ]; then - echo "❌ Error: Could not extract version from pdfer.go" - exit 1 - fi - - # Get remote version (if remote exists) - REMOTE_VERSION="" - if git rev-parse --verify origin/main >/dev/null 2>&1; then - REMOTE_VERSION=$(git show origin/main:pdfer.go 2>/dev/null | grep 'return "' | sed -n 's/.*return "\([^"]*\)".*/\1/p' | head -1 || echo "") - fi - - # If remote version exists and matches current, reject push - if [ -n "$REMOTE_VERSION" ] && [ "$CURRENT_VERSION" = "$REMOTE_VERSION" ]; then - echo "" - echo "❌ ERROR: Version bump required!" - echo " Current version: $CURRENT_VERSION" - echo " Remote version: $REMOTE_VERSION" - echo "" - echo " Version numbers must be incremented before pushing to main." - echo " Update the version in pdfer.go and commit the change." - echo "" - echo " To override (not recommended), use: git push --no-verify" - echo "" - exit 1 - fi - - if [ -n "$REMOTE_VERSION" ]; then - echo "✓ Version check passed: $REMOTE_VERSION -> $CURRENT_VERSION" - else - echo "✓ Version check passed: $CURRENT_VERSION (no remote version found)" - fi -fi - -# Run all tests echo "Running tests..." go test ./... echo "" -echo "✅ All tests passed! Safe to push." +echo "All tests passed! Safe to push." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f43652f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test (Go ${{ matrix.go }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: ['1.21', '1.22', '1.23'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + - run: go vet ./... + - run: go test ./... + + fmt: + name: gofmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Check formatting + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "The following files need gofmt:" + echo "$UNFORMATTED" + exit 1 + fi + + version-guard: + name: No hardcoded library version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Reject literal version string in pdfer.go + run: | + if grep -nE 'return\s+"v?[0-9]+\.[0-9]+\.[0-9]+"' pdfer.go; then + echo "" + echo "ERROR: pdfer.go contains a hardcoded version literal." + echo "Version() must derive from runtime/debug.ReadBuildInfo()." + echo "See CONTRIBUTING.md for the release process." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..84775b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., v1.10.0)' + required: true + type: string + ref: + description: 'Branch or commit to release from' + required: false + default: 'main' + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Validate version format + run: | + if ! [[ "${{ inputs.version }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "ERROR: version must match vMAJOR.MINOR.PATCH (optional -prerelease suffix)" + echo "Got: ${{ inputs.version }}" + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Fail if tag already exists + run: | + if git rev-parse "refs/tags/${{ inputs.version }}" >/dev/null 2>&1; then + echo "ERROR: tag ${{ inputs.version }} already exists" + exit 1 + fi + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Verify build and tests + run: | + go vet ./... + go test ./... + + - name: Create and push annotated tag + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: | + git tag -a "${{ inputs.version }}" -m "Release ${{ inputs.version }}" + git push origin "${{ inputs.version }}" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ inputs.version }}" \ + --title "${{ inputs.version }}" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3272633..4a54654 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,25 +25,28 @@ go test ./... # Run tests This project uses git hooks to maintain code quality: - **pre-commit**: Runs `gofmt` and `go vet` on staged files -- **pre-push**: Runs the full test suite and enforces version bump requirement +- **pre-push**: Runs the full test suite before pushing -#### Version Bump Requirement +The setup script (`./scripts/setup.sh`) configures these. To skip hooks temporarily: -**Pushing to `main` requires a version bump.** The pre-push hook will reject pushes to `main` if the version in `pdfer.go` hasn't changed from the remote version. This ensures every change to `main` has an associated version increment. - -To bump the version: -1. Update the version string in `pdfer.go` (e.g., `0.6.0` → `0.7.0`) -2. Commit the change: `git commit -m "chore: bump version to X.Y.Z"` -3. Push normally - the hook will verify the version changed - -To override (not recommended): `git push --no-verify` - -The setup script configures these automatically. To skip hooks temporarily: ```bash git commit --no-verify # Skip pre-commit git push --no-verify # Skip pre-push ``` +## Releases + +`Version()` reads the module version at runtime from `runtime/debug.ReadBuildInfo()`, so it always matches the git tag Go's module system resolved. There is no version constant in source to keep in sync. + +Releases are cut from `main` via the **Release** GitHub Actions workflow: + +1. Open the [Actions tab](../../actions/workflows/release.yml) +2. Click **Run workflow** +3. Enter the version (e.g., `v1.10.0`), following [semantic versioning](https://semver.org/) +4. The workflow validates the version, runs the test suite, creates an annotated tag, pushes it, and publishes a GitHub release with auto-generated notes + +Only maintainers with write access can dispatch the workflow. + ## Code Style - Follow standard Go conventions (use `gofmt`) diff --git a/pdfer.go b/pdfer.go index 06c5921..a154a0c 100644 --- a/pdfer.go +++ b/pdfer.go @@ -77,6 +77,8 @@ package pdfer import ( + "runtime/debug" + "github.com/benedoc-inc/pdfer/types" ) @@ -107,7 +109,28 @@ type XFAConfig = types.XFAConfig // XFALocaleSet represents parsed XFA localization data. type XFALocaleSet = types.XFALocaleSet -// Version returns the library version. +const modulePath = "github.com/benedoc-inc/pdfer" + +// Version returns the library version, derived from Go module metadata. +// +// When consumed via `go get` or `go install`, this returns the resolved +// module version (e.g., "v1.10.0"). When built from a working copy, it +// returns "(devel)". Falls back to "(unknown)" if build info is unavailable. +// +// Versioning is driven by git tags through the release workflow; no manual +// string updates are required. func Version() string { - return "1.3.1" + info, ok := debug.ReadBuildInfo() + if !ok { + return "(unknown)" + } + if info.Main.Path == modulePath { + return info.Main.Version + } + for _, dep := range info.Deps { + if dep != nil && dep.Path == modulePath { + return dep.Version + } + } + return "(unknown)" }