diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..a7e920f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Pre-push hook — runs all check scripts before allowing a push. +# Install with: git config core.hooksPath .githooks + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec "$REPO_ROOT/scripts/runChecks.sh" diff --git a/.github/workflows/ci-global-commits-signed.yaml b/.github/workflows/ci-global-commits-signed.yaml new file mode 100644 index 0000000..ff87103 --- /dev/null +++ b/.github/workflows/ci-global-commits-signed.yaml @@ -0,0 +1,56 @@ +--- +# Fails a pull request when any of its commits is not verified by GitHub. +# +# Uses GitHub's own commit verification (keys uploaded to each author's +# account, plus GitHub's web-flow key for UI-made merge commits) rather than +# running `git verify-commit` in CI, which would need every signer's key in +# the runner's keyring. Locally, the same rule is enforced by +# scripts/checks/verifyGitLogs.sh via the pre-push hook. +# +# Make this check required on `main` in the branch-protection settings so a +# merge cannot happen while it is failing. +name: ci-global-commits-signed +on: + workflow_dispatch: + + pull_request: {} + +permissions: + contents: read + pull-requests: read + +jobs: + verify: + name: all commits verified + runs-on: ubuntu-latest + steps: + - name: Check every commit in the pull request + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + fail=0 + total=0 + while IFS=$'\t' read -r sha verified reason subject; do + total=$((total + 1)) + if [ "$verified" = "true" ]; then + printf ' ✓ %s [%s] %s\n' "${sha:0:7}" "$reason" "$subject" + else + printf ' ✗ %s [%s] %s\n' "${sha:0:7}" "$reason" "$subject" + fail=$((fail + 1)) + fi + done < <(gh api --paginate "repos/${REPO}/pulls/${PR}/commits" \ + --jq '.[] | [.sha, (.commit.verification.verified|tostring), .commit.verification.reason, (.commit.message|split("\n")[0])] | @tsv') + echo + if [ "$fail" -gt 0 ]; then + echo "::error::${fail} of ${total} commit(s) in this pull request are not verified." + exit 1 + fi + echo "All ${total} commit(s) are verified." + + - name: Nothing to verify outside a pull request + if: github.event_name != 'pull_request' + run: echo "Manual run — this check only inspects pull request commits." diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..3f2b81b --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,11 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/yamllint.json +extends: default + +rules: + comments: disable + line-length: disable + braces: disable + brackets: disable + truthy: + check-keys: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6138f18 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing + +This repo only houses the organization profile README (`profile/README.md`), but it +follows the same conventions as the other AI Crafting repositories. + +## Signed commits are required + +Every commit must carry a valid signature. Enforced in two places: + +- **Locally**, by the pre-push hook. Enable it once per clone: + + ```bash + git config core.hooksPath .githooks + ``` + + The hook runs `scripts/runChecks.sh`, which runs every executable check in + `scripts/checks/`: `lint.sh` (yamllint + shellcheck) and `verifyGitLogs.sh`, which + rejects any unpushed commit whose signature is not good (`%G?` of `G` or `U`). + `U` is accepted because a good signature from a key you have not personally + trusted is still a valid signature; GitHub's own merge commits show as `U` until + you trust its web-flow key. + +- **In CI**, by the `ci-global-commits-signed` workflow, which fails a pull request if + GitHub reports any of its commits as unverified. That check is required on `main`. + +Run the checks on demand with: + +```bash +scripts/runChecks.sh +``` + +Code style: tabs (width 4), trailing whitespace trimmed on save except in `.md` +files, one final newline. See `AGENTS.md`. diff --git a/scripts/checks/lint.sh b/scripts/checks/lint.sh new file mode 100755 index 0000000..99e5b26 --- /dev/null +++ b/scripts/checks/lint.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Local pre-push lint check — mirrors the CI lint workflow. +# Can be run from anywhere: +# lint.sh — run all checks +# lint.sh yaml — yamllint only +# lint.sh shell — shellcheck only +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +pass=0 +fail=0 +filter="${1:-all}" + +run_check() { + local name="$1" + shift + printf '\033[1m▶ %s\033[0m\n' "$name" + if "$@"; then + printf '\033[0;32m ✓ %s passed\033[0m\n\n' "$name" + pass=$((pass + 1)) + else + printf '\033[0;31m ✗ %s failed\033[0m\n\n' "$name" + fail=$((fail + 1)) + fi +} + +should_run() { + [ "$filter" = "all" ] || [ "$filter" = "$1" ] +} + +# yamllint (exclude node_modules) +if should_run yaml; then + if command -v yamllint &>/dev/null; then + run_check "yamllint" bash -c \ + 'find . \( -name "*.yml" -o -name "*.yaml" \) -not -path "*/node_modules/*" -not -path "./external/*" | xargs yamllint' + else + printf '\033[0;31m ⚠ yamllint not installed — skipping\033[0m\n\n' + fi +fi + +# ShellCheck +if should_run shell; then + if command -v shellcheck &>/dev/null; then + run_check "shellcheck" bash -c \ + 'find . -type f -name "*.sh" -not -path "./node_modules/*" -not -path "./external/*" -print0 | xargs -0 -r shellcheck' + else + printf '\033[0;31m ⚠ shellcheck not installed — skipping\033[0m\n\n' + fi +fi + +printf '\033[1m────────────────────────────\033[0m\n' +printf '\033[0;32mPassed: %d\033[0m \033[0;31mFailed: %d\033[0m\n' "$pass" "$fail" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/checks/verifyGitLogs.sh b/scripts/checks/verifyGitLogs.sh new file mode 100755 index 0000000..3b4aa23 --- /dev/null +++ b/scripts/checks/verifyGitLogs.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Verify that recent commits carry valid signatures. +# Can be run from anywhere: +# verifyGitLogs.sh — check the unpushed commits (vs the upstream), +# or the last 10 if there are none / no upstream +# verifyGitLogs.sh N — check the most recent N commits +# +# A commit passes when `git` reports its signature as good (`%G?` = G or U, +# i.e. cryptographically valid; U = valid but the signing key isn't trusted). +# Bad, missing, expired, revoked, or unverifiable signatures fail. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +green=$'\033[0;32m' +red=$'\033[0;31m' +bold=$'\033[1m' +reset=$'\033[0m' + +# --------------------------------------------------------------------------- +# Determine how many commits to verify. +# --------------------------------------------------------------------------- +count="${1:-}" + +if [ -z "$count" ]; then + unpushed=0 + if upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then + unpushed="$(git rev-list --count "${upstream}..HEAD" 2>/dev/null || echo 0)" + echo "Upstream ${upstream}: ${unpushed} unpushed commit(s)." + else + echo "No upstream configured." + fi + if [ "$unpushed" -gt 0 ]; then + count="$unpushed" + else + count=10 + echo "Falling back to the last ${count} commits." + fi +fi + +if ! [[ "$count" =~ ^[0-9]+$ ]] || [ "$count" -eq 0 ]; then + echo "${red}error: commit count must be a positive integer (got '${count}')${reset}" >&2 + exit 2 +fi + +# Don't ask for more commits than exist. +total="$(git rev-list --count HEAD)" +if [ "$count" -gt "$total" ]; then + count="$total" +fi + +# --------------------------------------------------------------------------- +# Verify each commit's signature. +# --------------------------------------------------------------------------- +printf '%s▶ Verifying signatures on the most recent %s commit(s)%s\n' "$bold" "$count" "$reset" + +fail=0 +while read -r sha; do + status="$(git show --no-patch --format='%G?' "$sha")" + subject="$(git show --no-patch --format='%s' "$sha")" + short="$(git rev-parse --short "$sha")" + case "$status" in + G | U) + printf '%s ✓ %s [%s] %s%s\n' "$green" "$short" "$status" "${subject:0:60}" "$reset" + ;; + *) + printf '%s ✗ %s [%s] %s%s\n' "$red" "$short" "$status" "${subject:0:60}" "$reset" + fail=$((fail + 1)) + ;; + esac +done < <(git rev-list -n "$count" HEAD) + +echo +if [ "$fail" -gt 0 ]; then + printf '%s%d of %d commit(s) have invalid or missing signatures.%s\n' "$red" "$fail" "$count" "$reset" >&2 + exit 1 +fi +printf '%sAll %d commit(s) have valid signatures.%s\n' "$green" "$count" "$reset" diff --git a/scripts/runChecks.sh b/scripts/runChecks.sh new file mode 100755 index 0000000..018e656 --- /dev/null +++ b/scripts/runChecks.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Runs all check scripts in scripts/checks/ and exits non-zero if any fail. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/checks" && pwd)" +fail=0 + +for script in "$SCRIPT_DIR"/*.sh; do + [ -x "$script" ] || continue + printf '\033[1m━━━ %s ━━━\033[0m\n' "$(basename "$script")" + if "$script"; then + : + else + fail=$((fail + 1)) + fi +done + +if [ "$fail" -gt 0 ]; then + printf '\n\033[0;31m%d check script(s) failed.\033[0m\n' "$fail" + exit 1 +fi + +printf '\n\033[0;32mAll checks passed.\033[0m\n'