From 65fcf4a2ed042fe640bd7af616329505221df072 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 16:11:58 +0300 Subject: [PATCH 1/2] Trunk-based releases: build/release workflows, tests, v1.0.0 - build.yml: go mod verify/tidy, vet, build and test -race on Go stable + oldstable; API tests against stag-v2 with CONVERTAPI_STAGING_TOKEN, skipped without it; `build` aggregator is the required check and checks config.Version. - release.yml: GitHub Release v{semver} by tomasr78 -> checks publisher, re-run actor, tag, major version vs module path, main, config.Version; tests; go mod download via proxy.golang.org (+ sum.golang.org) and checks the proxy's origin commit; asks pkg.go.dev to fetch the version. - Offline tests against an httptest fake API; live tests skip cleanly. - config.Version = "1.0.0" in the User-Agent; config.New nil-transport panic and missing CaTransport fixed; go vet copylocks fix in param.NewFile. - RELEASING.md, CHANGELOG.md, dependabot, README install snippet. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 30 +++++ .github/workflows/build.yml | 144 ++++++++++++++++++++++ .github/workflows/release.yml | 221 ++++++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 33 ----- CHANGELOG.md | 30 +++++ README.md | 23 ++-- RELEASING.md | 61 ++++++++++ convert_test.go | 50 +++++--- offline_test.go | 116 ++++++++++++++++++ pkg/config/config.go | 21 +++- pkg/config/transport.go | 3 +- pkg/config/version.go | 4 +- pkg/param/file.go | 10 +- 13 files changed, 681 insertions(+), 65 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100644 offline_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9cf6e70 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +# Weekly dependency updates, each opened as a pull request against main that must pass the `build` check +# and is merged like any other change (merge commit). Merging releases nothing: a new module version still +# needs a GitHub Release (see RELEASING.md). +version: 2 +updates: + # The workflows pin every action to a commit SHA with a version comment; Dependabot updates both. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + groups: + github-actions: + patterns: ["*"] + commit-message: + prefix: ci + labels: [dependencies] + + # The library itself has no dependencies; this keeps the test tooling (testify) current. + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + groups: + gomod-minor-and-patch: + update-types: [minor, patch] + commit-message: + prefix: deps + labels: [dependencies] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..39442f7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,144 @@ +name: Build +run-name: >- + ${{ github.event_name == 'pull_request' && format('Checking that #{0} builds', github.event.pull_request.number) + || format('Building {0}', github.ref_name) }} + +# Trunk-based: main is the only long-lived branch. Every pull request and every push to main runs +# `go vet`, `go build` and `go test` on the two latest Go releases. Nothing is released here: a Go module +# version is a v{semver} tag, and tags come only from a GitHub Release (see RELEASING.md and release.yml). +# +# The tests that call the API run against the staging API with the CONVERTAPI_STAGING_TOKEN secret (set for +# Actions and for Dependabot). Where the secret is unavailable (forks) they skip, and the offline tests run. +# +# The required status check is the `build` job, which passes only when every Go version passed. +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # A newer push to the same pull request cancels the older build; builds of main never cancel each other. + group: build-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: test (Go ${{ matrix.go }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # setup-go's aliases for the latest and the previous Go release, the two the Go team supports. + go: [stable, oldstable] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ matrix.go }} + cache-dependency-path: go.sum + + - name: Check go.mod and go.sum + run: | + set -euo pipefail + go version + go mod verify + go mod tidy -diff + + - name: Vet + run: go vet ./... + + - name: Build + run: go build ./... + + # The staging API answers 503 (no healthy upstream) in bursts of up to a minute, so the suite gets up + # to three attempts, 30 s apart. + - name: Test + env: + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + CONVERTAPI_BASE_URL: https://stag-v2.convertapi.com + run: | + set -uo pipefail + if [ -z "$CONVERTAPI_TOKEN" ]; then + echo "::notice::CONVERTAPI_STAGING_TOKEN is not available to this run (a fork pull request), so the API tests are skipped." + fi + for attempt in 1 2 3; do + go test -count=1 -race -v ./... && exit 0 + echo "::warning::Attempt $attempt: the tests failed, see above" + sleep 30 + done + echo "::error::The tests failed three times" + exit 1 + + build: + name: build + needs: test + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check every Go version passed + env: + RESULT: ${{ needs.test.result }} + run: | + if [ "$RESULT" != success ]; then + echo "::error::The Go version matrix finished with '$RESULT'." + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # config.Version goes into the User-Agent of every request, and release.yml refuses a tag that does + # not match it. It may run ahead of the latest release, never behind it. + - name: Check the version constant + id: version + run: | + set -euo pipefail + VERSION=$(sed -n 's/^const Version = "\(.*\)".*/\1/p' pkg/config/version.go) + SEMVER='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' + if ! [[ "$VERSION" =~ $SEMVER ]]; then + echo "::error file=pkg/config/version.go::config.Version '$VERSION' is not {major}.{minor}.{patch}[-{pre-release}]" + exit 1 + fi + # The module path has no /vN suffix, so it can only carry v0 and v1 versions. + if [ "${VERSION%%.*}" -ge 2 ]; then + echo "::error file=pkg/config/version.go::config.Version $VERSION needs the module path github.com/ConvertAPI/convertapi-go/v${VERSION%%.*} (see RELEASING.md)" + exit 1 + fi + LATEST=$(git tag --list 'v*' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sed 's/^v//' | sort -V | tail -n1) + if [ -n "$LATEST" ] && [ "$(printf '%s\n%s\n' "$LATEST" "${VERSION%%-*}" | sort -V | tail -n1)" != "${VERSION%%-*}" ]; then + echo "::error file=pkg/config/version.go::config.Version $VERSION is lower than the latest release $LATEST" + exit 1 + fi + # SemVer sorts 1.0.1-beta.1 below 1.0.1, so a pre-release of the latest release is a step back. + if [[ "$VERSION" == *-* ]] && [ "${VERSION%%-*}" = "$LATEST" ]; then + echo "::error file=pkg/config/version.go::config.Version $VERSION is a pre-release of $LATEST, which is already released" + exit 1 + fi + { + echo "version=$VERSION" + echo "latest=${LATEST:-none}" + } >> "$GITHUB_OUTPUT" + + - name: Summary + env: + VERSION: ${{ steps.version.outputs.version }} + LATEST: ${{ steps.version.outputs.latest }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| \`config.Version\` | \`$VERSION\` (latest release: \`$LATEST\`) |" + echo "| Go | stable, oldstable |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..29aa83d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,221 @@ +name: Release +run-name: Releasing github.com/ConvertAPI/convertapi-go ${{ github.event.release.tag_name }} + +# A release is a GitHub Release, never a merge. Merging to main only builds (build.yml). Publishing the +# Release v{semver} creates the tag, and a Go module version is nothing more than that tag: there is no +# package to build, no registry upload and no key. This workflow checks that the release is legitimate and +# complete: who published it, that the tag is SemVer, on main and fits the module path, that config.Version +# matches it and that the tests pass against the API. Then it asks the Go module proxy to fetch the tag, +# which also records its checksum in the checksum database, and asks pkg.go.dev to show it. +# +# gh release create v1.0.1 --target main --generate-notes -> github.com/ConvertAPI/convertapi-go v1.0.1 +# gh release create v1.1.0-beta.1 --target main --prerelease ... -> github.com/ConvertAPI/convertapi-go v1.1.0-beta.1 +# +# The tag is public the moment it exists, and once the proxy has fetched it the version is permanent, so a +# failure here does not undo a release; it says the version needs a fix-forward release (see RELEASING.md). +# Nothing here retries a failed check or moves a tag. +on: + release: + types: [published] + +permissions: + contents: read + +env: + # Only this account may publish a Release. GitHub cannot express this as a permission on the Team plan, + # so it is checked here; the real control is the v* tag ruleset (only repository admins create v* tags). + PUBLISHER: tomasr78 + GOPROXY: https://proxy.golang.org + GOSUMDB: sum.golang.org + +jobs: + release: + name: release + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: go-proxy + url: https://pkg.go.dev/github.com/ConvertAPI/convertapi-go@${{ github.event.release.tag_name }} + concurrency: + group: release-go + cancel-in-progress: false + steps: + - name: Check who published the release + run: | + # A re-run keeps GITHUB_ACTOR as the original user; GITHUB_TRIGGERING_ACTOR is whoever pressed + # "Re-run", so both must be the publisher or anyone with write access could replay a release. + if [ "$GITHUB_ACTOR" != "$PUBLISHER" ] || [ "${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}" != "$PUBLISHER" ]; then + echo "::error::Only $PUBLISHER may publish a release - this release was published by $GITHUB_ACTOR and run by ${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}." + exit 1 + fi + echo "Published by $GITHUB_ACTOR" + + # The release event runs this file from the tag, so the workflow and the code come from the same commit. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check the tag and the version + id: tag + env: + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + set -euo pipefail + # SemVer 2.0 without build metadata, which Go module versions cannot carry. + SEMVER='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' + if ! [[ "$TAG" =~ $SEMVER ]]; then + echo "::error::Tag '$TAG' is not v{major}.{minor}.{patch} or v{major}.{minor}.{patch}-{pre-release}, e.g. v1.0.1 or v1.1.0-beta.1" + exit 1 + fi + if [ "$GITHUB_REF" != "refs/tags/$TAG" ]; then + echo "::error::The run is on $GITHUB_REF, not on the release tag refs/tags/$TAG" + exit 1 + fi + VERSION="${TAG#v}" + SHA=$(git rev-parse "refs/tags/$TAG^{commit}") + if [ "$(git rev-parse HEAD)" != "$SHA" ]; then + echo "::error::Checked out $(git rev-parse HEAD), but $TAG points at $SHA" + exit 1 + fi + + # Go's import compatibility rule: v2 and later live under a /vN module path, so a v2+ tag on a + # module path without that suffix is a version no `go get` can install. + MODULE=$(sed -n 's/^module \([^ ]*\).*/\1/p' go.mod) + MAJOR="${VERSION%%.*}" + if [ "$MAJOR" -ge 2 ] && [[ "$MODULE" != */v"$MAJOR" ]]; then + echo "::error file=go.mod::$TAG is major version $MAJOR, but the module path is $MODULE. A v$MAJOR release needs the module path $MODULE/v$MAJOR (see RELEASING.md)." + exit 1 + elif [ "$MAJOR" -lt 2 ] && [[ "$MODULE" =~ /v[0-9]+$ ]]; then + echo "::error file=go.mod::$TAG is major version $MAJOR, but the module path $MODULE has a major version suffix" + exit 1 + fi + + # The GitHub "pre-release" tick and the version must agree, so a preview cannot pass for the + # latest stable release on GitHub, and a stable version cannot hide as a pre-release. + if [[ "$VERSION" == *-* ]]; then + if [ "$PRERELEASE" != true ]; then + echo "::error::$TAG is a pre-release version, so the GitHub Release must be marked as a pre-release (gh release create --prerelease)" + exit 1 + fi + elif [ "$PRERELEASE" = true ]; then + echo "::error::$TAG is a stable version, but the GitHub Release is marked as a pre-release. Use a pre-release version such as ${TAG}-beta.1, or untick pre-release." + exit 1 + fi + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::$TAG ($SHA) is not on main. Releases, pre-releases included, are tagged on main only." + exit 1 + fi + + # Go builds the tagged tree as it is, so the version the library sends in its User-Agent is the + # constant in the code, and it must be the tag's. + CONSTANT=$(sed -n 's/^const Version = "\(.*\)".*/\1/p' pkg/config/version.go) + if [ "$CONSTANT" != "$VERSION" ]; then + echo "::error file=pkg/config/version.go::config.Version is '$CONSTANT' at $TAG, not '$VERSION'. The tag is already public; release the next version with the constant bumped." + exit 1 + fi + + { + echo "version=$VERSION" + echo "sha=$SHA" + echo "module=$MODULE" + } >> "$GITHUB_OUTPUT" + echo "Releasing $MODULE $TAG from $SHA" + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + + - name: Vet and build + run: | + set -euo pipefail + go version + go mod verify + go vet ./... + go build ./... + + # Every test, the API ones included, against the staging API. The staging API answers 503 (no healthy + # upstream) in bursts of up to a minute, so the suite gets up to three attempts, 30 s apart. + - name: Test + env: + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + CONVERTAPI_BASE_URL: https://stag-v2.convertapi.com + run: | + set -uo pipefail + if [ -z "$CONVERTAPI_TOKEN" ]; then + echo "::error::The CONVERTAPI_STAGING_TOKEN repository secret is not set, so the API tests cannot run." + exit 1 + fi + for attempt in 1 2 3; do + go test -count=1 -race -v ./... && exit 0 + echo "::warning::Attempt $attempt: the tests failed, see above" + sleep 30 + done + echo "::error::The tests failed three times" + exit 1 + + # `go mod download` from an empty directory makes proxy.golang.org fetch the tag from GitHub and + # checks the result against sum.golang.org, as every user's first `go get` will. The proxy must then + # report the tagged commit as the version's origin. A tag the proxy has just seen can take a minute. + - name: Publish to the Go module proxy + env: + TAG: ${{ github.event.release.tag_name }} + SHA: ${{ steps.tag.outputs.sha }} + MODULE: ${{ steps.tag.outputs.module }} + run: | + set -uo pipefail + cd "$(mktemp -d)" + # The proxy's URLs spell capital letters as !lowercase. + ESCAPED=$(sed 's/[A-Z]/!\L&/g' <<<"$MODULE") + for _ in $(seq 1 10); do + if JSON=$(go mod download -json "$MODULE@$TAG"); then + ORIGIN=$(curl -fsS "$GOPROXY/$ESCAPED/@v/$TAG.info" | jq -r '.Origin.Hash // empty') || ORIGIN='' + if [ "$ORIGIN" = "$SHA" ]; then + jq -r '"\(.Path) \(.Version) \(.Sum)"' <<<"$JSON" + echo "The proxy serves $MODULE $TAG from $SHA, and sum.golang.org has its checksum" + exit 0 + elif [ -n "$ORIGIN" ]; then + echo "::error::The proxy serves $MODULE $TAG from $ORIGIN, not from $SHA. The proxy keeps the first commit it saw; release the next version." + exit 1 + fi + fi + sleep 30 + done + echo "::error::proxy.golang.org has not served $MODULE $TAG after 5 minutes. Run: GOPROXY=$GOPROXY go list -m $MODULE@$TAG" + exit 1 + + # pkg.go.dev picks new versions up from the proxy's index, usually within minutes; the fetch request + # (the page's "Request" button) asks for it at once. The documentation site is not part of the + # release, so a slow pkg.go.dev only warns. + - name: Wait for pkg.go.dev + env: + TAG: ${{ github.event.release.tag_name }} + MODULE: ${{ steps.tag.outputs.module }} + run: | + set -uo pipefail + curl -fsS -o /dev/null -X POST "https://pkg.go.dev/fetch/$MODULE@$TAG" || true + for _ in $(seq 1 30); do + if curl -fsS -o /dev/null "https://pkg.go.dev/$MODULE@$TAG"; then + echo "https://pkg.go.dev/$MODULE@$TAG is up" + exit 0 + fi + sleep 30 + done + echo "::warning::pkg.go.dev has not shown $MODULE $TAG after 15 minutes. Open https://pkg.go.dev/$MODULE@$TAG and press Request." + + - name: Summary + env: + TAG: ${{ github.event.release.tag_name }} + SHA: ${{ steps.tag.outputs.sha }} + MODULE: ${{ steps.tag.outputs.module }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| Module | [\`$MODULE $TAG\`](https://pkg.go.dev/$MODULE@$TAG) |" + echo "| Install | \`go get $MODULE@$TAG\` |" + echo "| Commit | \`$SHA\` |" + echo "| Tested against | https://stag-v2.convertapi.com (Go stable) |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 0d0c6a2..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Tests - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - env: - GO111MODULE: auto - CONVERTAPI_SECRET: ${{ secrets.CONVERTAPI_SECRET }} - CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_TOKEN }} - CONVERTAPI_APIKEY: ${{ secrets.CONVERTAPI_APIKEY }} - - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: 1.22.2 - - - name: Install package - run: go install ./pkg - - - name: Build - run: go build -v ./pkg - - - name: testing - run: go test -v ./pkg \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..730ac0a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog and this project adheres to Semantic Versioning. Before 1.0.0 the +module had no version tags, so `go get` resolved pseudo-versions such as +`v0.0.0-20250603083246-b586aa6ba8a2`; their history is the +[commit log](https://github.com/ConvertAPI/convertapi-library-go/commits/main). + +## [1.0.0] — 2026-10-20 + +The first tagged release. The API is the one of the latest pseudo-version +(`v0.0.0-20250603083246-b586aa6ba8a2`), with the changes below. + +### Changed +- `config.Version` is the string `"1.0.0"` (it was the number `4`), and the `User-Agent` is + `ConvertAPI-Go/1.0.0 ()`. Code that used `config.Version` as a number must change. +- The default branch is `main` (was `master`). +- Releases are now cut from a GitHub Release `v{version}` on `main`, checked by `release.yml`, which also + makes the Go module proxy fetch the version. Every pull request is built and tested on the two latest + Go releases. + +### Fixed +- `config.New` with a nil transport no longer panics on the first request; it uses + `http.DefaultTransport`. It also sets `Config.CaTransport`, which was nil, and no longer panics when + `config.Default` is not set and the URL is nil. +- `param.NewFile` no longer copies a mutex (`go vet` copylocks). +- The README's full example imports the right packages. + +[1.0.0]: https://github.com/ConvertAPI/convertapi-library-go/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 75fdda6..c9e7343 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # ConvertAPI Go Client -![example workflow](https://github.com/ConvertAPI/convertapi-go/actions/workflows/tests.yml/badge.svg) +[![Build](https://github.com/ConvertAPI/convertapi-library-go/actions/workflows/build.yml/badge.svg)](https://github.com/ConvertAPI/convertapi-library-go/actions/workflows/build.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/ConvertAPI/convertapi-go.svg)](https://pkg.go.dev/github.com/ConvertAPI/convertapi-go) ## Convert your files with our online file conversion API @@ -11,12 +12,18 @@ You can get your free API token at https://www.convertapi.com/a/authentication ## Installation -Execute this command in your GOPATH: +Add the library to your Go module: ```shell -go get github.com/ConvertAPI/convertapi-go +go get github.com/ConvertAPI/convertapi-go@v1.0.0 ``` +The module path is `github.com/ConvertAPI/convertapi-go` (this repository's former name), and the +packages you import are `github.com/ConvertAPI/convertapi-go/pkg` (package `convertapi`), +`.../pkg/config` and `.../pkg/param`. Releases are listed on the +[releases page](https://github.com/ConvertAPI/convertapi-library-go/releases) and in the +[changelog](CHANGELOG.md). The library is tested on the two latest Go releases. + ## Usage ### Configuration @@ -90,12 +97,12 @@ conversionsConsumed := user.ConversionsConsumed ### Alternative domain -How to set an alternative domain can be found in the [advanced example](https://github.com/ConvertAPI/convertapi-go/blob/master/examples/advanced/main.go). +How to set an alternative domain can be found in the [advanced example](https://github.com/ConvertAPI/convertapi-library-go/blob/main/examples/advanced/main.go). Dedicated to the region [domain list](https://www.convertapi.com/doc/servers-location). ### More examples -Find more advanced examples in the [examples](https://github.com/ConvertAPI/convertapi-go/tree/master/examples) folder. +Find more advanced examples in the [examples](https://github.com/ConvertAPI/convertapi-library-go/tree/main/examples) folder. #### Converting your first file, full example: @@ -105,9 +112,11 @@ ConvertAPI is designed to make converting file super easy. The following snippet package main import ( - "github.com/ConvertAPI/convertapi-go" "fmt" "os" + + "github.com/ConvertAPI/convertapi-go/pkg" + "github.com/ConvertAPI/convertapi-go/pkg/config" ) func main() { @@ -139,4 +148,4 @@ Please leave all comments, bugs, requests, and issues on the Issues page. We'll ### License The ConvertAPI Go Library is licensed under the [MIT](http://www.opensource.org/licenses/mit-license.php "Read more about the MIT license form") license. -Refer to the [LICENSE](https://github.com/ConvertAPI/convertapi-go/blob/master/LICENSE) file for more information. \ No newline at end of file +Refer to the [LICENSE](https://github.com/ConvertAPI/convertapi-library-go/blob/main/LICENSE.txt) file for more information. \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..efc017b --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,61 @@ +# Releasing + +`main` is the only long-lived branch. Changes reach it through pull requests, merge commits only, and +every pull request and push to `main` must pass the `build` check (`.github/workflows/build.yml`: +`go mod verify`, `go mod tidy -diff`, `go vet`, `go build` and `go test -race` on the two latest Go +releases). The tests that call the API run against the staging API (`https://stag-v2.convertapi.com`) +with the `CONVERTAPI_STAGING_TOKEN` secret; without the secret (fork pull requests) they skip and the +offline tests still run. Merging publishes nothing. + +A Go module version is just a tag: there is no package to build, no registry upload and no key. The +module path is **`github.com/ConvertAPI/convertapi-go`** (the repository's old name; GitHub redirects it +to this repository, and every existing user imports it), so the version `v1.0.1` is the tag `v1.0.1` in +this repository. `v*` tags are created only by publishing a **GitHub Release** (repository admins only, by +ruleset), which `tomasr78` does: + +1. In a pull request, set `Version` in `pkg/config/version.go` to the new version (it goes into the + `User-Agent` of every request) and add the version to `CHANGELOG.md`. Merge it. +2. Wait for the `build` check of that merge on `main` to pass, then publish the release: + +```bash +gh release create v1.0.1 --repo ConvertAPI/convertapi-library-go --target main --title v1.0.1 --generate-notes +``` + +That runs `.github/workflows/release.yml` in the `go-proxy` environment, which: + +1. refuses a Release published or re-run by anyone else; +2. checks the tag is SemVer (`v1.0.1`, `v1.1.0-beta.1`), that its major version fits the module path, that + the GitHub pre-release tick matches the version, that the tagged commit is on `main`, and that + `config.Version` at the tag is the version; +3. runs `go vet`, `go build` and every test, the API ones included, on the latest Go against the staging API; +4. runs `go mod download github.com/ConvertAPI/convertapi-go@v1.0.1` through `proxy.golang.org`, which + makes the proxy fetch the tag and checks it against `sum.golang.org`, then checks that the proxy serves + the version from the tagged commit; +5. asks pkg.go.dev to fetch the version and waits up to 15 minutes for + (a warning only). + +The tag is public the moment it exists, so these checks cannot stop a release; a red run means the version +that is already public needs a fix. **Pre-releases** (`--prerelease`, e.g. `v1.1.0-beta.1`) work the same +way and are also tagged on `main`; `go get` installs them only when asked for by version +(`go get github.com/ConvertAPI/convertapi-go@v1.1.0-beta.1`), never as `@latest`. + +Go module versions are permanent: once the proxy has fetched a tag it serves that content forever, and the +checksum database pins it, so a moved or re-created tag breaks every build that uses it. Never delete, move +or re-create a release tag. Fix a bad version by releasing the next one and say so in both Releases' notes. +To keep a broken version from being chosen, add a `retract` directive to `go.mod` in the next release. + +## Major versions + +Go's import compatibility rule puts v2 and later under a different module path: a `v2.0.0` release needs +`module github.com/ConvertAPI/convertapi-go/v2` in `go.mod`, and every import in the code, the tests and +the examples changes to `.../convertapi-go/v2/pkg...`. Users then upgrade by changing their imports. Both +`build.yml` and `release.yml` refuse a v2+ version on a module path without the suffix. Breaking API +changes, such as the unreleased `v3` branch (the `api.convertapi.io/v3` API with renamed types and +functions), go out as the next major version this way. + +## Module path + +The module path stays `github.com/ConvertAPI/convertapi-go` because changing it would break every user's +imports. It works only while GitHub redirects the old repository name here: never create a repository named +`ConvertAPI/convertapi-go`. `go get github.com/ConvertAPI/convertapi-library-go` does not work and fails +with "module declares its path as github.com/ConvertAPI/convertapi-go". diff --git a/convert_test.go b/convert_test.go index 253637b..cb79115 100644 --- a/convert_test.go +++ b/convert_test.go @@ -1,32 +1,47 @@ package convertapi import ( + "net/url" + "os" + "path" + "testing" + "github.com/ConvertAPI/convertapi-go/pkg" "github.com/ConvertAPI/convertapi-go/pkg/config" "github.com/ConvertAPI/convertapi-go/pkg/param" "github.com/stretchr/testify/assert" - "os" - "path" - "testing" ) -var authCred = os.Getenv("CONVERTAPI_SECRET") - -func TestSetup(t *testing.T) { - config.Default = config.NewDefault(authCred) - assert.Equal(t, config.Default.CaTransport.AuthCred, authCred) +// The tests in this file call the ConvertAPI API. They run only when CONVERTAPI_TOKEN is set, against +// CONVERTAPI_BASE_URL when it is set (CI uses the staging API) and the production API otherwise. +func liveConfig(t *testing.T) *config.Config { + t.Helper() + token := os.Getenv("CONVERTAPI_TOKEN") + if token == "" { + t.Skip("CONVERTAPI_TOKEN is not set, so the API tests are skipped") + } + conf := config.NewDefault(token) + if base := os.Getenv("CONVERTAPI_BASE_URL"); base != "" { + baseURL, err := url.ParseRequestURI(base) + if err != nil { + t.Fatalf("CONVERTAPI_BASE_URL %q: %v", base, err) + } + conf.BaseURL = baseURL + } + config.Default = conf + return conf } func TestConvertPath(t *testing.T) { - config.Default = config.NewDefault(authCred) - file, errs := convertapi.ConvertPath("assets/test.docx", path.Join(os.TempDir(), "convertapi-test.pdf")) + liveConfig(t) + file, errs := convertapi.ConvertPath("assets/test.docx", path.Join(t.TempDir(), "convertapi-test.pdf")) assert.Nil(t, errs) assert.NotEmpty(t, file.Name()) } func TestChained(t *testing.T) { - config.Default = config.NewDefault(authCred) + liveConfig(t) jpgRes := convertapi.Convert("docx", "jpg", []param.IParam{ param.NewPath("file", "assets/test.docx", nil), }, nil) @@ -43,14 +58,17 @@ func TestChained(t *testing.T) { files, err := zipRes.Files() assert.Nil(t, err) - assert.Equal(t, "test.zip", files[0].FileName) + if assert.NotEmpty(t, files) { + assert.Equal(t, "test.zip", files[0].FileName) + } } func TestUserInfo(t *testing.T) { - config.Default = config.NewDefault(authCred) - user, err := convertapi.UserInfo(nil) + conf := liveConfig(t) + user, err := convertapi.UserInfo(conf) assert.Nil(t, err) - assert.NotEmpty(t, user.ConversionsTotal) - assert.NotEmpty(t, user.ConversionsConsumed) + if assert.NotNil(t, user) { + assert.NotEmpty(t, user.ConversionsTotal) + } } diff --git a/offline_test.go b/offline_test.go new file mode 100644 index 0000000..bfc528f --- /dev/null +++ b/offline_test.go @@ -0,0 +1,116 @@ +package convertapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "strings" + "testing" + + "github.com/ConvertAPI/convertapi-go/pkg" + "github.com/ConvertAPI/convertapi-go/pkg/config" + "github.com/ConvertAPI/convertapi-go/pkg/param" + "github.com/stretchr/testify/assert" +) + +// fakeAPI answers like the ConvertAPI API: upload, convert, download and user. Every request must carry +// the token and the library's User-Agent. +func fakeAPI(t *testing.T) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.True(t, strings.HasPrefix(r.Header.Get("User-Agent"), "ConvertAPI-Go/"+config.Version+" ("), + "User-Agent %q", r.Header.Get("User-Agent")) + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/upload": + assert.Equal(t, "test.docx", r.URL.Query().Get("filename")) + io.WriteString(w, "upload-id") + case r.Method == http.MethodPost && r.URL.Path == "/convert/docx/to/pdf": + assert.Equal(t, "true", r.URL.Query().Get("storefile")) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "upload-id", r.PostForm.Get("file")) + assert.Equal(t, "2", r.PostForm.Get("pagerange")) + json.NewEncoder(w).Encode(map[string]any{ + "ConversionCost": 1, + "Files": []map[string]any{ + {"FileName": "test.pdf", "FileSize": 3, "Url": srv.URL + "/d/test.pdf"}, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/d/test.pdf": + io.WriteString(w, "PDF") + case r.Method == http.MethodGet && r.URL.Path == "/user": + io.WriteString(w, `{"Active":true,"ConversionsTotal":250,"ConversionsConsumed":7}`) + default: + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, `{"Code":4013,"Message":"Unknown endpoint"}`) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func fakeConfig(t *testing.T, srv *httptest.Server) *config.Config { + t.Helper() + baseURL, err := url.Parse(srv.URL) + assert.NoError(t, err) + conf := config.New("test-token", baseURL, nil) + config.Default = conf + return conf +} + +func TestOfflineConvert(t *testing.T) { + conf := fakeConfig(t, fakeAPI(t)) + assert.Equal(t, "test-token", conf.CaTransport.AuthCred) + + res := convertapi.ConvDef("docx", "pdf", + param.NewPath("file", "assets/test.docx", nil), + param.NewInt("pagerange", 2), + ) + + cost, err := res.Cost() + assert.NoError(t, err) + assert.Equal(t, 1, cost) + + out := path.Join(t.TempDir(), "result.pdf") + files, errs := res.ToPath(out) + assert.Nil(t, errs) + if assert.Len(t, files, 1) { + body, err := os.ReadFile(files[0].Name()) + assert.NoError(t, err) + assert.Equal(t, "PDF", string(body)) + } +} + +func TestOfflineUserInfo(t *testing.T) { + conf := fakeConfig(t, fakeAPI(t)) + user, err := convertapi.UserInfo(conf) + + assert.NoError(t, err) + if assert.NotNil(t, user) { + assert.Equal(t, 250, user.ConversionsTotal) + assert.Equal(t, 7, user.ConversionsConsumed) + } +} + +func TestOfflineError(t *testing.T) { + fakeConfig(t, fakeAPI(t)) + _, err := convertapi.ConvDef("pptx", "pdf", param.NewString("file", "https://example.com/a.pptx")).Files() + + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "4013") + } +} + +func TestNewWithoutDefault(t *testing.T) { + config.Default = nil + conf := config.New("test-token", nil, nil) + + assert.Equal(t, "https://v2.convertapi.com", conf.BaseURL.String()) + assert.Equal(t, "test-token", conf.CaTransport.AuthCred) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d6e454d..f0b7ca4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,6 +5,8 @@ import ( "net/url" ) +const defaultBaseURL = "https://v2.convertapi.com" + var Default *Config type Config struct { @@ -14,17 +16,28 @@ type Config struct { } func NewDefault(authCred string) *Config { - baseUrl, _ := url.ParseRequestURI("https://v2.convertapi.com") + baseUrl, _ := url.ParseRequestURI(defaultBaseURL) transport := NewCaTransport(authCred, nil) client := &http.Client{Transport: transport} return &Config{baseUrl, transport, client} } +// New returns a configuration for another API domain (url) or HTTP transport (e.g. a proxy). A nil url +// keeps the default configuration's domain, and a nil transport uses http.DefaultTransport. func New(authCred string, url *url.URL, transport *http.Transport) *Config { if url == nil { - url = Default.BaseURL + if Default != nil { + url = Default.BaseURL + } else { + url = NewDefault(authCred).BaseURL + } } - caTransport := NewCaTransport(authCred, transport) - return &Config{BaseURL: url, HttpClient: &http.Client{Transport: caTransport}} + // A nil *http.Transport inside the RoundTripper interface is not a nil interface, so pass nil itself. + var roundTripper http.RoundTripper + if transport != nil { + roundTripper = transport + } + caTransport := NewCaTransport(authCred, roundTripper) + return &Config{BaseURL: url, CaTransport: caTransport, HttpClient: &http.Client{Transport: caTransport}} } diff --git a/pkg/config/transport.go b/pkg/config/transport.go index e64b15d..c55d3e0 100644 --- a/pkg/config/transport.go +++ b/pkg/config/transport.go @@ -19,8 +19,7 @@ func NewCaTransport(authCred string, roundTripper http.RoundTripper) *CaTranspor } func (this *CaTransport) RoundTrip(req *http.Request) (*http.Response, error) { - runtime.Version() - agent := fmt.Sprintf("ConvertAPI-Go/%d (%s)", Version, runtime.GOOS) + agent := fmt.Sprintf("ConvertAPI-Go/%s (%s)", Version, runtime.GOOS) req.Header.Add("User-Agent", agent) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", this.AuthCred)) return this.RoundTripper.RoundTrip(req) diff --git a/pkg/config/version.go b/pkg/config/version.go index 8888324..e5700f2 100644 --- a/pkg/config/version.go +++ b/pkg/config/version.go @@ -1,3 +1,5 @@ package config -const Version = 4 +// Version is the library version, sent in the User-Agent of every request. It is the version of the +// release tag (v{Version}), which release.yml checks; bump it in the pull request that prepares a release. +const Version = "1.0.0" diff --git a/pkg/param/file.go b/pkg/param/file.go index 5469b29..26109d4 100644 --- a/pkg/param/file.go +++ b/pkg/param/file.go @@ -14,8 +14,14 @@ type ParamFile struct { } func NewFile(name string, file *os.File, conf *config.Config) *ParamFile { - paramReader := NewReader(name, file, filepath.Base(file.Name()), conf) - return &ParamFile{*paramReader, file.Name()} + if conf == nil { + conf = config.Default + } + // Built in place: copying a ParamReader would copy its mutex. + return &ParamFile{ + ParamReader: ParamReader{Param: *New(name), reader: file, fileName: filepath.Base(file.Name()), config: conf}, + filePath: file.Name(), + } } func NewPath(name string, path string, conf *config.Config) IParam { From 756101d470cc447b571d8b3a704044996f905c5d Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 16:22:25 +0300 Subject: [PATCH 2/2] Release checks: SemVer precedence for the version floor, proxy origin from go mod download Copilot review: a pre-release config.Version could pass below an existing pre-release tag, and the .info URL did not case-encode uppercase pre-release tags. The floor now compares against every v* tag by SemVer precedence, and the origin commit comes from go mod download's own JSON. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 26 ++++++++++++++++++-------- .github/workflows/release.yml | 5 ++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39442f7..a856211 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,19 +116,29 @@ jobs: echo "::error file=pkg/config/version.go::config.Version $VERSION needs the module path github.com/ConvertAPI/convertapi-go/v${VERSION%%.*} (see RELEASING.md)" exit 1 fi - LATEST=$(git tag --list 'v*' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sed 's/^v//' | sort -V | tail -n1) - if [ -n "$LATEST" ] && [ "$(printf '%s\n%s\n' "$LATEST" "${VERSION%%-*}" | sort -V | tail -n1)" != "${VERSION%%-*}" ]; then - echo "::error file=pkg/config/version.go::config.Version $VERSION is lower than the latest release $LATEST" + # The highest v* tag by SemVer precedence, pre-releases included (sort -V does not implement it: + # 1.1.0-beta.2 < 1.1.0, and 1.1.0-beta.10 > 1.1.0-beta.2). + read -r LATEST BEHIND < <(git tag --list 'v*' | python3 -c ' + import re, sys + def key(v): + core, _, pre = v.partition("-") + ids = tuple((0, int(i), "") if i.isdigit() else (1, 0, i) for i in pre.split(".")) if pre else () + return tuple(map(int, core.split("."))), not pre, ids + ok = re.compile(r"^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$") + tags = [t[1:] for t in sys.stdin.read().split() if ok.match(t)] + latest = max(tags, key=key, default="") + print(latest or "none", "yes" if latest and key(sys.argv[1]) < key(latest) else "no") + ' "$VERSION") + if [ -z "${BEHIND:-}" ]; then + echo "::error::Could not compare config.Version with the v* tags" exit 1 - fi - # SemVer sorts 1.0.1-beta.1 below 1.0.1, so a pre-release of the latest release is a step back. - if [[ "$VERSION" == *-* ]] && [ "${VERSION%%-*}" = "$LATEST" ]; then - echo "::error file=pkg/config/version.go::config.Version $VERSION is a pre-release of $LATEST, which is already released" + elif [ "$BEHIND" = yes ]; then + echo "::error file=pkg/config/version.go::config.Version $VERSION is lower than the latest release $LATEST" exit 1 fi { echo "version=$VERSION" - echo "latest=${LATEST:-none}" + echo "latest=$LATEST" } >> "$GITHUB_OUTPUT" - name: Summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29aa83d..e888f54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,11 +167,10 @@ jobs: run: | set -uo pipefail cd "$(mktemp -d)" - # The proxy's URLs spell capital letters as !lowercase. - ESCAPED=$(sed 's/[A-Z]/!\L&/g' <<<"$MODULE") for _ in $(seq 1 10); do + # The Origin is the proxy's record of the commit it fetched the version from. if JSON=$(go mod download -json "$MODULE@$TAG"); then - ORIGIN=$(curl -fsS "$GOPROXY/$ESCAPED/@v/$TAG.info" | jq -r '.Origin.Hash // empty') || ORIGIN='' + ORIGIN=$(jq -r '.Origin.Hash // empty' <<<"$JSON") || ORIGIN='' if [ "$ORIGIN" = "$SHA" ]; then jq -r '"\(.Path) \(.Version) \(.Sum)"' <<<"$JSON" echo "The proxy serves $MODULE $TAG from $SHA, and sum.golang.org has its checksum"