From c820870f72d4bb7a2ec4b4ec9b9415859b95a1ce Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Wed, 22 Jul 2026 17:53:17 +0200 Subject: [PATCH] chore(ci): consolidate Gitea CI Co-authored-by: Claude --- .gitea/workflows/build-binaries.yml | 22 ++++---- .gitea/workflows/ci.yml | 47 ++++++++++++++++ .gitea/workflows/sync-wrappers.yml | 5 +- .gitea/workflows/test.yml | 30 ----------- Makefile | 1 + README.md | 11 ++-- tests/workflow-contract.sh | 84 +++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 49 deletions(-) create mode 100644 .gitea/workflows/ci.yml delete mode 100644 .gitea/workflows/test.yml create mode 100644 tests/workflow-contract.sh diff --git a/.gitea/workflows/build-binaries.yml b/.gitea/workflows/build-binaries.yml index c99d1e4..15d3efa 100644 --- a/.gitea/workflows/build-binaries.yml +++ b/.gitea/workflows/build-binaries.yml @@ -1,25 +1,22 @@ -# Cross-platform Crucible binaries (D7 trigger standard): -# PR / push main -> cross-build ALL targets to prove they compile (no publish) -# tag v* -> build all targets + SHA256SUMS, upload to the Gitea release +# Cross-platform Crucible release binaries: a v* tag builds all targets, +# then uploads them, SHA256SUMS, and the canonical wrappers to its Gitea release. # Crucible is pure Go (CGO_ENABLED=0), so cross-compiling is a fast loop. name: build-binaries on: - pull_request: push: - branches: [main] tags: ['v*'] - paths-ignore: - - "docs/**" - - "README.md" - - "AGENTS.md" - - "LICENSE" + +permissions: + code: read + releases: write jobs: build-binaries: runs-on: nix-docker + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: { fetch-depth: 0 } - name: Cross-build all targets @@ -45,9 +42,8 @@ jobs: ' - name: Upload to Gitea release - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} + TOKEN: ${{ secrets.GITEA_TOKEN }} TAG: ${{ github.ref_name }} REPO: ${{ github.repository }} SERVER: ${{ github.server_url }} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..cbec1c9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,47 @@ +# Pull-request validation for Crucible. Releases and wrapper synchronization +# have their own narrow workflows because they need tag/main events. +name: ci + +on: + pull_request: + +permissions: read-all + +jobs: + ci: + runs-on: nix-docker + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: vet + test + lint + run: | + nix develop --command bash -c ' + set -euo pipefail + go vet ./... + go test ./... + shellcheck scripts/*.sh + yamllint .gitea + ' + + - name: binary smoke (fail-closed contract) + run: nix develop --command make smoke + + - name: Cross-build all targets + run: | + nix develop --command bash -c ' + set -euo pipefail + sha="$(git rev-parse --short=12 HEAD)" + ldflags="-s -w -X git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${sha}" + rm -rf dist && mkdir -p dist + export CGO_ENABLED=0 + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + os="${target%/*}"; arch="${target#*/}" + ext=""; [ "$os" = windows ] && ext=".exe" + echo "building crucible-${os}-${arch}${ext}" + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" \ + -o "dist/crucible-${os}-${arch}${ext}" ./cmd/crucible + done + ' diff --git a/.gitea/workflows/sync-wrappers.yml b/.gitea/workflows/sync-wrappers.yml index 2491571..bb63b04 100644 --- a/.gitea/workflows/sync-wrappers.yml +++ b/.gitea/workflows/sync-wrappers.yml @@ -14,11 +14,14 @@ on: - 'wrappers/crucible.sh' - 'wrappers/crucible.ps1' +permissions: read-all + jobs: sync: runs-on: nix-docker + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Open sync PRs to consumers env: diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml deleted file mode 100644 index e96612e..0000000 --- a/.gitea/workflows/test.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Lint + unit tests for the Crucible suite. PR-first: PRs + push to main (D7). -name: test - -on: - push: - branches: [main] - paths-ignore: - - "docs/**" - - "README.md" - - "AGENTS.md" - - "LICENSE" - pull_request: - -jobs: - test: - runs-on: nix-docker - steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } - - name: vet + test + lint - run: | - nix develop --command bash -c ' - set -euo pipefail - go vet ./... - go test ./... - shellcheck scripts/*.sh - yamllint .gitea - ' - - name: binary smoke (fail-closed contract) - run: nix develop --command make smoke diff --git a/Makefile b/Makefile index 2b60c1f..96fed53 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ SHELL := bash check: vet test shellcheck scripts/*.sh yamllint .gitea + bash tests/workflow-contract.sh vet: go vet ./... diff --git a/README.md b/README.md index 39335ae..aba79e8 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,14 @@ This retires the old habit of checking in `nwn-tool` / `sow-toolkit`. ## CI -PR-first (D7): checks run on pull requests and on push to `main`; the only -publish event is a `v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs, +PR-first (D7): checks run once on pull requests; the only publish event is a +`v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs, https://git.westgate.pw/ShadowsOverWestgate/sow-docs). -- `test.yml` — vet, test, shellcheck, yamllint, binary smoke (PR + main). -- `build-binaries.yml` — cross-build all targets (PR + main); on a `v*` tag, upload - the binaries, `SHA256SUMS`, and the wrappers to the Gitea release. +- `ci.yml` — vet, test, shellcheck, yamllint, binary smoke, and cross-build all + targets once per pull request. +- `build-binaries.yml` — on a `v*` tag, cross-build and upload the binaries, + `SHA256SUMS`, and the wrappers to the Gitea release. - `sync-wrappers.yml` — on a `main` push that touches `wrappers/`, auto-PR the canonical wrappers to the consumer repos in `wrappers/consumers.txt`. Consumer drift checks run after those PRs merge to `main`, not on the PRs diff --git a/tests/workflow-contract.sh b/tests/workflow-contract.sh new file mode 100644 index 0000000..f520728 --- /dev/null +++ b/tests/workflow-contract.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +contract_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +default_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +repo_root="${WORKFLOW_ROOT:-$default_root}" +cd "$repo_root" + +ci=.gitea/workflows/ci.yml +release=.gitea/workflows/build-binaries.yml +sync=.gitea/workflows/sync-wrappers.yml +checkout='actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' + +assert_exact_events() { + local workflow="$1" expected="$2" actual + actual="$(awk ' + /^on:$/ { in_on = 1 } + in_on && /^$/ { next } + in_on && $0 != "on:" && $0 !~ /^[[:space:]]/ { exit } + in_on { print } + ' "$workflow")" + [[ "$actual" == "$expected" ]] || { + echo "workflow-contract: unexpected event scope in $workflow" >&2 + return 1 + } +} + +[[ -f "$ci" ]] || { echo "missing consolidated CI workflow" >&2; exit 1; } +[[ ! -e .gitea/workflows/test.yml ]] || { echo "legacy test workflow still present" >&2; exit 1; } + +assert_exact_events "$ci" $'on:\n pull_request:' +grep -Fqx 'permissions: read-all' "$ci" +grep -Fqx ' timeout-minutes: 60' "$ci" +[[ "$(grep -Fc "$checkout" "$ci")" -eq 1 ]] +grep -Fqx ' fetch-depth: 0' "$ci" +grep -Fq 'go vet ./...' "$ci" +grep -Fq 'go test ./...' "$ci" +grep -Fq 'shellcheck scripts/*.sh' "$ci" +grep -Fq 'yamllint .gitea' "$ci" +grep -Fq 'make smoke' "$ci" +grep -Fq 'for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do' "$ci" + +assert_exact_events "$release" $'on:\n push:\n tags: [\'v*\']' +grep -Fqx ' code: read' "$release" +grep -Fqx ' releases: write' "$release" +grep -Fqx ' timeout-minutes: 30' "$release" +[[ "$(grep -Fc "$checkout" "$release")" -eq 1 ]] +grep -Fq 'secrets.GITEA_TOKEN' "$release" +if grep -Fq 'secrets.GITHUB_TOKEN' "$release"; then + echo "workflow-contract: release uses the GitHub token alias" >&2 + exit 1 +fi + +grep -Fqx 'permissions: read-all' "$sync" +grep -Fqx ' timeout-minutes: 30' "$sync" +[[ "$(grep -Fc "$checkout" "$sync")" -eq 1 ]] +assert_exact_events "$sync" $'on:\n push:\n branches: [main]\n paths:\n - \'wrappers/crucible.sh\'\n - \'wrappers/crucible.ps1\'' + +expect_mutation_rejected() { + local name="$1" workflow="$2" expression="$3" tmp + tmp="$(mktemp -d)" + mkdir -p "$tmp/.gitea" + cp -R "$default_root/.gitea/workflows" "$tmp/.gitea/workflows" + sed -i "$expression" "$tmp/$workflow" + if WORKFLOW_ROOT="$tmp" WORKFLOW_CONTRACT_MUTATION=1 bash "$contract_script" >/dev/null 2>&1; then + echo "workflow-contract: accepted forbidden mutation: $name" >&2 + rm -rf "$tmp" + return 1 + fi + rm -rf "$tmp" +} + +if [[ "${WORKFLOW_CONTRACT_MUTATION:-0}" != 1 ]]; then + mutations_ok=0 + expect_mutation_rejected 'CI workflow_dispatch event' "$ci" '/^ pull_request:$/a\ workflow_dispatch:' || mutations_ok=1 + expect_mutation_rejected 'release develop branch' "$release" "/^ tags:/a\\ branches: [develop]" || mutations_ok=1 + expect_mutation_rejected 'release schedule event' "$release" "/^ tags:/a\\ schedule:\n - cron: '0 0 * * *'" || mutations_ok=1 + expect_mutation_rejected 'release extra tag pattern' "$release" "s/tags: \['v\*'\]/tags: ['v*', 'release-*']/" || mutations_ok=1 + expect_mutation_rejected 'wrapper sync pull_request event' "$sync" '/^ push:$/i\ pull_request:' || mutations_ok=1 + expect_mutation_rejected 'wrapper sync broad path' "$sync" "/^ - 'wrappers\/crucible.ps1'$/a\\ - 'wrappers/**'" || mutations_ok=1 + (( mutations_ok == 0 )) || exit 1 +fi + +echo "workflow-contract: CI is PR-only; release and wrapper sync retain their narrow triggers" -- 2.54.0