diff --git a/.gitea/workflows/go.yaml b/.gitea/workflows/go.yaml new file mode 100644 index 0000000..6b3f8f8 --- /dev/null +++ b/.gitea/workflows/go.yaml @@ -0,0 +1,190 @@ +# Shared Go CI: run the standard Go gates in the official golang:-bookworm image. +# +# any push -> go mod verify, gofmt, go vet, golangci-lint (optional), go test, commands +# tag v* -> + release-commands, then the artifacts files and a SHA256SUMS on the tag's Gitea release +# +# The toolchain is the image's: GOTOOLCHAIN=local, so a go.mod that needs a newer Go fails instead of +# downloading one. golangci-lint is a pinned, checksum-verified release binary and reads the repository's +# own .golangci.yml. +name: go + +on: + workflow_call: + inputs: + go-version: + description: Go version; selects the golang:-bookworm image + type: string + default: '1.26' + runner: + type: string + default: container-build + working-directory: + description: Directory with go.mod; every step runs there + type: string + default: '.' + gofmt: + description: Fail when any tracked .go file is not gofmt-clean + type: boolean + default: true + golangci-lint: + description: Run the pinned golangci-lint (v2 configuration) + type: boolean + default: false + race: + description: Run the tests with the race detector + type: boolean + default: false + test-args: + description: Extra go test arguments (for example -coverprofile=cover.out) + type: string + default: '' + commands: + description: Shell commands, one per line, run after the tests (for example a coverage gate) + type: string + default: '' + release-commands: + description: Shell commands, one per line, run on v* tags after every gate passed (build the binaries) + type: string + default: '' + artifacts: + description: Files or globs, one per line, attached with a SHA256SUMS to the Gitea release on v* tags; empty = no release + type: string + default: '' + +permissions: + contents: write + +env: + GOLANGCI_LINT_VERSION: 2.15.0 + GOLANGCI_LINT_SHA256: 9b7fe6a6bf6334b98decf8d86b11415ea7889afdabdd1959fdce23af2a9ebcea + +jobs: + go: + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + container: + image: golang:${{ inputs.go-version }}-bookworm + volumes: + - /var/lib/gitea-job-workspace:${{ github.workspace }} + defaults: + run: + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + HOME: /tmp + CI: 'true' + GOPATH: /tmp/go + GOCACHE: /tmp/go-build + GOTOOLCHAIN: local + steps: + - uses: builtin:checkout + + - name: Set up + run: | + set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" + go version + go mod download + go mod verify + + - name: gofmt + if: inputs.gofmt + run: | + set -euo pipefail + unformatted=$(git ls-files -z -- '*.go' | xargs -0 -r gofmt -l) + if [[ -n $unformatted ]]; then echo "Not gofmt-clean (run gofmt -w):"; echo "$unformatted"; exit 1; fi + + - name: go vet + run: go vet ./... + + - name: golangci-lint + if: inputs.golangci-lint + run: | + set -euo pipefail + name=golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64 + ( + mkdir -p /tmp/tools && cd /tmp/tools + curl -fsSLo lint.tgz "https://github.com/golangci/golangci-lint/releases/download/v$GOLANGCI_LINT_VERSION/$name.tar.gz" + echo "$GOLANGCI_LINT_SHA256 lint.tgz" | sha256sum -c - + tar -xzf lint.tgz --strip-components=1 "$name/golangci-lint" && rm lint.tgz + ) + /tmp/tools/golangci-lint version + /tmp/tools/golangci-lint run ./... + + - name: go test + env: + RACE: ${{ inputs.race }} + TEST_ARGS: ${{ inputs.test-args }} + run: | + set -euo pipefail + args=() + [[ $RACE == true ]] && args+=(-race) + # Word splitting is intended: TEST_ARGS is a list of go test flags. + # shellcheck disable=SC2206 + args+=($TEST_ARGS) + go test "${args[@]}" ./... + + - name: Run commands + if: inputs.commands != '' + env: + COMMANDS: ${{ inputs.commands }} + run: | + printf '%s\n' "$COMMANDS" > /tmp/commands.sh + bash -exo pipefail /tmp/commands.sh + + - name: Release commands + if: inputs.release-commands != '' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + env: + COMMANDS: ${{ inputs.release-commands }} + run: | + printf '%s\n' "$COMMANDS" > /tmp/release-commands.sh + bash -exo pipefail /tmp/release-commands.sh + + - name: Release + if: inputs.artifacts != '' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + env: + ARTIFACTS: ${{ inputs.artifacts }} + # The job token creates this repository's release (contents: write); no secret needed. + TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + files=() + while IFS= read -r pattern; do + [[ -z ${pattern// } ]] && continue + # Glob expansion is intended. + # shellcheck disable=SC2206 + matches=($pattern) + [[ -f ${matches[0]} ]] || { echo "No file matches $pattern"; exit 1; } + files+=("${matches[@]}") + done <<< "$ARTIFACTS" + sums=/tmp/SHA256SUMS + for f in "${files[@]}"; do (cd "$(dirname "$f")" && sha256sum "$(basename "$f")"); done > "$sums" + cat "$sums" + + api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" + auth=(-H "Authorization: token $TOKEN") + rel=/tmp/release.json + code=$(curl -sS -o "$rel" -w '%{http_code}' "${auth[@]}" "$api/releases/tags/$TAG") + if [[ $code == 404 ]]; then + prerelease=false; [[ $TAG == *-* ]] && prerelease=true + # Another job may create the same release concurrently: fall back to reading it. + curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -o "$rel" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$prerelease}" "$api/releases" \ + || curl -fsS "${auth[@]}" -o "$rel" "$api/releases/tags/$TAG" + elif [[ $code != 200 ]]; then + echo "release lookup for $TAG returned HTTP $code"; exit 1 + fi + # Gitea serialises a release as {"id":N,...,"assets":[{"id":N,"name":"..."},...]}. + release=$(grep -o '^{"id":[0-9]*' "$rel" | cut -d: -f2) + [[ -n $release ]] || { echo "cannot read the release id"; cat "$rel"; exit 1; } + upload() { + # Re-runs replace an asset of the same name instead of failing. + { grep -o "\"id\":[0-9]*,\"name\":\"${2//./\\.}\"" "$rel" || true; } | cut -d, -f1 | cut -d: -f2 | while read -r old; do + curl -fsS -X DELETE "${auth[@]}" "$api/releases/$release/assets/$old" + done + curl -fsS -o /dev/null "${auth[@]}" -F "attachment=@$1" "$api/releases/$release/assets?name=$2" + echo "attached $2 to release $TAG" + } + for f in "${files[@]}"; do upload "$f" "$(basename "$f")"; done + upload "$sums" SHA256SUMS diff --git a/.gitea/workflows/gradle.yaml b/.gitea/workflows/gradle.yaml new file mode 100644 index 0000000..6287959 --- /dev/null +++ b/.gitea/workflows/gradle.yaml @@ -0,0 +1,147 @@ +# Shared Gradle CI: run the repository's Gradle wrapper in the official gradle:jdk image (Ubuntu, +# Eclipse Temurin, git); the image's own Gradle is not used. +# +# any push -> verify the wrapper, commands, then each Gradle line in tasks +# tag v* -> + the artifacts files and a SHA256SUMS on the tag's Gitea release (for example plugin zips) +# +# Wrapper check: gradle-wrapper.jar must be the official jar of the distributionUrl version, the +# distribution must come from services.gradle.org, and distributionSha256Sum must be set to the official +# checksum (Gradle then verifies the download itself). Fix a failure with +# ./gradlew wrapper --gradle-version --gradle-distribution-sha256-sum +name: gradle + +on: + workflow_call: + inputs: + tasks: + description: Gradle invocations, one per line (arguments to ./gradlew); every line must pass + type: string + default: build + java-version: + description: JDK major version; selects the gradle:jdk image (17, 21, 25) + type: string + default: '21' + runner: + type: string + default: container-build + commands: + description: Shell commands, one per line, run before Gradle + type: string + default: '' + artifacts: + description: Files or globs, one per line, attached with a SHA256SUMS to the Gitea release on v* tags; empty = no release + type: string + default: '' + +permissions: + contents: write + +jobs: + gradle: + runs-on: ${{ inputs.runner }} + timeout-minutes: 60 + container: + image: gradle:jdk${{ inputs.java-version }} + volumes: + - /var/lib/gitea-job-workspace:${{ github.workspace }} + defaults: + run: + shell: bash + env: + HOME: /tmp + CI: 'true' + GRADLE_USER_HOME: /tmp/.gradle + GRADLE_OPTS: -Dorg.gradle.daemon=false + steps: + - uses: builtin:checkout + + - name: Verify the Gradle wrapper + run: | + set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" + java -version + props=gradle/wrapper/gradle-wrapper.properties + [[ -x ./gradlew && -f gradle/wrapper/gradle-wrapper.jar && -f $props ]] || { echo "./gradlew, gradle-wrapper.jar or $props is missing (or gradlew is not executable)"; exit 1; } + url=$(sed -n 's/^distributionUrl=//p' "$props" | tr -d '\r' | sed 's/\\:/:/g') + re='^https://services\.gradle\.org/distributions/gradle-([0-9][0-9A-Za-z.-]*)-(bin|all)\.zip$' + [[ $url =~ $re ]] || { echo "distributionUrl must be an official services.gradle.org distribution: $url"; exit 1; } + version=${BASH_REMATCH[1]} + official=https://services.gradle.org/distributions + jar=$(curl -fsSL "$official/gradle-$version-wrapper.jar.sha256") + echo "$jar gradle/wrapper/gradle-wrapper.jar" | sha256sum -c - \ + || { echo "gradle-wrapper.jar is not the official Gradle $version wrapper jar; run ./gradlew wrapper"; exit 1; } + dist=$(curl -fsSL "${url}.sha256") + pinned=$(sed -n 's/^distributionSha256Sum=//p' "$props" | tr -d '\r') + [[ $pinned == "$dist" ]] || { echo "distributionSha256Sum must be the official checksum $dist (found '${pinned}')"; exit 1; } + echo "Gradle wrapper $version verified" + + - name: Run commands + if: inputs.commands != '' + env: + COMMANDS: ${{ inputs.commands }} + run: | + printf '%s\n' "$COMMANDS" > /tmp/commands.sh + bash -exo pipefail /tmp/commands.sh + + - name: Gradle + env: + TASKS: ${{ inputs.tasks }} + run: | + set -euo pipefail + while IFS= read -r line; do + [[ -z ${line// } ]] && continue + echo "::group::./gradlew $line" + # Word splitting is intended: each line is a list of Gradle arguments. + # shellcheck disable=SC2086 + ./gradlew --console=plain $line + echo "::endgroup::" + done <<< "$TASKS" + + - name: Release + if: inputs.artifacts != '' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + env: + ARTIFACTS: ${{ inputs.artifacts }} + # The job token creates this repository's release (contents: write); no secret needed. + TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + files=() + while IFS= read -r pattern; do + [[ -z ${pattern// } ]] && continue + # Glob expansion is intended. + # shellcheck disable=SC2206 + matches=($pattern) + [[ -f ${matches[0]} ]] || { echo "No file matches $pattern"; exit 1; } + files+=("${matches[@]}") + done <<< "$ARTIFACTS" + sums=/tmp/SHA256SUMS + for f in "${files[@]}"; do (cd "$(dirname "$f")" && sha256sum "$(basename "$f")"); done > "$sums" + cat "$sums" + + api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" + auth=(-H "Authorization: token $TOKEN") + rel=/tmp/release.json + code=$(curl -sS -o "$rel" -w '%{http_code}' "${auth[@]}" "$api/releases/tags/$TAG") + if [[ $code == 404 ]]; then + prerelease=false; [[ $TAG == *-* ]] && prerelease=true + # Another job may create the same release concurrently: fall back to reading it. + curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -o "$rel" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$prerelease}" "$api/releases" \ + || curl -fsS "${auth[@]}" -o "$rel" "$api/releases/tags/$TAG" + elif [[ $code != 200 ]]; then + echo "release lookup for $TAG returned HTTP $code"; exit 1 + fi + # Gitea serialises a release as {"id":N,...,"assets":[{"id":N,"name":"..."},...]}. + release=$(grep -o '^{"id":[0-9]*' "$rel" | cut -d: -f2) + [[ -n $release ]] || { echo "cannot read the release id"; cat "$rel"; exit 1; } + upload() { + # Re-runs replace an asset of the same name instead of failing. + { grep -o "\"id\":[0-9]*,\"name\":\"${2//./\\.}\"" "$rel" || true; } | cut -d, -f1 | cut -d: -f2 | while read -r old; do + curl -fsS -X DELETE "${auth[@]}" "$api/releases/$release/assets/$old" + done + curl -fsS -o /dev/null "${auth[@]}" -F "attachment=@$1" "$api/releases/$release/assets?name=$2" + echo "attached $2 to release $TAG" + } + for f in "${files[@]}"; do upload "$f" "$(basename "$f")"; done + upload "$sums" SHA256SUMS diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml new file mode 100644 index 0000000..e99650f --- /dev/null +++ b/.gitea/workflows/lint.yaml @@ -0,0 +1,256 @@ +# Shared repository hygiene checks. Every check is off until its input is set; each tool is pinned and +# checksum- or digest-verified, and reads the repository's own configuration (.shellcheckrc, .yamllint, +# ruff.toml, .markdownlint-cli2.*, .ansible-lint). +# +# File inputs are git pathspec globs, one per line, matched against tracked files: `*.sh` is the top +# level only, `**/*.sh` any depth, `ops/**/*.sh` under ops/. A glob that matches nothing fails the check. +# `exclude` globs are removed from every file check. git-crypt files (filter=git-crypt) are ciphertext +# in CI and always skipped. markdownlint takes markdownlint-cli2 globs instead (exclude becomes #glob). +name: lint + +on: + workflow_call: + inputs: + runner: + type: string + default: container-build + exclude: + description: Globs, one per line, removed from every file check (vendored or generated files) + type: string + default: '' + shellcheck: + description: Shell script globs for shellcheck + type: string + default: '' + yamllint: + description: YAML globs for yamllint + type: string + default: '' + json: + description: JSON globs that must parse (python -m json.tool) + type: string + default: '' + ruff: + description: Python globs for ruff check + type: string + default: '' + tofu-fmt: + description: OpenTofu/Terraform globs (*.tf, *.tfvars) for tofu fmt -check + type: string + default: '' + tofu-validate: + description: Root module directories, one per line, for tofu init -backend=false and tofu validate + type: string + default: '' + markdownlint: + description: Markdown globs for markdownlint-cli2 (its own glob syntax; config ignores apply) + type: string + default: '' + ansible-lint: + description: Arguments to ansible-lint (paths or options; '.' lints the repository); empty = off + type: string + default: '' + commands: + description: Extra shell commands, one per line, run after the checks in the lint image (Node, Python 3, git) + type: string + default: '' + +permissions: + contents: read + +env: + SHELLCHECK_VERSION: v0.11.0 + SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 + TOFU_VERSION: 1.13.2 + TOFU_SHA256: ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645 + RUFF_VERSION: 0.17.1 + RUFF_SHA256: a293ac937715ae6fb6b64da86761598379e6149f40fbc00fde9ba8a2a562ac65 + UV_VERSION: 0.13.6 + MARKDOWNLINT_CLI2_VERSION: 0.24.0 + MARKDOWNLINT_CLI2_BEFORE: '2026-10-22' + UV_SHA256: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + +jobs: + lint: + if: inputs.shellcheck != '' || inputs.yamllint != '' || inputs.json != '' || inputs.ruff != '' || inputs.tofu-fmt != '' || inputs.tofu-validate != '' || inputs.markdownlint != '' || inputs.commands != '' + runs-on: ${{ inputs.runner }} + timeout-minutes: 20 + container: + image: node:22-bookworm + volumes: + - /var/lib/gitea-job-workspace:${{ github.workspace }} + defaults: + run: + shell: bash + env: + HOME: /tmp + CI: 'true' + EXCLUDE: ${{ inputs.exclude }} + steps: + - uses: builtin:checkout + + - name: Set up + run: | + set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" + mkdir -p /tmp/tools + echo /tmp/tools >> "$GITHUB_PATH" + # pick-files GLOBS OUT: the tracked files matching GLOBS, minus EXCLUDE and git-crypt files, NUL-separated. + cat > /tmp/tools/pick-files <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + specs=() + while IFS= read -r p; do [[ -n ${p// } ]] && specs+=(":(glob)$p"); done <<< "$1" + while IFS= read -r p; do [[ -n ${p// } ]] && specs+=(":(glob,exclude)$p"); done <<< "${EXCLUDE:-}" + git ls-files -z -- "${specs[@]}" | git check-attr -z --stdin filter | + while IFS= read -r -d '' f && IFS= read -r -d '' _ && IFS= read -r -d '' v; do + [[ $v == git-crypt ]] || printf '%s\0' "$f" + done > "$2" + [[ -s $2 ]] || { echo "No tracked file matches: ${1//$'\n'/ }"; exit 1; } + echo "$(tr -cd '\0' < "$2" | wc -c) files" + EOF + chmod +x /tmp/tools/pick-files + + - name: shellcheck + if: inputs.shellcheck != '' + env: + GLOBS: ${{ inputs.shellcheck }} + run: | + set -euo pipefail + curl -fsSLo /tmp/sc.txz "https://github.com/koalaman/shellcheck/releases/download/$SHELLCHECK_VERSION/shellcheck-$SHELLCHECK_VERSION.linux.x86_64.tar.xz" + echo "$SHELLCHECK_SHA256 /tmp/sc.txz" | sha256sum -c - + tar -xJf /tmp/sc.txz -C /tmp/tools --strip-components=1 "shellcheck-$SHELLCHECK_VERSION/shellcheck" + shellcheck --version | sed -n 2p + pick-files "$GLOBS" /tmp/files + xargs -0 shellcheck < /tmp/files + + - name: yamllint + if: inputs.yamllint != '' + env: + GLOBS: ${{ inputs.yamllint }} + run: | + set -euo pipefail + curl -fsSLo /tmp/uv.tgz "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" + echo "$UV_SHA256 /tmp/uv.tgz" | sha256sum -c - + tar -xzf /tmp/uv.tgz -C /tmp/tools --strip-components=1 + # Hash-locked for the image's Python 3.11. + cat > /tmp/yamllint.txt <<'EOF' + yamllint==1.38.0 --hash=sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220 + pathspec==1.1.1 --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + pyyaml==6.0.3 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d + EOF + uv venv -q --python /usr/bin/python3 /tmp/yamllint + uv pip install -q --python /tmp/yamllint/bin/python --require-hashes --no-deps --only-binary :all: -r /tmp/yamllint.txt + /tmp/yamllint/bin/yamllint --version + pick-files "$GLOBS" /tmp/files + xargs -0 /tmp/yamllint/bin/yamllint < /tmp/files + + - name: JSON + if: inputs.json != '' + env: + GLOBS: ${{ inputs.json }} + run: | + set -euo pipefail + pick-files "$GLOBS" /tmp/files + status=0 + while IFS= read -r -d '' f; do + python3 -m json.tool "$f" > /dev/null || { echo "invalid JSON: $f"; status=1; } + done < /tmp/files + exit $status + + - name: ruff + if: inputs.ruff != '' + env: + GLOBS: ${{ inputs.ruff }} + run: | + set -euo pipefail + curl -fsSLo /tmp/ruff.tgz "https://github.com/astral-sh/ruff/releases/download/$RUFF_VERSION/ruff-x86_64-unknown-linux-gnu.tar.gz" + echo "$RUFF_SHA256 /tmp/ruff.tgz" | sha256sum -c - + tar -xzf /tmp/ruff.tgz -C /tmp/tools --strip-components=1 + ruff --version + pick-files "$GLOBS" /tmp/files + xargs -0 ruff check --no-cache < /tmp/files + + - name: Install OpenTofu + if: inputs.tofu-fmt != '' || inputs.tofu-validate != '' + run: | + set -euo pipefail + curl -fsSLo /tmp/tofu.tgz "https://github.com/opentofu/opentofu/releases/download/v$TOFU_VERSION/tofu_${TOFU_VERSION}_linux_amd64.tar.gz" + echo "$TOFU_SHA256 /tmp/tofu.tgz" | sha256sum -c - + tar -xzf /tmp/tofu.tgz -C /tmp/tools tofu + tofu version + + - name: tofu fmt + if: inputs.tofu-fmt != '' + env: + GLOBS: ${{ inputs.tofu-fmt }} + run: | + set -euo pipefail + pick-files "$GLOBS" /tmp/files + xargs -0 tofu fmt -check -diff < /tmp/files + + - name: tofu validate + if: inputs.tofu-validate != '' + env: + DIRS: ${{ inputs.tofu-validate }} + TF_PLUGIN_CACHE_DIR: /tmp/tofu-plugins + TF_IN_AUTOMATION: 'true' + run: | + set -euo pipefail + mkdir -p "$TF_PLUGIN_CACHE_DIR" + while IFS= read -r dir; do + [[ -z ${dir// } ]] && continue + echo "::group::tofu validate $dir" + tofu -chdir="$dir" init -backend=false -input=false -no-color > /tmp/init.log || { cat /tmp/init.log; exit 1; } + tofu -chdir="$dir" validate -no-color + echo "::endgroup::" + done <<< "$DIRS" + + - name: markdownlint + if: inputs.markdownlint != '' + env: + GLOBS: ${{ inputs.markdownlint }} + run: | + set -euo pipefail + # Exact version; --before freezes the dependency tree to its release day; npm checks every + # package's integrity hash; no install scripts run. + npm install --prefix /tmp/mdl --no-save --no-audit --no-fund --ignore-scripts \ + --before "$MARKDOWNLINT_CLI2_BEFORE" "markdownlint-cli2@$MARKDOWNLINT_CLI2_VERSION" > /dev/null + args=() + while IFS= read -r p; do [[ -n ${p// } ]] && args+=("$p"); done <<< "$GLOBS" + while IFS= read -r p; do [[ -n ${p// } ]] && args+=("#$p"); done <<< "$EXCLUDE" + /tmp/mdl/node_modules/.bin/markdownlint-cli2 "${args[@]}" + + - name: Run commands + if: inputs.commands != '' + env: + COMMANDS: ${{ inputs.commands }} + run: | + printf '%s\n' "$COMMANDS" > /tmp/commands.sh + bash -exo pipefail /tmp/commands.sh + + ansible-lint: + if: inputs.ansible-lint != '' + runs-on: ${{ inputs.runner }} + timeout-minutes: 20 + container: + image: ghcr.io/ansible/community-ansible-dev-tools:v26.10.1@sha256:13c96bd005f156fb48ac9913545f3b56b265ba20824b0be7ef33fba1125c6b1d + volumes: + - /var/lib/gitea-job-workspace:${{ github.workspace }} + env: + HOME: /tmp + ARGS: ${{ inputs.ansible-lint }} + steps: + - uses: builtin:checkout + + - name: ansible-lint + shell: bash + run: | + set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" + # Ansible ignores ./ansible.cfg in a world-writable directory; name it explicitly. + if [[ -f ansible.cfg ]]; then export ANSIBLE_CONFIG="$PWD/ansible.cfg"; fi + ansible-lint --version + # Word splitting is intended: ARGS is a list of ansible-lint arguments. + # shellcheck disable=SC2086 + ansible-lint $ARGS diff --git a/README.md b/README.md index 1369cde..fc9ef0c 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,110 @@ jobs: session, so `xcodebuild test` cannot reach `testmanagerd` (SwiftPM `swift test` works), and the host has no `git-lfs`. +## Go + +`.gitea/workflows/build.yaml` (see `KofTwentyTwo/license-tool`): + +```yaml +name: build +on: + push: + branches: ['**'] + tags: ['v*'] +jobs: + go: + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/go.yaml@v1 + with: + golangci-lint: true + race: true + release-commands: scripts/build-release.sh "${GITHUB_REF_NAME#v}" + artifacts: dist/* +``` + +Every push runs `go mod verify`, `gofmt -l` (tracked files), `go vet ./...`, +optionally golangci-lint, and `go test ./...`, in `golang:-bookworm` +with `GOTOOLCHAIN=local` (a `go.mod` that needs a newer Go fails, nothing is downloaded). + +| Input | Default | Meaning | +| --- | --- | --- | +| `go-version` | `'1.26'` | Selects the official `golang:-bookworm` image | +| `gofmt` | `true` | Fail when a tracked `.go` file is not gofmt-clean | +| `golangci-lint` | `false` | Pinned, sha256-verified golangci-lint v2 with the repository's `.golangci.yml` | +| `race` | `false` | `go test -race` | +| `test-args` | `''` | Extra `go test` flags (for example a coverage profile) | +| `commands` | `''` | Extra gates after the tests, one per line (for example a coverage threshold) | +| `release-commands` | `''` | Run on `v*` tags after every gate (cross-compile into `dist/`) | +| `artifacts` | `''` | Files or globs; on `v*` tags they and a `SHA256SUMS` go on the tag's Gitea release (job token; re-runs replace same-named assets) | +| `working-directory` | `.` | Directory with `go.mod` | +| `runner` | `container-build` | Runner label | + +## Gradle + +```yaml +jobs: + gradle: + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/gradle.yaml@v1 + with: + java-version: '17' + tasks: build + artifacts: build/distributions/*.zip +``` + +Runs `./gradlew` in `gradle:jdk` (the image's own Gradle is unused). +Before Gradle runs, the wrapper is verified: `distributionUrl` must be a +`services.gradle.org` distribution, `gradle-wrapper.jar` must be that version's +official jar, and `distributionSha256Sum` must be set to the official checksum so +Gradle verifies the download. Fix a failure with +`./gradlew wrapper --gradle-version --gradle-distribution-sha256-sum `. + +| Input | Default | Meaning | +| --- | --- | --- | +| `tasks` | `build` | Gradle invocations, one per line | +| `java-version` | `'21'` | JDK major version (17, 21, 25) | +| `commands` | `''` | Shell commands run before Gradle | +| `artifacts` | `''` | Files or globs; on `v*` tags they and a `SHA256SUMS` go on the tag's Gitea release | +| `runner` | `container-build` | Runner label | + +Android projects use `android.yaml`. + +## Lint + +Repository hygiene for scripts, configuration and infrastructure code. Every +check is off until its input is set. + +```yaml +jobs: + lint: + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/lint.yaml@v1 + with: + shellcheck: 'ops/**/*.sh' + tofu-fmt: '**/*.tf' + # vendor/: third-party scripts copied verbatim, not ours to restyle + exclude: | + vendor/** +``` + +| Input | Check | +| --- | --- | +| `shellcheck` | shellcheck (`.shellcheckrc` applies) | +| `yamllint` | yamllint (`.yamllint*` applies) | +| `json` | every file parses (`python3 -m json.tool`) | +| `ruff` | `ruff check` (`ruff.toml` / `pyproject.toml` applies) | +| `tofu-fmt` | `tofu fmt -check -diff` | +| `tofu-validate` | directories, one per line: `tofu init -backend=false` and `tofu validate` (no state, no secrets) | +| `markdownlint` | markdownlint-cli2 globs (`.markdownlint-cli2.*` applies) | +| `ansible-lint` | arguments to ansible-lint (`.` = the repository; `.ansible-lint` applies), in its own job | +| `commands` | extra shell commands in the lint image (Node 22, Python 3, git, curl) | +| `exclude` | globs removed from every file check | + +File inputs are git pathspec globs over tracked files (`*.sh` top level only, +`**/*.sh` any depth); a glob that matches nothing fails. git-crypt files are +ciphertext in CI and always skipped. Tools are pinned: shellcheck, OpenTofu, ruff +and uv by sha256; yamllint hash-locked; ansible-lint by image digest; +markdownlint-cli2 by exact version with its dependency tree frozen to the release +day (`npm --before`, integrity-checked). Exclude only vendored or generated files, +with a comment saying why; fix findings in your own files. + ## Releasing | To publish | Do this |