diff --git a/.github/workflows/autoupdate.yaml b/.github/workflows/autoupdate.yaml new file mode 100644 index 0000000..4690a54 --- /dev/null +++ b/.github/workflows/autoupdate.yaml @@ -0,0 +1,20 @@ +name: Autoupdate go.mod and go.sum + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +permissions: + contents: write + actions: write + pull-requests: write + security-events: write + +jobs: + autoupdate: + uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main + with: + go-version: ">=1.25" + release-workflow: "release.yml" + secrets: inherit diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..4d23eff --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,61 @@ +name: checks + +# Repo-specific gates. Called by ci.yml (PR/push) and release.yml (release gate). +on: + workflow_call: + +permissions: + contents: read + +jobs: + examples: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '>=1.25' + check-latest: true + - name: Build all examples + run: go build ./examples/... + + codegen-clean: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '>=1.25' + check-latest: true + - name: Regenerate against pinned snapshot + run: make regen-from-fixture + - name: Assert clean diff + run: git diff --exit-code internal/spec/api.json api/ docs/reference/ + + audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # need history for drift comparison + - uses: actions/setup-go@v6 + with: + go-version: '>=1.25' + check-latest: true + - name: Audit fallbacks + run: make audit + - name: Audit drift vs base + # On PRs: compare against the merge base (origin/). + # On push to main: compare against the parent commit. + # Drift is informational; doesn't fail CI. + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="origin/${{ github.base_ref }}" + else + BASE="HEAD~1" + fi + echo "Drift base: $BASE" + go run ./cmd/audit -ir internal/spec/api.json -drift -against "$BASE" || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 849a70e..2b9466d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,16 @@ name: ci +# Repo-specific checks run through checks.yml. Generic vet, staticcheck, +# govulncheck, gosec and tests run in pr.yaml (shared go-pr). Releases run in +# release.yml (shared go-release), gated by the same checks.yml. on: push: branches: [main] pull_request: workflow_dispatch: - inputs: - dry-run-release: - description: "Compute release version, do not tag or release" - type: boolean - default: false permissions: - contents: write - pull-requests: read - packages: write + contents: read # Cancel any in-flight CI runs for the same branch when a new push lands. concurrency: @@ -22,163 +18,15 @@ concurrency: cancel-in-progress: true jobs: - vet: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - - run: go vet ./... + checks: + uses: ./.github/workflows/checks.yml - staticcheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - - run: go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 - - run: staticcheck ./... - - govulncheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - # Pinned, matching staticcheck above. govulncheck@latest is v1.8.0, - # which declares go 1.26.0 and therefore cannot be installed by the - # 1.25.x toolchain this workflow pins - the install step fails before - # any scanning happens. v1.7.0 declares go 1.25.0. Pinning the binary - # does not freeze the vulnerability database, which govulncheck - # fetches at run time. Re-pin deliberately when bumping go-version. - - run: go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 - - run: govulncheck ./... - - gosec: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - - run: go install github.com/securego/gosec/v2/cmd/gosec@latest - # G404: math/rand/v2 jitter in transport/backoff.go — intentional (not crypto) - # G304: os.ReadFile from CLI flag variable — intentional (tool, not server) - # G306: 0o644 on generated doc artifacts in cmd/scrape — intentional - # G204: git subprocess in cmd/audit uses CLI flag path — intentional (operator tool) - # G706: log.Printf with values from Telegram/env in examples — illustrative, - # library users are expected to sanitise before logging in production - - run: gosec -quiet -exclude=G404,G304,G306,G204,G706 -exclude-dir=testdata ./... - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - - run: go test -race -coverprofile=coverage.out ./... - - name: Build all examples - run: go build ./examples/... - - uses: actions/upload-artifact@v7 - with: - name: coverage - path: coverage.out - - codegen-clean: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - - name: Regenerate against pinned snapshot - run: make regen-from-fixture - - name: Assert clean diff - run: git diff --exit-code internal/spec/api.json api/ docs/reference/ - - audit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 # need history for drift comparison - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - - name: Audit fallbacks - run: make audit - - name: Audit drift vs base - # On PRs: compare against the merge base (origin/). - # On push to main: compare against the parent commit. - # Drift is informational; doesn't fail CI. - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE="origin/${{ github.base_ref }}" - else - BASE="HEAD~1" - fi - echo "Drift base: $BASE" - go run ./cmd/audit -ir internal/spec/api.json -drift -against "$BASE" || true - - # Aggregate gate — depends on every check above. Used as a single - # required status check in branch protection AND as a dependency for the - # release job below. + # Aggregate gate for the repo-local checks. Keeps the `ci-success` name + # so an existing branch-protection rule keeps resolving. ci-success: runs-on: ubuntu-latest - needs: [vet, staticcheck, govulncheck, gosec, test, codegen-clean, audit] + timeout-minutes: 5 + needs: [checks] if: always() steps: - name: All checks passed @@ -190,85 +38,3 @@ jobs: echo "Failed/cancelled jobs:" echo '${{ toJSON(needs) }}' exit 1 - - # Auto-release fires on every clean push to main (and on manual - # workflow_dispatch for testing). Computes next SemVer from commit - # history via lukaszraczylo/semver-generator, dual-tags - # (v + bot-api-v), runs GoReleaser. - release: - needs: ci-success - if: | - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - check-latest: true - - # Action interface from - # https://github.com/lukaszraczylo/semver-generator/blob/main/action.yml - # Inputs: repository_local: true (use already-cloned repo) - # existing: true (respect existing tags as base) - # Output: semantic_version (bare version string, no "v" prefix) - - name: Compute next SemVer - id: semver - uses: lukaszraczylo/semver-generator@v1 - with: - repository_local: true - existing: true - config_file: .semver.yaml - - - name: Read Bot API version - id: api_version - run: | - VERSION=$(python3 -c 'import json; print(json.load(open("internal/spec/api.json"))["version"])') - if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then - echo "tag=" >> "$GITHUB_OUTPUT" - else - echo "tag=bot-api-v${VERSION}" >> "$GITHUB_OUTPUT" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Dry-run summary - if: github.event_name == 'workflow_dispatch' && inputs.dry-run-release == true - run: | - echo "Would release: v${{ steps.semver.outputs.semantic_version }}" - if [ -n "${{ steps.api_version.outputs.tag }}" ]; then - echo "Would also tag: ${{ steps.api_version.outputs.tag }} (Bot API ${{ steps.api_version.outputs.version }})" - fi - echo "Skipping tag + release (dry-run)." - - - name: Tag library + bot-api versions - if: github.event_name != 'workflow_dispatch' || inputs.dry-run-release == false - env: - LIB_TAG: v${{ steps.semver.outputs.semantic_version }} - API_TAG: ${{ steps.api_version.outputs.tag }} - API_VER: ${{ steps.api_version.outputs.version }} - run: | - # Bot API version (currently $API_VER) is intentionally NOT - # tagged separately. semver-generator picks the most recent - # tag as the version base; a non-SemVer marker like - # bot-api-vX.Y poisons that and restarts numbering from - # v0.0.x. Bot API version stays as a comment in the lib tag - # message and in the release notes. - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "$LIB_TAG" -m "Release $LIB_TAG (Bot API $API_VER)" - git push origin "$LIB_TAG" - - - name: Run GoReleaser - if: github.event_name != 'workflow_dispatch' || inputs.dry-run-release == false - uses: goreleaser/goreleaser-action@v7 - with: - distribution: goreleaser - version: '~> v2' - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BOT_API_VERSION: ${{ steps.api_version.outputs.version }} diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..383961f --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,22 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + push: + branches: + - "**" + - "!main" + +permissions: + contents: write + actions: write + pull-requests: write + security-events: write + +jobs: + pr-checks: + uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main + with: + go-version: ">=1.25" diff --git a/.github/workflows/regen.yml b/.github/workflows/regen.yml index 2672fe1..cc378a1 100644 --- a/.github/workflows/regen.yml +++ b/.github/workflows/regen.yml @@ -128,11 +128,11 @@ jobs: `make regen` (which fails on any audit fallback) and `go test -race ./...`, both run in this job before the PR opens. - # A PR opened with GITHUB_TOKEN does not trigger `ci.yml`, so waiting for + # A PR opened with GITHUB_TOKEN does not trigger `ci.yml` or `pr.yaml`, so waiting for # PR checks would wait forever. The gates already ran in this job: `make # regen` fails on any audit fallback, and the test step runs the full race # suite. Merging here therefore means "merged because the checks passed". - # `ci.yml` then runs on the push to main and cuts the release. + # `release.yml` then runs on the push to main and cuts the release. - name: Merge regen PR if: steps.diff.outputs.no_changes != 'true' && steps.pr.outputs.pull-request-number != '' env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7130ed7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + branches: + - main + paths: + - "**.go" + - "go.mod" + - "go.sum" + workflow_dispatch: + +permissions: + contents: write + packages: write + id-token: write + +jobs: + checks: + uses: ./.github/workflows/checks.yml + + release: + needs: checks + uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main + with: + go-version: ">=1.25" + semver-config: ".semver.yaml" + secrets: inherit diff --git a/workflow-prepare.sh b/workflow-prepare.sh new file mode 100755 index 0000000..82056e9 --- /dev/null +++ b/workflow-prepare.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Exports BOT_API_VERSION for the .goreleaser.yaml release header. +set -euo pipefail + +[ -n "${GITHUB_ENV:-}" ] || exit 0 + +ver=$(python3 -c 'import json; print(json.load(open("internal/spec/api.json"))["version"])') +if [ -z "$ver" ] || [ "$ver" = "null" ]; then + exit 0 +fi +echo "BOT_API_VERSION=${ver}" >> "$GITHUB_ENV"