diff --git a/.ghokin.yml b/.ghokin.yml index 3ea6fcc..613bce8 100644 --- a/.ghokin.yml +++ b/.ghokin.yml @@ -1,5 +1,3 @@ indent: 2 aliases: json: "jq ." -exclude: - - "ghokin/fixtures/*.input.feature" diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 65530f4..87cab9e 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,8 +1,28 @@ +# GENERATED FILE - DO NOT EDIT +# Synced from the actionlint_runner_labels local in +# PaddleHQ/terraform-github by the sync-actionlint-runners workflow. self-hosted-runner: labels: + - airflow-2-core + - airflow-4-core + - dbt-2-core + - dbt-4-core + - e2e-runner-16 + - e2e-runner-2 + - e2e-runner-32 + - e2e-runner-4 + - e2e-runner-8 + - generic-2-core + - go-16-core + - go-2-core + - go-32-core - go-4-core - - go-4-core-latest + - go-4-core-amd64 + - go-64-core - go-8-core - - go-8-core-latest - - go-16-core - - go-16-core-latest + - permifrost-2-core + - permifrost-4-core + - terraform-16-core + - terraform-2-core + - terraform-4-core + - terraform-8-core diff --git a/.github/actions/README.md b/.github/actions/README.md new file mode 100644 index 0000000..2eafab0 --- /dev/null +++ b/.github/actions/README.md @@ -0,0 +1,54 @@ +# Composite actions + +Shared workflow building blocks. Listed here with a one-line purpose and, for actions that own a cache, the contract that lets you avoid layering parallel caches on top. + +| Action | Purpose | +|---|---| +| [`setup-go`](./setup-go/action.yml) | Install Go (via `actions/setup-go`), authenticate to private PaddleHQ modules, run `go mod download all`. **Owns the Go deps cache** (see below). | +| [`setup-databases`](./setup-databases/action.yml) | Start Postgres (+ MySQL on opt-in), create the `runner` role and `testdatabase`, export `TESTAMENT_POSTGRES_DSN`. **Restores the Postgres template cache** (see below). | +| [`snapshot-postgres-templates`](./snapshot-postgres-templates/action.yml) | Tar the PG data dir at job end so `actions/cache`'s post-step uploads it. **Saves the Postgres template cache** (the other half of the lifecycle owned by `setup-databases`). | +| [`lint-scope`](./lint-scope/action.yml) | Decide whether `golangci-lint` should run, and if so against which packages. Outputs `skip`, `pkgs`, `new-from-rev`. Used by `go-lint.yml` (main) and `go-lint-experimental.yml` (different `config-glob` input, same logic). | +| [`otel-export`](./otel-export/action.yml) | Export the workflow trace to Honeycomb at job end. | +| [`resolve-generated-paths`](./resolve-generated-paths/action.yml) | Expand the `generated-paths` patterns into a concrete list (used by coverage filtering). | +| [`trigger-automerge`](./trigger-automerge/action.yml) | Kick the automerge workflow once required checks pass. | +| [`automerge-skipped-comment`](./automerge-skipped-comment/action.yml) | Comment on a PR when automerge declined to run. | + +## Cache topology — one owner per artefact + +Two caches are managed by composite actions in this repo. **Do not add parallel `actions/cache` steps targeting these paths in any workflow** — they collide on tar extract (`Cannot open: File exists`), `actions/cache` marks the restore as failed, and the post-step saves a fresh ~1.2 GB cache on every run for nothing. This bug has been fixed four times already (lint #483, validate #485, test.yml's `Cache test results` (#487), and inside `actions/setup-go` itself via `cache: false` (#487)). + +### Go modules + build cache + +| | | +|---|---| +| Paths | `~/go/pkg/mod`, `~/.cache/go-build` | +| Key | `setup-go-${{ runner.os }}-${{ runner.arch }}-go-${{ go-version }}[-]-${{ hashFiles('**/go.sum') }}` | +| Owner | [`setup-go`](./setup-go/action.yml) — explicit `actions/cache@v5.0.5`; `actions/setup-go`'s built-in cache is disabled (it 409s on every primary-key hit) | +| Used by | every workflow that needs Go — lint, validate, build, fuzz (default key); test (`cache-suffix: cover`); race and test-combined (`cache-suffix: race`) | + +`setup-go` takes a `cache-suffix` input. Jobs that compile with non-default flags pass a flavour so each writer owns its own key — race and test-combined pass `race`, test passes `cover`, everything else stays empty. Without per-flavour keys, parallel jobs collide on a single key and only one save wins per run, leaving the others permanently cold. + +**Rule:** call `setup-go` (with the right `cache-suffix` if you compile with non-default flags). Don't write a second `actions/cache` step for these paths. + +### Postgres template DB + +| | | +|---|---| +| Path | `/tmp/pg-template.tar.zst` | +| Key | `${{ runner.os }}-pg${{ pg-major }}-template-testament${{ testament-version }}-${{ migration-hash }}` | +| Restore owner | [`setup-databases`](./setup-databases/action.yml) | +| Save owner | [`snapshot-postgres-templates`](./snapshot-postgres-templates/action.yml), called with `if: always()` at job end | +| Used by | test, test-combined, race | + +**Why two composites for one cache?** GitHub composite actions can't declare post-steps. setup-databases restores the tar before PG starts; the snapshot composite tars again at job end so `actions/cache`'s auto-save uploads on cache-miss. Two composites — one for each end of the lifecycle. + +**Rule:** if you add a new job that uses testament, call `setup-databases` at the start *and* `snapshot-postgres-templates` at the end. Don't bypass either side. + +## Caches not owned here + +| Artefact | Owner | +|---|---| +| `~/.cache/golangci-lint` (lint analysis cache) | `golangci/golangci-lint-action`'s built-in cache (in `go-lint.yml`) | +| `~/.cache/go-build/fuzz` (fuzz corpus) | inline `actions/cache` step in `fuzz.yml` (independent sub-path, no collision risk) | + +These are noted for completeness — they don't conflict with anything above. diff --git a/.github/actions/install-go-tools/action.yml b/.github/actions/install-go-tools/action.yml new file mode 100644 index 0000000..25fce57 --- /dev/null +++ b/.github/actions/install-go-tools/action.yml @@ -0,0 +1,48 @@ +name: Install Go tools +description: | + For each requested tool, prepend the baked /opt/runner-tools// + dir to $GITHUB_PATH so consumers use the version baked into the custom runner + image. Falls back to `go install` if the baked path is missing (stock runner, + or pre-rollout). Versions come from tool-versions.json — synced across repos + by paddle-config. + +inputs: + tools: + description: | + Whitespace-separated list of tool names. Each must have an entry in + tool-versions.json. Example: + tools: | + govulncheck + modernize + required: true + tool-versions: + description: Path to tool-versions.json (relative to the workspace). + required: false + default: tool-versions.json + +runs: + using: composite + steps: + - shell: bash + env: + TOOLS: ${{ inputs.tools }} + TOOL_VERSIONS: ${{ inputs.tool-versions }} + run: | + set -euo pipefail + for name in $TOOLS; do + entry=$(jq -r --arg n "$name" '.tools[$n] // empty' "$TOOL_VERSIONS") + if [ -z "$entry" ]; then + echo "::error::tool '${name}' not found in ${TOOL_VERSIONS}" + exit 1 + fi + version=$(jq -r '.version' <<< "$entry") + package=$(jq -r '.package' <<< "$entry") + baked="/opt/runner-tools/${name}/${version}" + if [ -x "${baked}/${name}" ]; then + echo "${baked}" >> "$GITHUB_PATH" + echo " ${name}@${version} → baked" + else + echo " ${name}@${version} not baked, running go install" + go install "${package}@${version}" + fi + done diff --git a/.github/actions/lint-scope/action.yml b/.github/actions/lint-scope/action.yml new file mode 100644 index 0000000..2d41f19 --- /dev/null +++ b/.github/actions/lint-scope/action.yml @@ -0,0 +1,74 @@ +# DO NOT EDIT: This file should only be modified in the `go-library-template` repo. + +name: Determine lint scope +description: | + Compute which Go packages to lint and whether to lint at all, based on + the diff against the PR's base ref. Three outcomes: + + - lint config changed → `skip=false`, `pkgs=./...`, `new_from_rev` empty + (full-codebase lint so any new rules are enforced repo-wide). + - one or more `*.go` files changed → `skip=false`, `pkgs` is the unique + list of touched directories, `new_from_rev=--new-from-rev=` so + golangci-lint reports only issues new to the diff. + - neither → `skip=true`. The caller should gate the lint step on + `steps..outputs.skip != 'true'`; running golangci-lint here would + default to `./...` and analyse the whole codebase, but `--new-from-rev` + would filter every report out, so it'd be pure waste. + + The caller passes a `config-glob` regex matching the lint-config files + that should trigger a full-repo lint. Main lint passes the default + matching only `.golangci.{yml,yaml}`; experimental lint passes a wider + pattern that also matches `.golangci.experimental.{yml,yaml}` because + experimental inherits from the main config. + +inputs: + base-sha: + description: SHA of the PR base ref to diff against. + required: true + config-glob: + description: Regex (extended) matching the lint-config file(s) whose change should trigger a full repo lint. + default: '(^|/)\.golangci\.(yml|yaml)$' + required: false + +outputs: + skip: + description: '"true" if nothing should be linted (no .go files and no config change). Caller should gate the lint step on this.' + value: ${{ steps.scope.outputs.skip }} + pkgs: + description: Space-separated list of packages (or `./...`) to pass to golangci-lint. Empty if `skip` is true. + value: ${{ steps.scope.outputs.pkgs }} + new-from-rev: + description: '`--new-from-rev=` argument or empty. Pass through to golangci-lint args.' + value: ${{ steps.scope.outputs.new_from_rev }} + +runs: + using: composite + steps: + - id: scope + shell: bash + env: + BASE_SHA: ${{ inputs.base-sha }} + CONFIG_GLOB: ${{ inputs.config-glob }} + run: | + if git diff --name-only "$BASE_SHA"..HEAD | grep -qE "$CONFIG_GLOB"; then + { + echo "skip=false" + echo "pkgs=./..." + echo "new_from_rev=" + } >> "$GITHUB_OUTPUT" + else + pkgs=$(git diff --name-only --diff-filter=d "$BASE_SHA"..HEAD -- '*.go' \ + | xargs -I{} dirname {} \ + | sort -u \ + | sed 's|^|./|' \ + | tr '\n' ' ') + if [ -z "$pkgs" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + { + echo "skip=false" + echo "pkgs=$pkgs" + echo "new_from_rev=--new-from-rev=$BASE_SHA" + } >> "$GITHUB_OUTPUT" + fi + fi diff --git a/.github/actions/otel-export/action.yml b/.github/actions/otel-export/action.yml index e28189f..529a876 100644 --- a/.github/actions/otel-export/action.yml +++ b/.github/actions/otel-export/action.yml @@ -8,14 +8,24 @@ description: | inputs: honeycomb-team: - description: Honeycomb API key. Pass `${{ secrets.HONEYCOMB_GHA_SECRET }}` — secrets cannot be read directly inside composite actions. + description: "Honeycomb API key. Pass secrets.HONEYCOMB_GHA_SECRET — secrets cannot be read directly inside composite actions." required: true + honeycomb-dataset: + description: "Honeycomb dataset name. Pass vars.HONEYCOMB_GHA_DATASET — vars cannot be read directly inside composite actions." + required: true + github-token: + description: "GitHub token for API calls. Pass secrets.GITHUB_TOKEN." + required: true + extra-attributes: + description: "Optional extra OTel resource attributes to append, comma-separated key=value (e.g. service.name=my-workflow-e2e)." + required: false + default: "" runs: using: composite steps: - name: Checkout workflow file - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: .github/workflows sparse-checkout-cone-mode: false @@ -40,6 +50,6 @@ runs: continue-on-error: true with: otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ inputs.honeycomb-team }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ github.token }} - customAttributes: '{"workflow.source.hash":"${{ steps.wfhash.outputs.hash }}","runner.image.version":"${{ steps.image.outputs.version }}"}' + otlpHeaders: "x-honeycomb-team=${{ inputs.honeycomb-team }},x-honeycomb-dataset=${{ inputs.honeycomb-dataset }}" + githubToken: ${{ inputs.github-token }} + extraAttributes: "workflow.source.hash=${{ steps.wfhash.outputs.hash }},runner.image.version=${{ steps.image.outputs.version }}${{ inputs.extra-attributes != '' && format(',{0}', inputs.extra-attributes) || '' }}" diff --git a/.github/actions/setup-databases/action.yml b/.github/actions/setup-databases/action.yml index 5afdeab..c57911b 100644 --- a/.github/actions/setup-databases/action.yml +++ b/.github/actions/setup-databases/action.yml @@ -1,9 +1,18 @@ # DO NOT EDIT: This file should only be modified in the `go-library-template` repo. name: Setup Databases -description: | - This action starts and configures any databases required for testing, and exports the DSN for `go-testament` to use. - Use `enableMySQL: true` in your `build-config.yaml` to enable MySQL. +description: "Starts databases and exports DSNs for `go-testament`. Set `enableMySQL: true` in `build-config.yaml` for MySQL." + +outputs: + testament-imported: + description: "'true' if the consumer's go.mod requires go-testament" + value: ${{ steps.go_testament.outputs.imported }} + postgres-template-cache-hit: + description: "'true' if a Postgres template tar was restored from actions/cache" + value: ${{ steps.pg-cache.outputs.cache-hit }} + postgres-major-version: + description: "PostgreSQL major version detected on the runner (e.g. 17)" + value: ${{ steps.pg-detect.outputs.version }} runs: using: "composite" @@ -26,14 +35,70 @@ runs: - name: Read build config id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml + - name: Detect PostgreSQL major version + id: pg-detect + if: steps.go_testament.outputs.imported == 'true' + shell: bash + run: | + version=$(ls /etc/postgresql 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1) + if [ -z "$version" ]; then + echo "Error: no PostgreSQL installation found under /etc/postgresql/" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "PostgreSQL major version: ${version}" + + - name: Compute Postgres template cache key + id: pg-cache-key + if: steps.go_testament.outputs.imported == 'true' + shell: bash + run: | + if [ -d "${GITHUB_WORKSPACE}/database/migrations" ]; then + hash=$(find "${GITHUB_WORKSPACE}/database/migrations" -type f -print0 \ + | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-16) + elif [ -d "${GITHUB_WORKSPACE}/migrations" ]; then + hash=$(find "${GITHUB_WORKSPACE}/migrations" -type f -print0 \ + | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-16) + else + hash="no-migrations" + fi + # Pin testament version into the key: its template-name hash algorithm changes between versions. + testament_version=$(go list -m -f '{{.Version}}' \ + github.com/PaddleHQ/go-testament/v3 2>/dev/null \ + || go list -m -f '{{.Version}}' \ + github.com/PaddleHQ/go-testament/v4 2>/dev/null \ + || echo "unknown") + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + echo "testament-version=${testament_version}" >> "$GITHUB_OUTPUT" + echo "Migrations hash: ${hash}, testament: ${testament_version}" + + - name: Restore Postgres template cache + id: pg-cache + if: steps.go_testament.outputs.imported == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /tmp/pg-template.tar.zst + key: ${{ runner.os }}-pg${{ steps.pg-detect.outputs.version }}-template-testament${{ steps.pg-cache-key.outputs.testament-version }}-${{ steps.pg-cache-key.outputs.hash }} + restore-keys: | + ${{ runner.os }}-pg${{ steps.pg-detect.outputs.version }}-template-testament${{ steps.pg-cache-key.outputs.testament-version }}- + - name: Start PostgreSQL if: steps.go_testament.outputs.imported == 'true' shell: bash run: | + if [ -f /tmp/pg-template.tar.zst ]; then + sudo systemctl stop postgresql.service || true + sudo rm -rf /var/lib/postgresql/${{ steps.pg-detect.outputs.version }}/main + sudo mkdir -p /var/lib/postgresql/${{ steps.pg-detect.outputs.version }} + sudo tar --zstd -xf /tmp/pg-template.tar.zst -C /var/lib/postgresql/${{ steps.pg-detect.outputs.version }}/ + sudo chown -R postgres:postgres /var/lib/postgresql/${{ steps.pg-detect.outputs.version }}/main + sudo chmod 0700 /var/lib/postgresql/${{ steps.pg-detect.outputs.version }}/main + fi + sudo systemctl start postgresql.service pg_isready sudo -u postgres psql -c "ALTER SYSTEM SET max_connections TO '2000';" @@ -41,13 +106,18 @@ runs: sudo systemctl restart postgresql.service pg_isready - sudo -u postgres psql -c "CREATE USER runner WITH SUPERUSER CREATEDB REPLICATION PASSWORD 'hunter2'" - sudo -u postgres psql -c "CREATE DATABASE testdatabase WITH OWNER runner" + # NOT EXISTS: cache restore brings the role back; recreating changes the OID. + sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='runner'" | grep -q 1 \ + || sudo -u postgres psql -c "CREATE USER runner WITH SUPERUSER CREATEDB REPLICATION PASSWORD 'hunter2'" + sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='testdatabase'" | grep -q 1 \ + || sudo -u postgres psql -c "CREATE DATABASE testdatabase WITH OWNER runner" echo "TESTAMENT_POSTGRES_DSN=host=127.0.0.1 port=5432 user=runner password=hunter2 dbname=testdatabase sslmode=require" >> "$GITHUB_ENV" - # deprecated, can be removed once all repos have updated to `go-testament` >= v3.3 - echo "TESTAMENT_POSTGRES_16_DSN=host=127.0.0.1 port=5432 user=runner password=hunter2 dbname=testdatabase sslmode=require" >> "$GITHUB_ENV" + # Version-specific DSN keyed on the runner's detected major version: go-testament + # resolves TESTAMENT_POSTGRES__DSN when a test pins an explicit version, and + # its PostgresDefault shares a value with the latest version constant. + echo "TESTAMENT_POSTGRES_${{ steps.pg-detect.outputs.version }}_DSN=host=127.0.0.1 port=5432 user=runner password=hunter2 dbname=testdatabase sslmode=require" >> "$GITHUB_ENV" - name: Start MySQL if: steps.build_config.outputs['enableMySQL'] == 'true' diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 80c262a..afe1d5f 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -33,6 +33,17 @@ inputs: call `git credential-cache exit` after you have finished with the token. default: 'true' required: false + cache-suffix: + description: > + Optional suffix appended to the Go deps cache key. Use for jobs whose + build cache content differs from the default (e.g. the race job, which + compiles every package with -race and so produces distinct GOCACHE + entries from non-race jobs). Without a per-job suffix, race and + non-race jobs collide on the same cache key — only one save wins per + run, leaving the loser's compile artefacts uncached on subsequent + runs. Pass 'race' from the race job; leave empty for everything else. + default: '' + required: false outputs: go-mod-version: @@ -43,13 +54,13 @@ runs: using: "composite" steps: - name: Extract Go version - uses: arnested/go-version-action@8a203e9ff069cbbf4e3b65cb248101cfe307c71c # v1.1.18 + uses: arnested/go-version-action@6a9d1e0bcc9db5b1693bb7b0ad37491d205f67b6 # v2.1.4 id: go-version # For libraries then we use the latest patch version of the minor go version. # This should not be used for services. - name: Split version code - uses: step-security/split-strings@d2212424ae73f4709b3a56faaf96ea42d189102f # v1.0.1 + uses: step-security/split-strings@3f98629b8f82184c8b6de3199e16f1f68067f7ab # v1.0.2 id: split with: string: ${{ steps.go-version.outputs.go-mod-version }} @@ -57,14 +68,26 @@ runs: limit: 2 - name: Set up Go - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '${{ steps.split.outputs._0 }}.${{ steps.split.outputs._1 }}' check-latest: true - cache: true + cache: false + + - name: Cache Go modules + build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: setup-go-${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-version.outputs.go-mod-version }}${{ inputs.cache-suffix != '' && format('-{0}', inputs.cache-suffix) || '' }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + setup-go-${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-version.outputs.go-mod-version }}${{ inputs.cache-suffix != '' && format('-{0}', inputs.cache-suffix) || '' }}- + setup-go-${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-version.outputs.go-mod-version }}- + setup-go-${{ runner.os }}-${{ runner.arch }}-go- - name: Create a GitHub App Token - uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 if: ${{ inputs.private && inputs.private-key != '' }} id: app-token with: diff --git a/.github/actions/snapshot-postgres-templates/action.yml b/.github/actions/snapshot-postgres-templates/action.yml new file mode 100644 index 0000000..4b76789 --- /dev/null +++ b/.github/actions/snapshot-postgres-templates/action.yml @@ -0,0 +1,58 @@ +# DO NOT EDIT: This file should only be modified in the `go-library-template` repo. + +name: Snapshot Postgres templates +description: | + Tars `/var/lib/postgresql//main` to `/tmp/pg-template.tar.zst` so the + Postgres template cache populated by `setup-databases` (which restores the + same tar via actions/cache) refreshes for future runs. + + Pair with `./.github/actions/setup-databases`. setup-databases restores + the tar before PG starts; this action snapshots it after tests so + actions/cache's auto-save uploads the new entry. Call with `if: always()` + so a failing test step doesn't skip the snapshot — testament populates + the templates in TestMain before any test runs. + + Skips the work if testament wasn't imported by the consumer service, or + if the cache was a hit (no new content to save). Drops per-test instance + DBs before tarring so they don't bloat the artefact. + +inputs: + testament-imported: + description: setup-databases' `testament-imported` output ('true' / 'false') + required: true + postgres-template-cache-hit: + description: setup-databases' `postgres-template-cache-hit` output ('true' / 'false' / empty) + required: true + postgres-major-version: + description: setup-databases' `postgres-major-version` output (e.g. '17') + required: true + +runs: + using: composite + steps: + - name: Snapshot + if: inputs.testament-imported == 'true' && inputs.postgres-template-cache-hit != 'true' + shell: bash + env: + PG_MAJOR_VERSION: ${{ inputs.postgres-major-version }} + run: | + # Drop per-test instance DBs only; templates and testament's stable base must persist. + sudo -u postgres psql -tAc \ + "SELECT datname FROM pg_database WHERE datname LIKE 'testdb_tpl_%_inst_%'" \ + | while read -r db; do + [ -n "$db" ] && sudo -u postgres psql -c "DROP DATABASE \"$db\"" + done + sudo -u postgres psql -c "CHECKPOINT;" + sudo systemctl stop postgresql.service + # Wait for postgres process group to drain — systemctl stop is async, tar otherwise races it. + for _ in $(seq 1 30); do + pgrep -u postgres >/dev/null 2>&1 || break + sleep 1 + done + # On a restore-keys (prefix) cache match, setup-databases restored the stale tar to this + # path yet reports cache-hit=false, so this step runs. The restored file is owned by the + # runner user under sticky /tmp, so `tar -cf` over it fails ("Cannot open: Permission + # denied"). Remove it first so tar creates the refreshed snapshot cleanly. + sudo rm -f /tmp/pg-template.tar.zst || echo ":warn: failed to delete" + sudo tar --zstd -cf /tmp/pg-template.tar.zst -C "/var/lib/postgresql/${PG_MAJOR_VERSION}" main + echo "Snapshot size: $(du -sh /tmp/pg-template.tar.zst | cut -f1)" diff --git a/.github/actions/trigger-automerge/action.yml b/.github/actions/trigger-automerge/action.yml index 76a58d1..d4c3685 100644 --- a/.github/actions/trigger-automerge/action.yml +++ b/.github/actions/trigger-automerge/action.yml @@ -16,27 +16,40 @@ runs: using: "composite" steps: - name: Attempt to automerge + # continue-on-error so the calling job doesn't fail when the target + # workflow isn't yet on the default branch (e.g. the sync PR that + # first introduces automerge.yml). Dispatch is fire-and-forget; the + # PR can always be merged manually. + continue-on-error: true if: fromJSON(inputs.event).pull_request != null && inputs.hasAutoApproverPrivateKey == 'true' && fromJSON(inputs.event).pull_request.merged == false && fromJSON(inputs.event).pull_request.draft == false && fromJSON(inputs.event).pull_request.state == 'open' - uses: step-security/workflow-dispatch@821295dcc146c6330b5a388c03d56f9c90e667fc # v1.3.1 + uses: step-security/workflow-dispatch@024752877e2bfa07299ee4204cc5f7e7c14ff930 # v1.3.21 with: workflow: automerge.yml - ref: ${{ github.head_ref || github.ref_name }} + # Take the head branch from the event payload rather than github.head_ref, + # which is only set on pull_request/pull_request_target events. On other + # trigger types (e.g. pull_request_review, check_run) head_ref is empty and + # github.ref_name resolves to a non-branch ref (like "/merge") or the wrong + # branch, so the dispatch fails. inputs.event.pull_request is guaranteed here by + # the guard above and is present on every path that invokes this action. + ref: ${{ fromJSON(inputs.event).pull_request.head.ref }} inputs: '{ - "pr_number": "${{ fromJSON(inputs.event).pull_request.number }}", - "workflow": "${{ github.workflow }}" + "pr_number": "${{ fromJSON(inputs.event).pull_request.number }}" }' - name: Attempt to auto ready for review + continue-on-error: true if: fromJSON(inputs.event).pull_request != null && fromJSON(inputs.event).pull_request.draft == true - uses: step-security/workflow-dispatch@821295dcc146c6330b5a388c03d56f9c90e667fc # v1.3.1 + uses: step-security/workflow-dispatch@024752877e2bfa07299ee4204cc5f7e7c14ff930 # v1.3.21 with: workflow: auto-ready-for-review.yml - ref: ${{ github.head_ref || github.ref_name }} + # See note above: source the head branch from the event payload so dispatch + # works regardless of the triggering event type. + ref: ${{ fromJSON(inputs.event).pull_request.head.ref }} inputs: '{ "pr_number": "${{ fromJSON(inputs.event).pull_request.number }}", "head_sha": "${{ fromJSON(inputs.event).pull_request.head.sha }}", diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ad10331..6606002 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,6 +22,19 @@ updates: otel: patterns: - "go.opentelemetry.io/otel/*" - otelcontrib-do-not-merge: + otelcontrib: patterns: - "go.opentelemetry.io/contrib/*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "03:00" + labels: + - "norelease" + cooldown: + # Longer than the go-library-template's own 7-day cooldown so its action bumps merge + # and sync down first; libraries only open their own PR as a fallback. + default-days: 14 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5b3bd6d..4d021cc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,3 @@ -### Checklist: -- [ ] __Simple__: _Is this code as simple as possible? Could it be achieved in a different way? Is there an open source project that already does this?_ -- [ ] __Together__: _Have you worked with others on this change? Will it be a surprise to the reviewer? Will this change be communicated with other engineers?_ -- [ ] __For Others__: _Is the code readable and well commented? Will it be understandable when read by an engineer in the distant future? Does it "make the boat go faster"?_ - ### Semver: `NONE` | `PATCH` | `MINOR` | `MAJOR`: (delete as appropriate - and include why in this section) diff --git a/.github/workflows/auto-ready-for-review.yml b/.github/workflows/auto-ready-for-review.yml index d5c6b18..21488af 100644 --- a/.github/workflows/auto-ready-for-review.yml +++ b/.github/workflows/auto-ready-for-review.yml @@ -31,25 +31,30 @@ concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.inputs.pr_number }} cancel-in-progress: true -permissions: - checks: read - contents: read - pull-requests: write # required for `auto-ready-for-review` to mark the PR as ready for review +# No top-level `permissions:` — every job declares its own minimum scope. +# This isolates the third-party `step-security/allcheckspassed` action in its own +# job (`wait_for_checks`) where the GITHUB_TOKEN it inherits has only +# `checks: read`, with no access to `pull-requests: write`. +permissions: {} jobs: - auto_ready_for_review: - name: "AutoReady for Review" - runs-on: ubuntu-latest + # -- + # Cheap pre-flight: only inspects PR state, no third-party actions involved. + # Scoped to `pull-requests: read` so the gh CLI can read labels and draft + # state but cannot mutate the PR. + gate: + name: "🧠 Gate: label + draft check" + runs-on: generic-2-core + timeout-minutes: 5 + permissions: + pull-requests: read + outputs: + ok: ${{ steps.gate.outputs.ok }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.head_sha }} persist-credentials: false @@ -58,10 +63,11 @@ jobs: id: gate env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} shell: bash run: | set -euo pipefail - pr_json="$(gh pr view ${{ inputs.pr_number }} --json isDraft,labels)" + pr_json="$(gh pr view "$PR_NUMBER" --json isDraft,labels)" is_draft="$(echo "$pr_json" | jq -r '.isDraft')" has_label="$(echo "$pr_json" | jq -r '.labels | map(.name) | index("auto-ready-for-review") | if . == null then "false" else "true" end')" @@ -72,11 +78,26 @@ jobs: echo "ok=false" >> "$GITHUB_OUTPUT" fi + # -- + # Wait for upstream checks via the third-party `step-security/allcheckspassed` + # action. Isolated in its own job so its GITHUB_TOKEN is scoped to + # `checks: read` only — even a compromised version of the action cannot + # reach the PR-mutation scope held by the downstream `mark_ready` job. + wait_for_checks: + name: "⏳ Wait for checks" + runs-on: generic-2-core + timeout-minutes: 30 + needs: gate + if: needs.gate.outputs.ok == 'true' + permissions: + checks: read + outputs: + passed: ${{ steps.confirm.outputs.passed }} + + steps: - name: "⏳ Wait for calling workflow to be ready" - if: |- - steps.gate.outputs.ok == 'true' && - github.event.inputs.workflow != '' - uses: wechuli/allcheckspassed@1d00cf0c34c4b0805db8866d8913f22e7125301e # v2.3.0 + if: github.event.inputs.workflow != '' + uses: step-security/allcheckspassed@c00c70ebb41bb057d9a00f6b97bfbc6f6e4c2f30 # v2.3.0 id: check-caller with: delay: '0' @@ -86,10 +107,8 @@ jobs: checks_include: ${{ github.event.inputs.workflow }} - name: "⏳ Wait for All Required Checks to Pass" - if: |- - steps.gate.outputs.ok == 'true' && - steps.check-caller.outcome == 'success' - uses: wechuli/allcheckspassed@1d00cf0c34c4b0805db8866d8913f22e7125301e # v2.3.0 + if: steps.check-caller.outcome == 'success' + uses: step-security/allcheckspassed@c00c70ebb41bb057d9a00f6b97bfbc6f6e4c2f30 # v2.3.0 id: check-all with: delay: '0' @@ -98,48 +117,84 @@ jobs: commit_sha: ${{ inputs.head_sha }} checks_exclude: (AutoReady|StepSecurity).* + # Bubble the inner step's outcome to a job-level output, mirroring the + # original single-job gating: `mark_ready` runs only when `check-all` + # actually succeeded — not when it was skipped because the `workflow` + # input was empty. + - name: "Confirm all checks passed" + id: confirm + if: steps.check-all.outcome == 'success' + run: echo "passed=true" >> "$GITHUB_OUTPUT" + + # -- + # PR mutation: marks the PR ready and removes the trigger label. Holds + # `pull-requests: write`. Only runs if both prior jobs succeeded — gate + # passed AND all checks passed. + mark_ready: + name: "🔄 Mark PR ready" + runs-on: generic-2-core + timeout-minutes: 5 + needs: + - gate + - wait_for_checks + if: needs.gate.outputs.ok == 'true' && needs.wait_for_checks.outputs.passed == 'true' + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.head_sha }} + persist-credentials: false + - name: "🔑 Generate GitHub App Token" # github.token doesn't have permission to mark PR ready for review :( id: generate-token - if: steps.gate.outputs.ok == 'true' && steps.check-all.outcome == 'success' - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v1 with: app-id: 1045853 private-key: ${{ secrets.AUTO_APPROVER_PRIVATE_KEY }} - name: "🔄 Mark PR ready for review" # github.token doesn't have permission to mark PR ready for review :( - if: steps.gate.outputs.ok == 'true' && steps.check-all.outcome == 'success' env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} + PR_NUMBER: ${{ inputs.pr_number }} shell: bash run: | - gh pr ready "${{ inputs.pr_number }}" + gh pr ready "$PR_NUMBER" - name: "🧹 Remove auto label" - if: steps.gate.outputs.ok == 'true' && steps.check-all.outcome == 'success' env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} shell: bash run: | - gh pr edit "${{ inputs.pr_number }}" --remove-label "auto-ready-for-review" + gh pr edit "$PR_NUMBER" --remove-label "auto-ready-for-review" otel_cicd_export: name: OpenTelemetry Export Trace if: always() - needs: [auto_ready_for_review] - runs-on: ubuntu-latest + needs: [gate, wait_for_checks, mark_ready] + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 34f3682..c3ea0e0 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -14,12 +14,6 @@ on: description: 'Number of the pull request' required: true type: string - workflow: - # This is used to wait for the workflow to complete before running further actions - # It is safe as an input - description: 'Name of the workflow that triggered this action' - required: false - type: string concurrency: @@ -37,14 +31,10 @@ jobs: pr_info: name: "Get PR Info" - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Check sender env: SENDER: ${{ github.event.sender.login }} @@ -55,7 +45,7 @@ jobs: fi - name: "📥 Checkout Default Branch" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -75,7 +65,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: "📥 Checkout PR Code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.pr-info.outputs.head_ref }} persist-credentials: false @@ -91,19 +81,38 @@ jobs: mergeBase=$(git merge-base origin/${{ steps.pr-info.outputs.base_ref }} HEAD) echo "merge_base=${mergeBase}" >> "$GITHUB_OUTPUT" + - name: "📝 List changed files" + id: changed + env: + MERGE_BASE: ${{ steps.merge-base.outputs.merge_base }} + run: | + # Emitted as a multiline output so downstream gate jobs can do + # their scope check against a shared file list (single source of + # truth for `git diff --name-only`). + { + echo 'files<> "$GITHUB_OUTPUT" + outputs: draft: ${{ steps.pr-info.outputs.draft }} user_login: ${{ steps.pr-info.outputs.user_login }} labels: ${{ steps.pr-info.outputs.labels }} head_ref: ${{ steps.pr-info.outputs.head_ref }} merge_base: ${{ steps.merge-base.outputs.merge_base }} + changed_files: ${{ steps.changed.outputs.files }} # -- - # Wait for the PR to be ready for automerge + # Confirm the automerge secret is present before any gate runs. Waiting for + # checks is no longer done here — the `automerge` job enables GitHub-native + # auto-merge (`gh pr merge --auto`), so the rulesets' required checks, merge + # queue and approval gate the actual merge. wait_for_ready: - name: Check source and wait for PR to be ready - runs-on: ubuntu-latest + name: Confirm automerge is enabled + runs-on: generic-2-core + timeout-minutes: 5 needs: - pr_info @@ -115,11 +124,6 @@ jobs: ) steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: "🔐 Verify Automerge Secret Available" id: check-secret run: | @@ -130,108 +134,157 @@ jobs: echo "can_automerge=true" >> "$GITHUB_OUTPUT" fi - - name: "🔄 Wait for calling workflow to be ready" - if: |- - steps.check-secret.outputs.can_automerge == 'true' && - github.event.inputs.workflow != '' - uses: wechuli/allcheckspassed@1d00cf0c34c4b0805db8866d8913f22e7125301e # v2.3.0 - id: check-caller - continue-on-error: true - with: - delay: '0' - poll: true - checks_include: ${{ github.event.inputs.workflow }} - - - name: "⏳ Wait for All Required Checks to Pass" - if: |- - steps.check-secret.outputs.can_automerge == 'true' && - steps.check-caller.outcome == 'success' - uses: wechuli/allcheckspassed@1d00cf0c34c4b0805db8866d8913f22e7125301e # v2.3.0 - id: check-all - continue-on-error: true - with: - delay: '0' - poll: false - checks_exclude: (\[Automerge\/.*\]|StepSecurity).* - - - name: "✅ Confirm Automerge Eligibility" - id: check-failure - if: |- - steps.check-secret.outputs.can_automerge == 'true' && - steps.check-caller.outcome == 'success' && - steps.check-all.outcome == 'success' - run: | - echo "✅ All checks passed - automerge enabled" - echo "can_automerge=true" >> "$GITHUB_OUTPUT" - outputs: - can_automerge: ${{ steps.check-secret.outputs.can_automerge && steps.check-failure.outputs.can_automerge }} + can_automerge: ${{ steps.check-secret.outputs.can_automerge }} # -- # Check the source and the changes of the PR # One of the following jobs must pass for the PR to be merged automatically - check_dependabot_changes: - name: "[Automerge/Check] 🤖 Validate Dependabot Changes" + # Two independent dependabot gates, each scoped to one surface. A PR + # passes when it's *only* changes within that surface AND those changes + # meet the trust criteria. Out-of-scope PRs (touching other files, or + # mixing surfaces) exit silently — the existing OR in the `automerge` + # job's `if:` lets either gate green-light a merge. + + check_dependabot_go_deps: + name: "[Automerge/Check] 🤖 Validate Go dependency updates" needs: - pr_info - wait_for_ready - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 if: |- needs.wait_for_ready.outputs.can_automerge == 'true' && needs.pr_info.outputs.user_login == 'app/dependabot' steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: "📥 Checkout PR Code" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.pr_info.outputs.head_ref }} + persist-credentials: false + fetch-depth: 0 + + - name: "🔍 Validate Go dependency updates" + id: check + env: + CHANGED_FILES: ${{ needs.pr_info.outputs.changed_files }} + run: | + # Silent exit if any changed file is outside go.mod/go.sum - + # another gate may apply. + if [ -z "${CHANGED_FILES}" ] || echo "${CHANGED_FILES}" | grep -qvE '^(go\.mod|go\.sum)$'; then + echo "ℹ️ Not a pure go.mod/go.sum diff - not this gate's responsibility" + exit 0 + fi + + if git diff --quiet -G "github.com/PaddleHQ/" ${{ needs.pr_info.outputs.merge_base }} -- go.mod; then + REASON="Third-party Go dependency updates require manual review." + echo "⚠️ ${REASON}" + echo "reason=${REASON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "✅ PaddleHQ Go dependency update - automerge enabled" + echo "can_automerge=true" >> "$GITHUB_OUTPUT" + + - name: "💬 Manage Automerge Status Comment" + uses: ./.github/actions/automerge-skipped-comment with: - egress-policy: audit + pr_number: ${{ github.event.inputs.pr_number }} + check_name: "Validate Go dependency updates" + reason: ${{ steps.check.outputs.reason }} + github_token: ${{ github.token }} + + outputs: + can_automerge: ${{ steps.check.outputs.can_automerge }} + + check_dependabot_action_refs: + name: "[Automerge/Check] 🛡️ Validate workflow action updates" + needs: + - pr_info + - wait_for_ready + runs-on: generic-2-core + timeout-minutes: 5 + + if: |- + needs.wait_for_ready.outputs.can_automerge == 'true' && + needs.pr_info.outputs.user_login == 'app/dependabot' + steps: - name: "📥 Checkout PR Code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.pr_info.outputs.head_ref }} persist-credentials: false fetch-depth: 0 - - name: "🔍 Analyze Dependency Changes" - id: check-external + - name: "🔍 Validate workflow action updates" + id: check + env: + CHANGED_FILES: ${{ needs.pr_info.outputs.changed_files }} run: | - echo "🔍 Checking dependency changes in go.mod..." - if git diff --quiet ${{ needs.pr_info.outputs.merge_base }} -- 'go.mod'; then - REASON="No changes detected in \`go.mod\` — automerge only applies to PaddleHQ dependency updates." - echo "ℹ️ ${REASON}" + # Pre-vetted action publishers, pipe-separated for grep alternation. + # Keep in sync with `check-action-security.yml`. + TRUSTED_ORGS_REGEX='(actions|github|step-security|paddlehq)' + + # Silent exit if any changed file is outside the workflow/action + # tree - another gate may apply. + if [ -z "${CHANGED_FILES}" ] || echo "${CHANGED_FILES}" | grep -qvE '^\.github/(workflows|actions)/'; then + echo "ℹ️ Not a pure workflow/action diff - not this gate's responsibility" + exit 0 + fi + + # Two greps: first picks added `uses:` lines pointing at a remote + # owner (the `[A-Za-z0-9_-]+/` rules out local `./...` composite + # refs — `.` is intentionally not in the char class); second drops + # trusted orgs case-insensitively. `|| true` keeps the pipeline + # from failing on no-match. + UNTRUSTED=$(git diff ${{ needs.pr_info.outputs.merge_base }} -- '.github/workflows' '.github/actions' \ + | grep -E '^\+[[:space:]]+(- )?uses:[[:space:]]*["'\'']?[A-Za-z0-9_-]+/' \ + | grep -ivE "uses:[[:space:]]*[\"']?${TRUSTED_ORGS_REGEX}/" \ + || true) + + if [ -n "${UNTRUSTED}" ]; then + REASON="Untrusted third-party action reference(s) added. Allowed orgs: actions, github, step-security, paddlehq." + echo "⚠️ ${REASON}" + printf '%s\n' "${UNTRUSTED}" { - echo "can_automerge=false" - echo "reason=${REASON}" + echo 'reason<> "$GITHUB_OUTPUT" - elif git diff --quiet -G "github.com/PaddleHQ/" ${{ needs.pr_info.outputs.merge_base }} -- go.mod; then - REASON="No PaddleHQ dependencies updated — third-party dependency updates require manual review." - echo "⚠️ ${REASON}" - echo "reason=${REASON}" >> "$GITHUB_OUTPUT" - else - echo "✅ PaddleHQ dependencies updated - automerge enabled" - echo "can_automerge=true" >> "$GITHUB_OUTPUT" + exit 0 fi + echo "✅ All workflow/action bumps target trusted orgs - automerge enabled" + echo "can_automerge=true" >> "$GITHUB_OUTPUT" + - name: "💬 Manage Automerge Status Comment" uses: ./.github/actions/automerge-skipped-comment with: pr_number: ${{ github.event.inputs.pr_number }} - check_name: "Validate Dependabot Changes" - reason: ${{ steps.check-external.outputs.reason }} + check_name: "Validate workflow action updates" + reason: ${{ steps.check.outputs.reason }} github_token: ${{ github.token }} outputs: - can_automerge: ${{ steps.check-external.outputs.can_automerge }} + can_automerge: ${{ steps.check.outputs.can_automerge }} + + check_library_template_changes: name: "[Automerge/Check] 📝 Check GO Library Template changes" needs: - pr_info - wait_for_ready - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 if: |- needs.wait_for_ready.outputs.can_automerge == 'true' && @@ -243,13 +296,8 @@ jobs: ) steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: "📥 Checkout PR Code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.pr_info.outputs.head_ref }} persist-credentials: false @@ -257,14 +305,25 @@ jobs: - name: "🔍 Analyze Go Library Template Changes" id: check-files + env: + CHANGED_FILES: ${{ needs.pr_info.outputs.changed_files }} run: | - echo "🔍 Checking which files were modified by from a library template sync..." - # Check if only safe files were modified (workflows and dependabot config) - if git diff --quiet ${{ needs.pr_info.outputs.merge_base }} -- ':!.github/actions' ':!.github/workflows' ':!.github/dependabot.yml' ':!.github/pull_request_template.md' ':!.github/release.yml' ':!.github/actionlint.yaml'; then + echo "🔍 Checking which files were modified by a library template sync..." + # Files a library-template sync is allowed to touch. Anything else + # → manual review. + ALLOWED=( + -e '^\.github/(actions|workflows)/' + -e '^\.github/dependabot\.yml$' + -e '^\.github/pull_request_template\.md$' + -e '^\.github/release\.yml$' + -e '^\.github/actionlint\.yaml$' + -e '^tool-versions\.json$' + ) + if [ -n "${CHANGED_FILES}" ] && ! echo "${CHANGED_FILES}" | grep -qvE "${ALLOWED[@]}"; then echo "✅ Only safe template files modified (workflows/dependabot) - automerge enabled" echo "can_automerge=true" >> "$GITHUB_OUTPUT" else - REASON="Updates to files outside the safe template allow-list (\`.github/actions\`, \`.github/workflows\`, \`.github/dependabot.yml\`, \`.github/pull_request_template.md\`, \`.github/release.yml\`, \`.github/actionlint.yaml\`) — manual review required." + REASON="Updates to files outside the safe template allow-list (\`.github/actions\`, \`.github/workflows\`, \`.github/dependabot.yml\`, \`.github/pull_request_template.md\`, \`.github/release.yml\`, \`.github/actionlint.yaml\`, \`tool-versions.json\`) — manual review required." echo "⚠️ ${REASON}" echo "reason=${REASON}" >> "$GITHUB_OUTPUT" fi @@ -280,12 +339,69 @@ jobs: outputs: can_automerge: ${{ steps.check-files.outputs.can_automerge }} + check_coding_standards_changes: + name: "[Automerge/Check] 📋 Check Coding Standards Changes" + needs: + - pr_info + - wait_for_ready + runs-on: generic-2-core + timeout-minutes: 5 + + if: |- + needs.wait_for_ready.outputs.can_automerge == 'true' && + needs.pr_info.outputs.user_login == 'app/paddle-repo-file-sync' && + contains(needs.pr_info.outputs.labels, 'origin:coding-standards') + + steps: + - name: "📥 Checkout PR Code" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.pr_info.outputs.head_ref }} + persist-credentials: false + fetch-depth: 0 + + - name: "🔍 Analyze Coding Standards Changes" + id: check-files + env: + CHANGED_FILES: ${{ needs.pr_info.outputs.changed_files }} + run: | + echo "🔍 Checking which files were modified by coding-standards sync..." + # Coding-standards sync is allowed to touch only linter/editor config files. + # This gate is isolated from other sync sources to prevent cross-contamination. + ALLOWED=( + -e '^\.golangci\.yml$' + -e '^\.golangci\.experimental\.yml$' + -e '^\.golangci\.v2\.yml$' + -e '^\.golangci\.v2\.experimental\.yml$' + -e '^\.editorconfig$' + ) + if [ -n "${CHANGED_FILES}" ] && ! echo "${CHANGED_FILES}" | grep -qvE "${ALLOWED[@]}"; then + echo "✅ Only safe coding-standards files modified (.golangci.yml variants, .editorconfig) - automerge enabled" + echo "can_automerge=true" >> "$GITHUB_OUTPUT" + else + REASON="Coding-standards sync should only modify linter/editor config files (\`.golangci.yml\`, \`.golangci.experimental.yml\`, their v2 variants, and \`.editorconfig\`) — other changes require manual review." + echo "⚠️ ${REASON}" + echo "reason=${REASON}" >> "$GITHUB_OUTPUT" + fi + + - name: "💬 Manage Automerge Status Comment" + uses: ./.github/actions/automerge-skipped-comment + with: + pr_number: ${{ github.event.inputs.pr_number }} + check_name: "Check Coding Standards Changes" + reason: ${{ steps.check-files.outputs.reason }} + github_token: ${{ github.token }} + + outputs: + can_automerge: ${{ steps.check-files.outputs.can_automerge }} + check_paddle_config_changes: name: "[Automerge/Check] ⚙️ Check Paddle Config Changes" needs: - pr_info - wait_for_ready - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 if: |- needs.wait_for_ready.outputs.can_automerge == 'true' && @@ -293,13 +409,8 @@ jobs: contains(needs.pr_info.outputs.labels, 'origin:paddle-config') steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: "📥 Checkout PR Code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.pr_info.outputs.head_ref }} persist-credentials: false @@ -307,7 +418,7 @@ jobs: - name: "📖 Read Build Configuration" id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml @@ -345,7 +456,8 @@ jobs: needs: - pr_info - wait_for_ready - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 if: |- needs.wait_for_ready.outputs.can_automerge == 'true' && @@ -356,13 +468,8 @@ jobs: ) steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: "📥 Checkout PR Code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.pr_info.outputs.head_ref }} persist-credentials: false @@ -370,16 +477,17 @@ jobs: - name: "📖 Read Build Configuration" id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml - name: "🔍 Analyze Template Changes" id: check-files + env: + CHANGED_FILES: ${{ needs.pr_info.outputs.changed_files }} run: | - echo "🔍 Checking which files were modified by from a library template sync..." - # Check if only safe files were modified (workflows and dependabot config) - if git diff --quiet ${{ needs.pr_info.outputs.merge_base }} -- ':!*.yaml.tmpl'; then + echo "🔍 Checking which files were modified by a renovate sync..." + if [ -n "${CHANGED_FILES}" ] && ! echo "${CHANGED_FILES}" | grep -qvE '\.yaml\.tmpl$'; then echo "✅ Only safe template files modified - automerge enabled" echo "can_automerge=true" >> "$GITHUB_OUTPUT" else @@ -404,13 +512,16 @@ jobs: automerge: name: "[Automerge/Merge] 🚀 Auto Approve and Merge" - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 needs: - pr_info - wait_for_ready - - check_dependabot_changes + - check_dependabot_go_deps + - check_dependabot_action_refs - check_library_template_changes + - check_coding_standards_changes - check_paddle_config_changes - check_renovate_changes @@ -418,34 +529,31 @@ jobs: # always() ensures this is a `or` condition and does not depend on ALL the checks passing, but only one. if: |- always() && ( - needs.check_dependabot_changes.outputs.can_automerge == 'true' || + needs.check_dependabot_go_deps.outputs.can_automerge == 'true' || + needs.check_dependabot_action_refs.outputs.can_automerge == 'true' || needs.check_library_template_changes.outputs.can_automerge == 'true' || + needs.check_coding_standards_changes.outputs.can_automerge == 'true' || needs.check_paddle_config_changes.outputs.can_automerge == 'true' || needs.check_renovate_changes.outputs.can_automerge == 'true' ) steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: "📥 Checkout PR Code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.pr_info.outputs.head_ref }} persist-credentials: false - name: "📖 Read Build Configuration" id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 continue-on-error: true with: config: ${{ github.workspace }}/build-config.yaml - name: "🔑 Generate GitHub App Token" id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: 1045853 private-key: ${{ secrets.AUTO_APPROVER_PRIVATE_KEY }} @@ -460,37 +568,34 @@ jobs: if: steps.build_config.outputs['mergeBotEnabled'] == 'true' env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} - run: | - echo "🔀 Attempting direct merge..." - if MERGE_OUTPUT=$(gh pr merge --admin --squash "${{ github.event.inputs.pr_number }}" 2>&1); then - echo "✅ PR merged successfully" - elif echo "$MERGE_OUTPUT" | grep -q "merge queue"; then - echo "🚂 Merge queue required, adding PR to queue..." - gh pr merge --squash "${{ github.event.inputs.pr_number }}" - else - echo "❌ Merge failed:" - echo "$MERGE_OUTPUT" - exit 1 - fi + # Enable GitHub-native auto-merge. The PR merges once the rulesets are + # satisfied (required checks green, approval present, merge queue), so + # this returns immediately rather than holding a runner until checks pass. + run: gh pr merge --auto --squash "${{ github.event.inputs.pr_number }}" otel_cicd_export: name: OpenTelemetry Export Trace if: always() - needs: [pr_info, wait_for_ready, check_dependabot_changes, check_library_template_changes, check_paddle_config_changes, check_renovate_changes, automerge] - runs-on: ubuntu-latest + needs: [pr_info, wait_for_ready, check_dependabot_go_deps, check_dependabot_action_refs, check_library_template_changes, check_coding_standards_changes, check_paddle_config_changes, check_renovate_changes, automerge] + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/check-api-compat.yml b/.github/workflows/check-api-compat.yml new file mode 100644 index 0000000..394b442 --- /dev/null +++ b/.github/workflows/check-api-compat.yml @@ -0,0 +1,253 @@ +# DO NOT EDIT: This file should only be modified in the `go-library-template` repo. + +name: Check API compatibility + +# Runs only on pull_request: gorelease needs the PR's labels (to skip +# release:major) and a base release tag, neither of which exist in merge_group. +# Kept in its own workflow so label changes refresh the comment without +# re-running the full validate suite. +on: + pull_request: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +env: + # Required to enable support for synctest.Run + # This will be removed once we update to go 1.25 + GOEXPERIMENT: synctest + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + gorelease: + name: Check API compatibility + runs-on: go-4-core + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + fetch-depth: 0 # fetch all history + tags to resolve the base version + + - name: Set up Go + uses: ./.github/actions/setup-go + id: setup-go + with: + owner: ${{ github.repository_owner }} + private: ${{ github.event.repository.private }} + private-key: ${{ secrets.PRIVATE_GO_CI_PRIVATE_KEY }} + cache-suffix: gorelease + # Keep the GitHub App token in git's credential cache so the gorelease + # step can fetch the base version's transitive private deps (e.g. + # go-rest). The repo-scoped GITHUB_TOKEN can't reach other PaddleHQ + # repos; the App token is owner-scoped. Dropped by "Clear git + # credentials" below. + clear-token: 'false' + + - name: Install Go tools + uses: ./.github/actions/install-go-tools + with: + tools: | + gorelease + + # -- + # API compatibility (gorelease) + # + # Checks whether the PR's release label is big enough for the API changes + # vs the latest release tag for this module's major version. Skipped for + # release:major PRs (author is knowingly breaking the API). Posts a + # comment; does not fail the build. + # + # We run gorelease with -version set to the version the label implies and + # parse its report, NOT its exit code: gorelease exits non-zero for benign + # diagnostics too (e.g. retracted transitive deps), so the exit code alone + # misreads a compatible release as a breaking change. Breaking changes come + # from the report's "## incompatible changes" section (populated for every + # major, incl. v0 which the summary verdict never flags); the label-too-low + # case still keys off gorelease's "minor version is not incremented" line. + + - name: Run gorelease + id: gorelease + if: ${{ !contains(github.event.pull_request.labels.*.name, 'release:major') }} + shell: bash {0} + env: + GONOSUMDB: "github.com/PaddleHQ/*" + GOPRIVATE: "github.com/PaddleHQ/*" + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + GORELEASE_TIMEOUT_SECONDS: "600" + run: | + # gorelease fetches the base version and resolves its full module + # graph — including transitive private deps (e.g. go-rest) — via git + # over HTTPS. It relies on the GitHub App token setup-go left in git's + # credential cache (clear-token: 'false'); that token is owner-scoped, + # so it can reach those cross-repo private deps. Cleared by "Clear git + # credentials" below. + # Derive which vMAJOR tags to consider from the module path. Go omits + # the /vN suffix for both v0 and v1 modules, so a bare path could be + # either: match v0.* and v1.* and let the tag list decide, or a v0 + # module (whose only tags are v0.*) finds nothing and is skipped. + MODULE_PATH=$(go list -m) + MAJOR_SUFFIX=$(echo "$MODULE_PATH" | grep -oE '/v[0-9]+$' | grep -oE '[0-9]+') + if [ -n "$MAJOR_SUFFIX" ]; then + TAG_RE="^v${MAJOR_SUFFIX}\.[0-9]+\.[0-9]+$" + MAJOR_DESC="v${MAJOR_SUFFIX}" + else + TAG_RE="^v[01]\.[0-9]+\.[0-9]+$" + MAJOR_DESC="v0/v1" + fi + + BASE_TAG=$(git tag --list --sort=-version:refname \ + | grep -E "$TAG_RE" \ + | head -1) + + if [ -z "$BASE_TAG" ]; then + echo "No prior release tag for ${MAJOR_DESC} — skipping" + exit 0 + fi + + echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT" + + # The base tag is a clean vMAJOR.MINOR.PATCH (filtered above). Compute + # the version the PR's release label implies so gorelease can judge + # whether that bump is sufficient (release:major is excluded by the + # step `if`). + base_semver="${BASE_TAG#v}" + base_major="${base_semver%%.*}" + base_rest="${base_semver#*.}" + base_minor="${base_rest%%.*}" + base_patch="${base_rest##*.}" + + case ",${PR_LABELS}," in + *,release:patch,*) + # PR claims patch-only: test a patch bump so we also catch + # compatible additions that actually require a minor bump. + PROPOSED="v${base_major}.${base_minor}.$((base_patch + 1))" + ;; + *) + # release:minor, or no release label yet: a minor bump is valid for + # any compatible change, so this flags only incompatible changes. + PROPOSED="v${base_major}.$((base_minor + 1)).0" + ;; + esac + + # gorelease refuses to run against a dirty work tree, and the + # setup/install steps above can leave go.mod/go.sum modified. Restore + # tracked files to the committed PR head so gorelease evaluates exactly + # what's committed. + git checkout -- . + + # Bound the run: gorelease resolves the base tag against the unpruned + # module graph, which takes minutes on a large tree and burns the whole + # job timeout when a version in it is unresolvable. A missing coreutils + # is a broken runner rather than a baseline problem, so fail loudly. + if ! command -v timeout >/dev/null 2>&1; then + echo "::error::timeout(1) not found; cannot bound the gorelease run." + exit 1 + fi + + # The API report goes to stdout, operational errors to stderr. + err_file="${RUNNER_TEMP:-/tmp}/gorelease.err" + report=$(timeout "${GORELEASE_TIMEOUT_SECONDS}" gorelease -base="${BASE_TAG}" -version="${PROPOSED}" 2>"${err_file}") + gorelease_status=$? + tool_err=$(<"${err_file}") + + # Emit a PR comment whose body is $1 (markdown header lines) followed + # by $2 — defaulting to the gorelease report — in a quoted code block. + # The delimiter is randomised per run: the body embeds gorelease output, + # which can carry text from a dependency's go.mod (a retraction + # rationale is free-form). A fixed delimiter appearing in that text + # would close the heredoc early and let the rest set arbitrary outputs. + emit_comment() { + delim="GORELEASE_EOF_${RANDOM}${RANDOM}${RANDOM}" + { + echo "result<<${delim}" + printf '%s\n' "$1" + echo '> ```' + printf '%s\n' "${2:-${report}}" | awk '{print "> "$0}' + echo '> ```' + echo "${delim}" + } >> "$GITHUB_OUTPUT" + } + + # A report with no '#' section means gorelease died before judging the + # API. Not keyed off stderr (a valid report can carry harmless chatter) + # nor the exit status (non-zero for benign diagnostics like retractions). + if ! printf '%s\n' "${report}" | grep -q '^#'; then + if [ "${gorelease_status}" -eq 124 ]; then + detail="gorelease exceeded ${GORELEASE_TIMEOUT_SECONDS}s against ${BASE_TAG} and was terminated." + else + detail="gorelease exited ${gorelease_status} against ${BASE_TAG} without producing a report." + fi + + # An unresolvable baseline is not evidence of an API break, so warn + # instead of failing. Comment too: the delete step below is + # unconditional, so a skip would otherwise erase an earlier warning. + # stop-commands fences the stderr dump: it can contain text from a + # dependency's go.mod, and a '::' line would otherwise be run as a + # workflow command. + stop_token="gorelease-stderr-${RANDOM}${RANDOM}${RANDOM}" + echo "::stop-commands::${stop_token}" + printf '%s\n' "${detail}" "${tool_err}" + echo "::${stop_token}::" + echo "::warning::Skipped: ${detail} See this step's log for the full error." + emit_comment "> [!NOTE] + > ## :warning: API compatibility was not checked + > \`gorelease\` could not establish a baseline against \`${BASE_TAG}\`, so this PR's API impact is unverified." "${detail} + ${tool_err}" + elif printf '%s\n' "${report}" | awk ' + /^## incompatible changes$/ { in_section = 1; next } + /^#/ { in_section = 0 } + in_section && NF { found = 1 } + END { exit !found } + '; then + # Detect breaks from the report's "## incompatible changes" section + # rather than the summary verdict: gorelease only prints "There are + # incompatible changes." for v1+ modules whose proposed bump is too + # low. For a v0 module every version is "valid" (semver grants v0 no + # API guarantees), so the verdict never flags a removal; the section + # does. release:major is excluded above and PROPOSED is only ever a + # minor/patch bump, so any incompatible change means the label is + # insufficient regardless of major version. + emit_comment "> [!CAUTION] + > ## :rotating_light: Breaking API changes detected + > \`gorelease\` found incompatible changes vs \`${BASE_TAG}\`. If intentional, change the semver label to \`release:major\`." + elif printf '%s' "${report}" | grep -qF 'the minor version is not incremented'; then + emit_comment "> [!WARNING] + > ## :warning: Release label too low + > \`gorelease\` found compatible API additions vs \`${BASE_TAG}\`, but this PR is not labelled \`release:minor\`. Change the semver label to \`release:minor\`." + else + echo "Release label is sufficient for the API changes vs ${BASE_TAG}" + fi + + # Drop the App-token credential setup-go left cached (clear-token: + # 'false'). Always runs so the token never outlives the job. + - name: Clear git credentials + if: always() + shell: bash + run: git credential-cache exit || true + + - name: Delete previous gorelease comment + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 + with: + comment-tag: gorelease + mode: delete + + - name: Comment PR with release-label issue + if: ${{ steps.gorelease.outputs.result != '' }} + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 + with: + comment-tag: gorelease + mode: upsert + message: ${{ steps.gorelease.outputs.result }} diff --git a/.github/workflows/check-blocking-labels.yml b/.github/workflows/check-blocking-labels.yml index 3ef47ea..a52e4da 100644 --- a/.github/workflows/check-blocking-labels.yml +++ b/.github/workflows/check-blocking-labels.yml @@ -22,15 +22,11 @@ env: jobs: check_blocking_labels: name: Check for blocking labels - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -69,20 +65,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [check_blocking_labels] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/check-build.yml b/.github/workflows/check-build.yml index c1921a2..56bf11e 100644 --- a/.github/workflows/check-build.yml +++ b/.github/workflows/check-build.yml @@ -17,15 +17,11 @@ permissions: jobs: check_build: name: Ensure code can build - runs-on: ubuntu-latest + runs-on: go-4-core + timeout-minutes: 10 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false @@ -51,20 +47,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [check_build] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/check-do-not-edit.yml b/.github/workflows/check-do-not-edit.yml index e5f1770..9b5b492 100644 --- a/.github/workflows/check-do-not-edit.yml +++ b/.github/workflows/check-do-not-edit.yml @@ -14,15 +14,11 @@ concurrency: jobs: check-do-not-edit: - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 10 name: Check for DO NOT EDIT files steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Skip if the PR was created by paddle-repo-file-sync[bot] if: github.actor == 'paddle-repo-file-sync[bot]' run: | @@ -30,7 +26,7 @@ jobs: exit 0 - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -38,6 +34,8 @@ jobs: - name: Check for DO NOT EDIT warnings shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} run: | echo "Checking changed files for DO NOT EDIT warnings..." @@ -46,7 +44,8 @@ jobs: # Get changed files using git diff echo "Getting changed files from git diff..." - merge_base=$(git merge-base origin/${{ github.event.pull_request.base.ref }} HEAD) + base_ref="${BASE_REF#refs/heads/}" + merge_base=$(git merge-base "origin/${base_ref}" HEAD) echo "Merge base: $merge_base" changed_files=$(git diff --name-only --merge-base "$merge_base") @@ -91,20 +90,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [check-do-not-edit] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/check-semver.yml b/.github/workflows/check-semver.yml index 77f390e..161cf8d 100644 --- a/.github/workflows/check-semver.yml +++ b/.github/workflows/check-semver.yml @@ -24,23 +24,19 @@ env: jobs: check_semver: name: Ensure SemVer Label is added - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 if: github.event_name == 'pull_request' steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false - name: Read build config id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml @@ -80,20 +76,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [check_semver] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude-dependency-review.yml b/.github/workflows/claude-dependency-review.yml new file mode 100644 index 0000000..94478ef --- /dev/null +++ b/.github/workflows/claude-dependency-review.yml @@ -0,0 +1,178 @@ +# DO NOT EDIT: This file should only be modified in the `go-library-template` repo. + +# Supply-chain review for PRs that bump third-party (non-PaddleHQ) Go deps, +# via the shared PaddleHQ/supply-chain-action. This file owns only the +# repo-specific bits: the triage policy (which PRs to review) and the OTel +# export of the action's usage metrics. The review itself lives in the action. +# +# Orthogonal to govulncheck (CVE gate, in validate.yml) and +# check-action-security.yml (GH-Actions bumps) - this flags what scanners +# can't: maintainer changes, init() side effects, suspicious imports, intent +# mismatch. See the action's README for the full pipeline. + +name: Claude Dependency Security Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +# Queue concurrent runs per PR so a new push waits for any in-flight review +# instead of cancelling it. +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + triage: + name: Triage dependency changes + runs-on: generic-2-core + timeout-minutes: 5 + if: ${{ !github.event.pull_request.draft && vars.SUPPLY_CHAIN_REVIEW_ENABLED != 'false' }} + + steps: + - name: Verify ANTHROPIC_API_KEY available + id: check-secret + env: + # Intermediate env var: keeps the secret out of the shell source + # text the workflow engine would otherwise inline (per GitHub's + # security-hardening guide). + ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -z "${ANTHROPIC_KEY}" ]; then + echo "::notice::No ANTHROPIC_API_KEY org secret - dependency review disabled" + echo "should_review=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "should_review=true" >> "$GITHUB_OUTPUT" + + - name: Checkout PR + if: steps.check-secret.outputs.should_review == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 # full history for the merge-base + filter: blob:none # but skip file blobs - go.mod is fetched lazily + persist-credentials: false + + - name: Resolve merge base + if: steps.check-secret.outputs.should_review == 'true' + id: merge-base + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + MERGE_BASE=$(git merge-base "origin/${BASE_REF}" HEAD) + echo "merge_base=${MERGE_BASE}" >> "$GITHUB_OUTPUT" + + - name: Classify the dependency diff + if: steps.check-secret.outputs.should_review == 'true' + id: classify + env: + MERGE_BASE: ${{ steps.merge-base.outputs.merge_base }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Base go.mod via the API: under the blob:none checkout the + # merge-base blob isn't local and a post-checkout lazy fetch is + # unauthenticated. HEAD's go.mod is the working-tree copy. + gh api "repos/${REPOSITORY}/contents/go.mod?ref=${MERGE_BASE}" --jq '.content' \ + | base64 -d > "${RUNNER_TEMP}/base.go.mod" + + # Two booleans (0/1) off the go.mod diff. awk not grep: BSD grep + # mishandles mixed -/+ char classes and skips alternating matches. + diff_gomod() { diff -u "${RUNNER_TEMP}/base.go.mod" go.mod || true; } + has() { diff_gomod | awk "$1" | grep -q . && echo 1 || echo 0; } + PADDLEHQ=$(has '/^[+-][^+-]/ && /github\.com\/PaddleHQ\//') + THIRD_PARTY=$(has '/^[+-][^+-]/ && /[a-zA-Z0-9._~\/-]+\.[a-zA-Z0-9._~\/-]+ v[0-9]/ && !/github\.com\/PaddleHQ\//') + [ "${PR_AUTHOR}" = "dependabot[bot]" ] && BOT=1 || BOT=0 + + # Decision matrix, keyed bot:paddlehq:thirdparty. A dependabot bump + # that touches a PaddleHQ module is skipped - its transitive deps + # were already reviewed in the upstream PaddleHQ release. Everything + # else reviews iff it introduces a third-party require change. + case "${BOT}:${PADDLEHQ}:${THIRD_PARTY}" in + 1:1:*) decision=skip; reason="dependabot bump touches a PaddleHQ module (reviewed upstream)" ;; + *:*:0) decision=skip; reason="no third-party require change" ;; + *) decision=review; reason="third-party require change" ;; + esac + + echo "::notice::Triage (bot=${BOT} paddlehq=${PADDLEHQ} third-party=${THIRD_PARTY}) → ${decision}: ${reason}" + [ "${decision}" = "review" ] && echo "should_review=true" >> "$GITHUB_OUTPUT" || echo "should_review=false" >> "$GITHUB_OUTPUT" + + outputs: + should_review: ${{ steps.check-secret.outputs.should_review == 'true' && steps.classify.outputs.should_review || 'false' }} + merge_base: ${{ steps.merge-base.outputs.merge_base }} + + review: + name: Supply-chain review + needs: triage + if: needs.triage.outputs.should_review == 'true' + runs-on: generic-2-core + timeout-minutes: 20 + permissions: + contents: read + pull-requests: write + id-token: write + actions: read + + steps: + - name: Checkout PR + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 # full history for the merge-base + filter: blob:none # but skip file blobs - go.mod is fetched lazily + persist-credentials: false + + - name: Supply-chain review + id: review + uses: PaddleHQ/supply-chain-action@cba07ecfe35f1ebab6d2d73e4748ecba41dc13f5 # v0.3.3 + with: + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} + merge-base: ${{ needs.triage.outputs.merge_base }} + + # Pass the whole outputs object through as one JSON blob; the otel job + # turns it into claude.* attributes generically (no per-field plumbing). + outputs: + metrics: ${{ toJSON(steps.review.outputs) }} + + otel_cicd_export: + name: OpenTelemetry Export Trace + if: always() + needs: [triage, review] + runs-on: generic-2-core + timeout-minutes: 5 + permissions: + contents: read + actions: read + steps: + # Turn the review job's metrics JSON into `claude.=,...` pairs: + # drop empty values (absent on cache hit / skip), hyphens → underscores. + - name: Build claude.* resource attributes + id: attrs + # Only when the review job actually produced metrics. Skipped/failed + # review → empty output → nothing to attach (the export step still + # runs on always() to capture the workflow trace). + if: needs.review.outputs.metrics != '' + env: + METRICS: ${{ needs.review.outputs.metrics }} + run: | + PAIRS=$(printf '%s' "$METRICS" | jq -r ' + to_entries + | map(select(.value != "" and .value != "null")) + | map("claude.\(.key | gsub("-"; "_"))=\(.value)") + | join(",")') + echo "pairs=${PAIRS}" >> "$GITHUB_OUTPUT" + echo "Resource attributes: ${PAIRS}" + + - name: Export workflow trace + uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + continue-on-error: true + with: + otlpEndpoint: grpc://api.honeycomb.io:443/ + otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" + githubToken: ${{ secrets.GITHUB_TOKEN }} + extraAttributes: ${{ steps.attrs.outputs.pairs }} diff --git a/.github/workflows/common-lint.yml b/.github/workflows/common-lint.yml index fcb53b5..548241b 100644 --- a/.github/workflows/common-lint.yml +++ b/.github/workflows/common-lint.yml @@ -19,15 +19,11 @@ permissions: jobs: common-lint: name: Ensure code passes generic linting rules - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 10 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: true # Required so that reviewdog can fallback to git command @@ -57,20 +53,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [common-lint] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index e478d02..447eff9 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -38,18 +38,14 @@ concurrency: jobs: discover: name: Discover fuzz targets - runs-on: ubuntu-24.04 + runs-on: go-4-core + timeout-minutes: 10 outputs: matrix: ${{ steps.list.outputs.matrix }} has_targets: ${{ steps.list.outputs.has_targets }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -95,7 +91,7 @@ jobs: name: Fuzz ${{ matrix.name }} needs: discover if: needs.discover.outputs.has_targets == 'true' - runs-on: ubuntu-24.04 + runs-on: go-4-core timeout-minutes: 90 permissions: contents: write @@ -105,21 +101,16 @@ jobs: max-parallel: 4 matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Create GitHub App Token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: 923765 private-key: ${{ secrets.REPO_FILE_SYNC_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # peter-evans/create-pull-request handles its own git auth; leaving # persist-credentials on causes a "Duplicate header: Authorization" @@ -140,7 +131,7 @@ jobs: # written post-job; restore-keys picks up the most recent entry for # this target when no exact match exists. - name: Restore fuzz cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/go-build/fuzz key: fuzz-${{ matrix.name }}-${{ github.run_id }} @@ -219,20 +210,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [discover, fuzz] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index 36b0277..3427642 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -22,17 +22,35 @@ permissions: actions: write # required for `workflow-dispatch` to trigger the automerge workflow jobs: - lint: - name: Ensure code passes linting rules - runs-on: ubuntu-latest + read-config: + name: Read build configuration + runs-on: generic-2-core + timeout-minutes: 5 + outputs: + runsOn: ${{ steps.build_config.outputs.runsOn }} + lintTimeout: ${{ steps.build_config.outputs.lintTimeout }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + persist-credentials: false + + - name: Read build config + id: build_config + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: - egress-policy: audit + config: ${{ github.workspace }}/build-config.yaml + lint: + name: Ensure code passes linting rules + needs: + - read-config + runs-on: ${{ needs.read-config.outputs.runsOn || 'go-4-core' }} + timeout-minutes: 20 + steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false @@ -45,12 +63,6 @@ jobs: private: ${{ github.event.repository.private }} private-key: ${{ secrets.PRIVATE_GO_CI_PRIVATE_KEY }} - - name: Read build config - id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 - with: - config: ${{ github.workspace }}/build-config.yaml - - name: Resolve base ref id: base run: | @@ -61,27 +73,16 @@ jobs: - name: Determine lint scope id: scope - run: | - base_sha="${{ steps.base.outputs.sha }}" - if git diff --name-only "$base_sha"..HEAD | grep -qE '(^|/)\.golangci\.(yml|yaml)$'; then - # Lint config changed — lint everything so new rules are enforced repo-wide. - echo "pkgs=./..." >> "$GITHUB_OUTPUT" - echo "new_from_rev=" >> "$GITHUB_OUTPUT" - else - pkgs=$(git diff --name-only --diff-filter=d "$base_sha"..HEAD -- '*.go' \ - | xargs -I{} dirname {} \ - | sort -u \ - | sed 's|^|./|' \ - | tr '\n' ' ') - echo "pkgs=$pkgs" >> "$GITHUB_OUTPUT" - echo "new_from_rev=--new-from-rev=$base_sha" >> "$GITHUB_OUTPUT" - fi + uses: ./.github/actions/lint-scope + with: + base-sha: ${{ steps.base.outputs.sha }} - name: golangci-lint - uses: step-security/golangci-lint-action@1797facf9ea427614d729a4e9cab0fae1a7852d9 # v9.2.0 + if: steps.scope.outputs.skip != 'true' + uses: step-security/golangci-lint-action@2697867e9c204558ca23d0decdd0039dc21f2b93 # v9.3.0 with: version: v2.5.0 - args: --timeout=${{ steps.build_config.outputs['lintTimeout'] || '5m' }} ${{ steps.scope.outputs.new_from_rev }} ${{ steps.scope.outputs.pkgs }} + args: --timeout=${{ needs.read-config.outputs.lintTimeout || '5m' }} ${{ steps.scope.outputs.new-from-rev }} ${{ steps.scope.outputs.pkgs }} verify: false debug: cache @@ -95,20 +96,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [lint] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/major.yml b/.github/workflows/major.yml index 1c9f692..0bddba3 100644 --- a/.github/workflows/major.yml +++ b/.github/workflows/major.yml @@ -17,15 +17,16 @@ permissions: jobs: major: name: Check for major dependency updates - runs-on: ubuntu-latest + # Only run on Dependabot PRs to reduce noise: the major-dependency advisory + # comment is most useful on dependency-bump PRs, not on every human PR. + # Automerge is still triggered for non-Dependabot PRs via the trigger-automerge + # step in the other synced workflows (test, validate, check-build, ...). + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: go-2-core + timeout-minutes: 10 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -39,7 +40,10 @@ jobs: clear-token: 'false' - name: Install `gomajor` - run: go install github.com/icholy/gomajor@4439c423d4f92ed1214c2a51750b6c34c4c70aa6 # v0.14.0 + # PaddleHQ fork of gomajor: resolves private github.com/PaddleHQ/* modules + # (via `go list`) so their major upgrades are surfaced too. Upstream + # icholy/gomajor silently skips private modules. + run: go install github.com/PaddleHQ-Forks/gomajor@2f09cbe011caa95b28cdb595bc90806278448b8e # v0.16.0 - name: Run `gomajor` id: gomajor @@ -50,21 +54,51 @@ jobs: # see https://paddle.slack.com/archives/CCALA6J23/p1744297603199989 go_path=$(go env GOPATH) export PATH=${PATH}:${go_path}/bin + raw="$(gomajor list -major -pre)" + deps="$(printf '%s\n' "$raw" | grep -v 'github.com/vektra/mockery/v2' || true)" { echo 'major_dependencies< "$0}' + printf '%s\n' "$deps" | awk 'NF {print "> "$0}' echo EOF } >> "$GITHUB_OUTPUT" + # Flag when PaddleHQ-owned modules are outstanding: previous major + # versions of our own modules are not maintained, so the comment step + # escalates the styling (danger vs warning) to prioritise them. + if printf '%s\n' "$deps" | grep -q '^github\.com/PaddleHQ/'; then + echo 'has_paddle_deps=true' >> "$GITHUB_OUTPUT" + else + echo 'has_paddle_deps=false' >> "$GITHUB_OUTPUT" + fi - name: Remove git token from cache shell: bash run: git credential-cache exit - - name: Comment PR to update major dependencies - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + # PaddleHQ modules outstanding -> danger styling: previous major versions of + # our own modules are not maintained, so these should be prioritised. This is + # advisory only — it does not block automerge. + - name: Comment PR — PaddleHQ major dependencies outstanding (danger) + if: steps.gomajor.outputs.major_dependencies != '' && steps.gomajor.outputs.has_paddle_deps == 'true' + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 + with: + comment-tag: major_dependencies + mode: upsert + message: | + > [!CAUTION] + > ## :rotating_light: PaddleHQ major upgrade outstanding + > + > The upgrades below include **PaddleHQ** modules. We don't maintain previous major versions, so please prioritise these — they won't be picked up automatically. + > ``` + ${{ steps.gomajor.outputs.major_dependencies }} + > ``` + + # Only third-party modules outstanding -> warning (advisory). + - name: Comment PR — third-party major dependencies outstanding (warning) + if: steps.gomajor.outputs.major_dependencies != '' && steps.gomajor.outputs.has_paddle_deps != 'true' + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: major_dependencies - mode: ${{ steps.gomajor.outputs.major_dependencies != '' && 'upsert' || 'delete' }} + mode: upsert message: | > [!IMPORTANT] > ## :warning: Major dependencies need updating :warning: @@ -74,6 +108,14 @@ jobs: ${{ steps.gomajor.outputs.major_dependencies }} > ``` + # Nothing outstanding -> remove any stale comment. + - name: Delete major dependencies comment when none outstanding + if: steps.gomajor.outputs.major_dependencies == '' + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 + with: + comment-tag: major_dependencies + mode: delete + - name: Automerge uses: ./.github/actions/trigger-automerge with: @@ -84,20 +126,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [major] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index b461bca..a1b0c88 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -18,15 +18,11 @@ permissions: jobs: pr_size: name: Label PRs with size and comment if too large - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 10 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -40,8 +36,10 @@ jobs: id: lines_changed env: PATTERNS: ${{ steps.generated_paths.outputs.patterns }} + BASE_REF: ${{ github.event.pull_request.base.ref }} run: | - merge_base=$(git merge-base origin/${{ github.event.pull_request.base.ref }} HEAD) + base_ref="${BASE_REF#refs/heads/}" + merge_base=$(git merge-base "origin/${base_ref}" HEAD) # Build git pathspec exclusions from the resolved pattern list. # `glob` magic is needed for `**` to match across path segments. @@ -89,7 +87,7 @@ jobs: gh pr edit ${{ github.event.pull_request.number }} --add-label "size/${SIZE}" - name: Comment PR to warn about PR size - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: very_large_pr mode: ${{ steps.lines_changed.outputs.total >= 1000 && 'upsert' || 'delete' }} @@ -110,20 +108,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [pr_size] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-on-push.yml b/.github/workflows/release-on-push.yml index 5b5cf77..1cc9a77 100644 --- a/.github/workflows/release-on-push.yml +++ b/.github/workflows/release-on-push.yml @@ -17,17 +17,13 @@ permissions: jobs: release_on_push: name: Create release on push to main branch - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Create release based on label - uses: step-security/release-on-push-action@cb8d0f4644b90de8de1c49113ad7e66f13224c3d # v0.28.5 + uses: step-security/release-on-push-action@b9dee0685e99e905fe90cba8ba9ed92c7f3806b4 # v0.28.6 with: bump_version_scheme: norelease use_github_release_notes: true @@ -35,7 +31,7 @@ jobs: - name: Checkout code # required for automerge if: format('refs/heads/{0}', github.event.repository.default_branch) != github.ref - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -51,20 +47,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [release_on_push] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 91e03c2..2758200 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,91 +28,46 @@ permissions: jobs: read-config: name: Read build configuration - runs-on: ubuntu-24.04 + runs-on: generic-2-core + timeout-minutes: 10 outputs: testRunner: ${{ steps.build_config.outputs.testRunner }} separateRaceTests: ${{ steps.build_config.outputs['separateRaceTests'] }} runsOn: ${{ steps.build_config.outputs.runsOn }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false - name: Read build config id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml - ecr-login: - name: ECR login for test container - runs-on: ubuntu-24.04 - needs: read-config - outputs: - token: ${{ steps.ecr-token.outputs.token }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - - name: Configure AWS credentials - if: needs.read-config.outputs.testRunner != '' - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 - with: - audience: sts.amazonaws.com - role-session-name: paddle-gha-ecr-${{ github.event.repository.name }} - role-to-assume: arn:aws:iam::067421297632:role/paddle-tfecr-gha-ecr-${{ github.event.repository.name }} - aws-region: us-east-1 - role-duration-seconds: 900 - retry-max-attempts: 3 - - - name: Get ECR login token - if: needs.read-config.outputs.testRunner != '' - id: ecr-token - run: | - echo "token=$(aws ecr get-login-password --region ${{ env.AWS_REGION }})" >> "$GITHUB_OUTPUT" - test: - name: Run tests and validate coverage + name: Run tests with coverage if: needs.read-config.outputs.separateRaceTests == 'true' needs: - read-config - - ecr-login - runs-on: ${{ needs.read-config.outputs.runsOn || 'ubuntu-24.04' }} - container: ${{ needs.read-config.outputs.testRunner != '' && fromJSON(format('{{"image":"{0}","credentials":{{"username":"AWS","password":"{1}"}}}}', needs.read-config.outputs.testRunner, needs.ecr-login.outputs.token)) || '' }} + runs-on: ${{ needs.read-config.outputs.runsOn || 'go-4-core' }} + timeout-minutes: 30 steps: - - name: Mask ECR token in logs - if: needs.read-config.outputs.testRunner != '' - run: echo "::add-mask::${{ needs.ecr-login.outputs.token }}" - - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false - - name: Allow git in workspace (required for Go -buildvcs in container) - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Set up Go uses: ./.github/actions/setup-go with: owner: ${{ github.repository_owner }} private: ${{ github.event.repository.private }} private-key: ${{ secrets.PRIVATE_GO_CI_PRIVATE_KEY }} + cache-suffix: cover - name: Download external dependencies run: | @@ -126,23 +81,15 @@ jobs: curl -L -o "${target}" "${source}" # Copy the file done - - name: Cache test results - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Install Go tools + uses: ./.github/actions/install-go-tools with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-test-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/*.go') }} - restore-keys: | - ${{ runner.os }}-go-test-${{ hashFiles('**/go.sum') }}- - ${{ runner.os }}-go-test- - - - name: Install test dependencies - run: | - go install gotest.tools/gotestsum@v1.13.0 - go install github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter@v0.0.14 + tools: | + gotestsum + go-ctrf-json-reporter - name: Setup test databases + id: setup-databases uses: ./.github/actions/setup-databases - name: Setup Env @@ -153,22 +100,90 @@ jobs: echo "${source}=${target}" >> "$GITHUB_ENV" done + - name: Configure AWS credentials for ECR pull + if: needs.read-config.outputs.testRunner != '' + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + audience: sts.amazonaws.com + role-session-name: paddle-gha-ecr-${{ github.event.repository.name }} + role-to-assume: arn:aws:iam::067421297632:role/paddle-tfecr-gha-ecr-${{ github.event.repository.name }} + aws-region: us-east-1 + role-duration-seconds: 900 + retry-max-attempts: 3 + + - name: Log in to Amazon ECR + if: needs.read-config.outputs.testRunner != '' + uses: step-security/amazon-ecr-login@b8045438dc204276b62463a5e3cfedca8cb0dca0 # v2.1.6 + - name: Run tests with coverage timeout-minutes: 20 env: ENVIRONMENT: ci CGO_ENABLED: 1 + TEST_IMAGE: ${{ needs.read-config.outputs.testRunner }} run: | - gotestsum \ - --format testname \ - --jsonfile test-report.json \ - -- \ - -coverprofile=cover.out \ - -covermode=atomic \ - -coverpkg ./... \ - -buildvcs=true \ - -shuffle=on \ - ./... + set -euo pipefail + mkdir -p "${GITHUB_WORKSPACE}/covdata" + gotestsum_args=( + --format testname + --jsonfile test-report.json + --rerun-fails + --rerun-fails-abort-on-data-race + --packages=./... + -- + -cover + -covermode=atomic + -coverpkg ./... + -buildvcs=true + -shuffle=on + -args -test.gocoverdir="${GITHUB_WORKSPACE}/covdata" + ) + + if [ -z "$TEST_IMAGE" ]; then + gotestsum "${gotestsum_args[@]}" + exit 0 + fi + + # Run go test inside the configured testRunner image. Host-side + # steps (checkout, setup-go, setup-databases, JS-based reporting) + # stay on the host so GHA's JS-Actions-in-Alpine-arm64 restriction + # doesn't bite. --network host lets the containerised tests reach + # databases setup-databases provisioned on the host. --user matches + # the host UID so output files (coverage, CTRF) are readable by + # subsequent host steps. + docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" -w "$GITHUB_WORKSPACE" \ + -v "$(go env GOMODCACHE):/tmp/gomodcache" \ + -v "$(go env GOCACHE):/tmp/gocache" \ + -e HOME=/tmp \ + -e GOMODCACHE=/tmp/gomodcache \ + -e GOCACHE=/tmp/gocache \ + -e ENVIRONMENT -e CGO_ENABLED \ + "$TEST_IMAGE" \ + gotestsum "${gotestsum_args[@]}" + + - name: Merge coverage profile + # gotestsum writes binary coverage into covdata/ (-test.gocoverdir), + # which accumulates across any --rerun-fails re-invocations. Merge it + # into the cover.out the downstream coverage steps read. An empty dir + # (a repo with no tests) yields a mode-only profile. + run: | + set -euo pipefail + if [ -n "$(ls -A "${GITHUB_WORKSPACE}/covdata" 2>/dev/null)" ]; then + go tool covdata textfmt -i="${GITHUB_WORKSPACE}/covdata" -o cover.out + else + echo "mode: atomic" > cover.out + fi + + - name: Snapshot Postgres template cache + if: always() + uses: ./.github/actions/snapshot-postgres-templates + with: + testament-imported: ${{ steps.setup-databases.outputs.testament-imported }} + postgres-template-cache-hit: ${{ steps.setup-databases.outputs.postgres-template-cache-hit }} + postgres-major-version: ${{ steps.setup-databases.outputs.postgres-major-version }} - name: Convert test reports to CTRF if: always() && github.event_name == 'pull_request' @@ -177,7 +192,7 @@ jobs: - name: Publish Test Report if: always() && github.event_name == 'pull_request' - uses: ctrf-io/github-test-reporter@0f299074936c32ccaab5be5230511f6b2b9080aa # v1.0.28 + uses: ctrf-io/github-test-reporter@e500b992f936420eb633c91644cf10d4d71df700 # v1.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -202,7 +217,7 @@ jobs: - name: Read build config id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml @@ -216,7 +231,7 @@ jobs: - name: Ensure coverage is good if: github.event_name == 'pull_request' - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: build_coverage mode: ${{ fromJSON(env.TOTAL_COVERAGE) < (steps.build_config.outputs['requiredCoverage'] || 100) && 'upsert' || 'delete' }} @@ -231,9 +246,32 @@ jobs: if: fromJSON(env.TOTAL_COVERAGE) < (steps.build_config.outputs['requiredCoverage'] || 100) run: exit 1 + # Emitted from the merge-queue run only: that build is the merge commit + # about to land on main, so its coverage is main's coverage. One event + # per merge to the ci-coverage dataset; graph MAX(coverage.total) by + # service.name over time. + - name: Push coverage to Honeycomb + if: github.event_name == 'merge_group' + continue-on-error: true + env: + HONEYCOMB_TEAM: ${{ secrets.HONEYCOMB_GHA_SECRET }} + REQUIRED_COVERAGE: ${{ steps.build_config.outputs['requiredCoverage'] || 100 }} + SERVICE_NAME: ${{ github.event.repository.name }} + COMMIT_SHA: ${{ github.event.merge_group.head_sha }} + run: | + curl -sf https://api.honeycomb.io/1/events/ci-coverage \ + -H "X-Honeycomb-Team: $HONEYCOMB_TEAM" \ + -H "Content-Type: application/json" \ + -d "$(jq -nc \ + --argjson total "$TOTAL_COVERAGE" \ + --argjson required "$REQUIRED_COVERAGE" \ + --arg service "$SERVICE_NAME" \ + --arg commit "$COMMIT_SHA" \ + '{"coverage.total": $total, "coverage.required": $required, "service.name": $service, "branch": "main", "commit": $commit}')" + - name: Comment PR to update build coverage if: github.event_name == 'pull_request' - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: build_coverage mode: ${{ fromJSON(env.TOTAL_COVERAGE) > fromJSON(env.TARGET_COVERAGE) && 'upsert' || 'delete' }} @@ -257,34 +295,22 @@ jobs: if: needs.read-config.outputs.separateRaceTests != 'true' needs: - read-config - - ecr-login - runs-on: ${{ needs.read-config.outputs.runsOn || 'ubuntu-24.04' }} - container: ${{ needs.read-config.outputs.testRunner != '' && fromJSON(format('{{"image":"{0}","credentials":{{"username":"AWS","password":"{1}"}}}}', needs.read-config.outputs.testRunner, needs.ecr-login.outputs.token)) || '' }} + runs-on: ${{ needs.read-config.outputs.runsOn || 'go-4-core' }} + timeout-minutes: 30 steps: - - name: Mask ECR token in logs - if: needs.read-config.outputs.testRunner != '' - run: echo "::add-mask::${{ needs.ecr-login.outputs.token }}" - - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false - - name: Allow git in workspace (required for Go -buildvcs in container) - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Set up Go uses: ./.github/actions/setup-go with: owner: ${{ github.repository_owner }} private: ${{ github.event.repository.private }} private-key: ${{ secrets.PRIVATE_GO_CI_PRIVATE_KEY }} + cache-suffix: race - name: Download external dependencies run: | @@ -298,23 +324,15 @@ jobs: curl -L -o "${target}" "${source}" # Copy the file done - - name: Cache test results - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Install Go tools + uses: ./.github/actions/install-go-tools with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-test-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/*.go') }} - restore-keys: | - ${{ runner.os }}-go-test-${{ hashFiles('**/go.sum') }}- - ${{ runner.os }}-go-test- - - - name: Install test dependencies - run: | - go install gotest.tools/gotestsum@v1.13.0 - go install github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter@v0.0.14 + tools: | + gotestsum + go-ctrf-json-reporter - name: Setup test databases + id: setup-databases uses: ./.github/actions/setup-databases - name: Setup Env @@ -325,24 +343,93 @@ jobs: echo "${source}=${target}" >> "$GITHUB_ENV" done + - name: Configure AWS credentials for ECR pull + if: needs.read-config.outputs.testRunner != '' + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + audience: sts.amazonaws.com + role-session-name: paddle-gha-ecr-${{ github.event.repository.name }} + role-to-assume: arn:aws:iam::067421297632:role/paddle-tfecr-gha-ecr-${{ github.event.repository.name }} + aws-region: us-east-1 + role-duration-seconds: 900 + retry-max-attempts: 3 + + - name: Log in to Amazon ECR + if: needs.read-config.outputs.testRunner != '' + uses: step-security/amazon-ecr-login@b8045438dc204276b62463a5e3cfedca8cb0dca0 # v2.1.6 + - name: Run tests with coverage and race detector timeout-minutes: 20 env: ENVIRONMENT: ci CGO_ENABLED: 1 + GORACE: atexit_sleep_ms=50 + TEST_IMAGE: ${{ needs.read-config.outputs.testRunner }} run: | - gotestsum \ - --format testname \ - --jsonfile test-report.json \ - -- \ - -v \ - -coverprofile=cover.out \ - -covermode=atomic \ - -coverpkg ./... \ - -race \ - -buildvcs=true \ - -shuffle=on \ - ./... + set -euo pipefail + mkdir -p "${GITHUB_WORKSPACE}/covdata" + gotestsum_args=( + --format testname + --jsonfile test-report.json + --rerun-fails + --rerun-fails-abort-on-data-race + --packages=./... + -- + -v + -cover + -covermode=atomic + -coverpkg ./... + -race + -buildvcs=true + -shuffle=on + -args -test.gocoverdir="${GITHUB_WORKSPACE}/covdata" + ) + + if [ -z "$TEST_IMAGE" ]; then + gotestsum "${gotestsum_args[@]}" + exit 0 + fi + + # Run go test inside the configured testRunner image. Host-side + # steps (checkout, setup-go, setup-databases, JS-based reporting) + # stay on the host so GHA's JS-Actions-in-Alpine-arm64 restriction + # doesn't bite. --network host lets the containerised tests reach + # databases setup-databases provisioned on the host. --user matches + # the host UID so output files (coverage, CTRF) are readable by + # subsequent host steps. + docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" -w "$GITHUB_WORKSPACE" \ + -v "$(go env GOMODCACHE):/tmp/gomodcache" \ + -v "$(go env GOCACHE):/tmp/gocache" \ + -e HOME=/tmp \ + -e GOMODCACHE=/tmp/gomodcache \ + -e GOCACHE=/tmp/gocache \ + -e ENVIRONMENT -e CGO_ENABLED -e GORACE \ + "$TEST_IMAGE" \ + gotestsum "${gotestsum_args[@]}" + + - name: Merge coverage profile + # gotestsum writes binary coverage into covdata/ (-test.gocoverdir), + # which accumulates across any --rerun-fails re-invocations. Merge it + # into the cover.out the downstream coverage steps read. An empty dir + # (a repo with no tests) yields a mode-only profile. + run: | + set -euo pipefail + if [ -n "$(ls -A "${GITHUB_WORKSPACE}/covdata" 2>/dev/null)" ]; then + go tool covdata textfmt -i="${GITHUB_WORKSPACE}/covdata" -o cover.out + else + echo "mode: atomic" > cover.out + fi + + - name: Snapshot Postgres template cache + if: always() + uses: ./.github/actions/snapshot-postgres-templates + with: + testament-imported: ${{ steps.setup-databases.outputs.testament-imported }} + postgres-template-cache-hit: ${{ steps.setup-databases.outputs.postgres-template-cache-hit }} + postgres-major-version: ${{ steps.setup-databases.outputs.postgres-major-version }} - name: Convert test reports to CTRF if: always() @@ -351,7 +438,7 @@ jobs: - name: Publish Test Report if: always() - uses: ctrf-io/github-test-reporter@0f299074936c32ccaab5be5230511f6b2b9080aa # v1.0.28 + uses: ctrf-io/github-test-reporter@e500b992f936420eb633c91644cf10d4d71df700 # v1.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -376,7 +463,7 @@ jobs: - name: Read build config id: build_config - uses: step-security/action-read-yaml@08f859a2769067ea7fd26c1cd03a9b940c0ac01b # v1.0.0 + uses: step-security/action-read-yaml@705ee0c6475c5a26a356e79ee380eb3527b3772d # v1.0.1 with: config: ${{ github.workspace }}/build-config.yaml @@ -390,7 +477,7 @@ jobs: - name: Ensure coverage is good if: github.event_name == 'pull_request' - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: build_coverage mode: ${{ fromJSON(env.TOTAL_COVERAGE) < (steps.build_config.outputs['requiredCoverage'] || 100) && 'upsert' || 'delete' }} @@ -405,9 +492,32 @@ jobs: if: fromJSON(env.TOTAL_COVERAGE) < (steps.build_config.outputs['requiredCoverage'] || 100) run: exit 1 + # Emitted from the merge-queue run only: that build is the merge commit + # about to land on main, so its coverage is main's coverage. One event + # per merge to the ci-coverage dataset; graph MAX(coverage.total) by + # service.name over time. + - name: Push coverage to Honeycomb + if: github.event_name == 'merge_group' + continue-on-error: true + env: + HONEYCOMB_TEAM: ${{ secrets.HONEYCOMB_GHA_SECRET }} + REQUIRED_COVERAGE: ${{ steps.build_config.outputs['requiredCoverage'] || 100 }} + SERVICE_NAME: ${{ github.event.repository.name }} + COMMIT_SHA: ${{ github.event.merge_group.head_sha }} + run: | + curl -sf https://api.honeycomb.io/1/events/ci-coverage \ + -H "X-Honeycomb-Team: $HONEYCOMB_TEAM" \ + -H "Content-Type: application/json" \ + -d "$(jq -nc \ + --argjson total "$TOTAL_COVERAGE" \ + --argjson required "$REQUIRED_COVERAGE" \ + --arg service "$SERVICE_NAME" \ + --arg commit "$COMMIT_SHA" \ + '{"coverage.total": $total, "coverage.required": $required, "service.name": $service, "branch": "main", "commit": $commit}')" + - name: Comment PR to update build coverage if: github.event_name == 'pull_request' - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: build_coverage mode: ${{ fromJSON(env.TOTAL_COVERAGE) > fromJSON(env.TARGET_COVERAGE) && 'upsert' || 'delete' }} @@ -431,15 +541,11 @@ jobs: if: needs.read-config.outputs.separateRaceTests == 'true' needs: - read-config - runs-on: ${{ needs.read-config.outputs.runsOn || 'ubuntu-24.04' }} + runs-on: ${{ needs.read-config.outputs.runsOn || 'go-4-core' }} + timeout-minutes: 30 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false @@ -450,6 +556,7 @@ jobs: owner: ${{ github.repository_owner }} private: ${{ github.event.repository.private }} private-key: ${{ secrets.PRIVATE_GO_CI_PRIVATE_KEY }} + cache-suffix: race - name: Download external dependencies run: | @@ -463,16 +570,9 @@ jobs: curl -L -o "${target}" "${source}" # Copy the file done - - name: Cache test results - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-test-race-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/*.go') }} - restore-keys: | - ${{ runner.os }}-go-test-race-${{ hashFiles('**/go.sum') }}- - ${{ runner.os }}-go-test-race- + - name: Setup test databases + id: setup-databases + uses: ./.github/actions/setup-databases - name: Setup Env run: | @@ -487,8 +587,10 @@ jobs: env: ENVIRONMENT: ci CGO_ENABLED: 1 + GORACE: atexit_sleep_ms=50 run: | - mapfile -t test_packages < <(go list ./... | grep -v "^github.com/${{ github.repository }}$") + module=$(go list -m) + mapfile -t test_packages < <(go list ./... | grep -v "^${module}$") go test \ -race \ @@ -496,32 +598,46 @@ jobs: -shuffle=on \ "${test_packages[@]}" - automerge_tests: - name: Automerge unit tests with and without race detector + - name: Snapshot Postgres template cache + if: always() + uses: ./.github/actions/snapshot-postgres-templates + with: + testament-imported: ${{ steps.setup-databases.outputs.testament-imported }} + postgres-template-cache-hit: ${{ steps.setup-databases.outputs.postgres-template-cache-hit }} + postgres-major-version: ${{ steps.setup-databases.outputs.postgres-major-version }} + + validate_tests: + name: Run tests and validate coverage needs: - read-config - test-combined - test - race - # `test-combined` and (`test` + `race`) are mutually exclusive paths - # selected by `separateRaceTests`, so one branch is always skipped. - # Without this condition the default `success()` gate skips this job - # whenever any need is skipped — meaning the automerge trigger never - # fires. - if: |- - !cancelled() && ( - needs.test-combined.result == 'success' || - (needs.test.result == 'success' && needs.race.result == 'success') - ) - runs-on: ubuntu-24.04 + # Always run so the required status check resolves to the actual outcome + # of whichever mutex path ran. A SKIPPED required check is treated as + # passing by branch protections, which previously let failing test runs + # merge when the named-required job happened to be the skipped one. + if: always() && !cancelled() + runs-on: generic-2-core + timeout-minutes: 10 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit + - name: Validate test results + env: + TEST_COMBINED: ${{ needs.test-combined.result }} + TEST: ${{ needs.test.result }} + RACE: ${{ needs.race.result }} + run: | + if [[ "$TEST_COMBINED" == "success" ]]; then + exit 0 + fi + if [[ "$TEST" == "success" && "$RACE" == "success" ]]; then + exit 0 + fi + echo "Tests did not succeed: test-combined=$TEST_COMBINED test=$TEST race=$RACE" + exit 1 - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false @@ -535,32 +651,26 @@ jobs: otel_cicd_export: name: OpenTelemetry Export Trace if: always() - needs: [read-config, ecr-login, test, test-combined, race, automerge_tests] - runs-on: ubuntu-latest + needs: [read-config, test, test-combined, race, validate_tests] + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit - - - name: Fetch workflow run timestamps - id: run_times - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - json=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}") - echo "created_at=$(echo "$json" | jq -r '.created_at')" >> "$GITHUB_OUTPUT" - echo "run_started_at=$(echo "$json" | jq -r '.run_started_at')" >> "$GITHUB_OUTPUT" + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - extraAttributes: "github.workflow_run.created_at=${{ steps.run_times.outputs.created_at }},github.workflow_run.run_started_at=${{ steps.run_times.outputs.run_started_at }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/trigger-workflows-after-checks-complete.yml b/.github/workflows/trigger-workflows-after-checks-complete.yml index a7b7bfc..b81725f 100644 --- a/.github/workflows/trigger-workflows-after-checks-complete.yml +++ b/.github/workflows/trigger-workflows-after-checks-complete.yml @@ -16,15 +16,11 @@ permissions: jobs: trigger_workflows_after_checks_complete: name: Trigger workflows after Checks complete - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 30 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.check_run.head_sha }} persist-credentials: false @@ -34,8 +30,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - PR_DATA=$(gh api "repos/${{ github.repository }}/commits/${{ github.event.check_run.head_sha }}/pulls" --jq '.[0]') - if [ "$PR_DATA" = "null" ]; then + # Treat an empty or failed PR lookup the same as "no PR": default to + # `{}` so the downstream `event` payload stays valid JSON. + PR_DATA=$(gh api "repos/${{ github.repository }}/commits/${{ github.event.check_run.head_sha }}/pulls" --jq '.[0] // empty' || true) + if [ -z "$PR_DATA" ] || [ "$PR_DATA" = "null" ]; then echo "No pull request found for this commit" echo "pr_data={}" >> "$GITHUB_OUTPUT" else @@ -53,20 +51,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [trigger_workflows_after_checks_complete] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5f053f9..32544a4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,15 +24,11 @@ permissions: jobs: validate: name: Run code validation checks - runs-on: ubuntu-24.04 + runs-on: go-4-core + timeout-minutes: 20 steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} persist-credentials: false @@ -45,28 +41,16 @@ jobs: owner: ${{ github.repository_owner }} private: ${{ github.event.repository.private }} private-key: ${{ secrets.PRIVATE_GO_CI_PRIVATE_KEY }} + cache-suffix: validate - - name: Cache go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Install Go tools + uses: ./.github/actions/install-go-tools with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-validate-${{ hashFiles('**/go.sum') }}-${{ hashFiles('**/*.go') }} - restore-keys: | - ${{ runner.os }}-go-validate-${{ hashFiles('**/go.sum') }}- - ${{ runner.os }}-go-validate- - - - name: Install `govulncheck`, `gopls`, `modfmt` and `ghokin` - run: | - for cmd in govulncheck modernize modfmt ghokin; do - command -v "$cmd" >/dev/null || install_all=1 - done - [ -z "${install_all:-}" ] && exit 0 - go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 - go install golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@v0.20.0 - go install github.com/abhijit-hota/modfmt@v0.0.0-20260105113844-287023c3d905 - go install github.com/PaddleHQ/ghokin/v4@1a4a51e4fc32c59c88d7eb7e4835636641a54b0f # v4.1.1 + tools: | + govulncheck + modernize + modfmt + ghokin # -- # Resolve scope @@ -211,8 +195,9 @@ jobs: if: steps.scope.conclusion == 'skipped' || steps.scope.outputs.feature_files != '' run: | [ -d "features/" ] || exit 0 + ghokin_version=$(jq -r '.tools.ghokin.version' tool-versions.json) ghokin check features/ || \ - (echo '::error title=Incorrect feature file formatting::Please run "go run github.com/PaddleHQ/ghokin/v4@1a4a51e4fc32c59c88d7eb7e4835636641a54b0f fmt replace features/" to fix' && exit 1) + (echo "::error title=Incorrect feature file formatting::Please run \"go run github.com/PaddleHQ/ghokin/v4@${ghokin_version} fmt replace features/\" to fix" && exit 1) # -- # Go Vulncheck @@ -238,7 +223,7 @@ jobs: if: | github.event_name == 'pull_request' && (steps.scope.conclusion == 'skipped' || steps.scope.outputs.gosum_changed == 'true') - uses: step-security/actions-comment-pull-request@7f6021194300cf361f7ba99a346c4daa6cab809b # v3.0.1 + uses: step-security/actions-comment-pull-request@60cd38988a354b2d22b47612fb02a20e822d6048 # v3.0.2 with: comment-tag: govulncheck mode: ${{ steps.govulncheck.outputs.result != '' && 'upsert' || 'delete' }} @@ -311,31 +296,25 @@ jobs: name: OpenTelemetry Export Trace if: always() needs: [validate] - runs-on: ubuntu-latest + runs-on: generic-2-core + timeout-minutes: 5 permissions: contents: read actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + - name: Checkout otel-export action + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - egress-policy: audit - - - name: Fetch workflow run timestamps - id: run_times - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - json=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}") - echo "created_at=$(echo "$json" | jq -r '.created_at')" >> "$GITHUB_OUTPUT" - echo "run_started_at=$(echo "$json" | jq -r '.run_started_at')" >> "$GITHUB_OUTPUT" + sparse-checkout: .github/actions/otel-export + sparse-checkout-cone-mode: false + persist-credentials: false - name: Export workflow trace - uses: step-security/otel-cicd-action@867b2182ace9682deea2e7c31712596e02e72644 # v4.0.1 + if: always() + uses: ./.github/actions/otel-export continue-on-error: true with: - otlpEndpoint: grpc://api.honeycomb.io:443/ - otlpHeaders: "x-honeycomb-team=${{ secrets.HONEYCOMB_GHA_SECRET }},x-honeycomb-dataset=${{ vars.HONEYCOMB_GHA_DATASET }}" - githubToken: ${{ secrets.GITHUB_TOKEN }} - extraAttributes: "github.workflow_run.created_at=${{ steps.run_times.outputs.created_at }},github.workflow_run.run_started_at=${{ steps.run_times.outputs.run_started_at }}" + honeycomb-team: ${{ secrets.HONEYCOMB_GHA_SECRET }} + honeycomb-dataset: ${{ vars.HONEYCOMB_GHA_DATASET }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/tool-versions.json b/tool-versions.json new file mode 100644 index 0000000..7f96f0a --- /dev/null +++ b/tool-versions.json @@ -0,0 +1,33 @@ +{ + "comment": "Pinned versions of Go CLI tools used by validate.yml and test.yml. Bumped daily by sync-tool-versions.yml from PaddleHQ/github-actions-custom-images. Synced to consumer repos by paddle-config.", + "tools": { + "govulncheck": { + "version": "v1.1.4", + "package": "golang.org/x/vuln/cmd/govulncheck" + }, + "modernize": { + "version": "v0.23.0", + "package": "golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize" + }, + "modfmt": { + "version": "v0.0.0-20260105113844-287023c3d905", + "package": "github.com/abhijit-hota/modfmt" + }, + "ghokin": { + "version": "v4.2.0", + "package": "github.com/PaddleHQ/ghokin/v4" + }, + "gorelease": { + "version": "v0.0.0-20260727155853-b88d891fe743", + "package": "golang.org/x/exp/cmd/gorelease" + }, + "gotestsum": { + "version": "v1.13.0", + "package": "gotest.tools/gotestsum" + }, + "go-ctrf-json-reporter": { + "version": "v0.1.0", + "package": "github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter" + } + } +}