From dd5e1c5cf5c9fae7bb01f242204917fa89c200f2 Mon Sep 17 00:00:00 2001 From: David Galloway Date: Thu, 13 Aug 2026 15:13:23 -0400 Subject: [PATCH 1/2] Add CI to validate key PRs, auto-merge, and regenerate @all Validate every ssh/*.pub file a PR adds or modifies: each line must be a parseable public key (no private key material, no DSA, RSA >= 2048 bits, sane filenames, no reserved @-prefixed names). PRs from repo/org members that only touch ssh/*.pub files are merged automatically once validation passes, and ./update is then run in CI to regenerate the autogenerated branch. A push-triggered workflow covers manually merged PRs, since GITHUB_TOKEN merges don't trigger it from the auto-merge path. Signed-off-by: David Galloway --- .github/scripts/validate-keys.sh | 117 ++++++++++++++++++++++++++++++ .github/workflows/pr-validate.yml | 70 ++++++++++++++++++ .github/workflows/regenerate.yml | 33 +++++++++ README.rst | 6 +- 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/validate-keys.sh create mode 100644 .github/workflows/pr-validate.yml create mode 100644 .github/workflows/regenerate.yml diff --git a/.github/scripts/validate-keys.sh b/.github/scripts/validate-keys.sh new file mode 100755 index 00000000..f3d69fa9 --- /dev/null +++ b/.github/scripts/validate-keys.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Validate SSH public keys added or modified between two commits. +# +# Usage: validate-keys.sh +# +# Fails (exit 1) if any added/modified ssh/*.pub file contains something +# that is not a valid, acceptable SSH public key. +# +# Writes "eligible=true|false" to $GITHUB_OUTPUT (when set): true only if +# every changed file in the range is an ssh/*.pub key file, i.e. the PR is +# safe to auto-merge without human review of non-key changes. +set -euo pipefail + +base=$1 +head=$2 +fail=0 +eligible=true + +# A git failure inside <(...) would be silently ignored; make sure the +# refs are usable before trusting the diffs below. +git merge-base "$base" "$head" > /dev/null + +# Triple-dot: diff against the merge base, i.e. only changes the PR introduces +mapfile -t changed < <(git diff --name-only --diff-filter=ACMR "$base...$head" -- ) +mapfile -t all_changed < <(git diff --name-only "$base...$head" -- ) + +# Deletions are allowed (user removal) but any non-key change makes the PR +# ineligible for auto-merge. +for f in "${all_changed[@]}"; do + case $f in + ssh/*.pub) ;; + *) + echo "note: $f is not an ssh key file; PR will need manual review/merge" + eligible=false + ;; + esac +done + +validate_file() { + local f=$1 lineno=0 keys=0 line info bits type + + if grep -q 'PRIVATE KEY' "$f"; then + echo "::error file=$f::$f contains private key material -- do not commit private keys!" + return 1 + fi + + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + # Skip blank lines and comments + [[ $line =~ ^[[:space:]]*$ ]] && continue + [[ $line =~ ^[[:space:]]*# ]] && continue + + if ! info=$(printf '%s\n' "$line" | ssh-keygen -lf - 2>&1); then + echo "::error file=$f,line=$lineno::not a valid SSH public key: $info" + return 1 + fi + + bits=$(awk '{print $1}' <<< "$info") + type=$(awk '{print $NF}' <<< "$info") + case $type in + '(DSA)') + echo "::error file=$f,line=$lineno::DSA keys are obsolete and not accepted" + return 1 + ;; + '(RSA)') + if [ "$bits" -lt 2048 ]; then + echo "::error file=$f,line=$lineno::RSA keys must be at least 2048 bits (got $bits)" + return 1 + fi + ;; + esac + keys=$((keys + 1)) + echo "$f:$lineno: OK $info" + done < "$f" + + if [ "$keys" -eq 0 ]; then + echo "::error file=$f::$f contains no SSH public keys" + return 1 + fi +} + +for f in "${changed[@]}"; do + case $f in + ssh/@*) + echo "::error file=$f::file names beginning with @ are reserved for autogenerated groups" + fail=1 + continue + ;; + ssh/*.pub) + ;; + *) + # Already flagged as ineligible above; nothing to validate. + continue + ;; + esac + + name=$(basename "$f" .pub) + if ! [[ $name =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error file=$f::key file names may only contain letters, digits, '.', '_' and '-'" + fail=1 + continue + fi + + if ! validate_file "$f"; then + fail=1 + fi +done + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "eligible=$eligible" >> "$GITHUB_OUTPUT" +fi + +if [ "$fail" -ne 0 ]; then + echo "Key validation FAILED" + exit 1 +fi +echo "Key validation passed (auto-merge eligible: $eligible)" diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml new file mode 100644 index 00000000..e8787a74 --- /dev/null +++ b/.github/workflows/pr-validate.yml @@ -0,0 +1,70 @@ +name: Validate keys + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + eligible: ${{ steps.check.outputs.eligible }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate changed key files + id: check + run: | + .github/scripts/validate-keys.sh "origin/${GITHUB_BASE_REF}" HEAD + + automerge: + # Auto-merge only when: + # - validation passed and the PR touches nothing but ssh/*.pub files + # - the PR branch lives in this repo (fork PRs get a read-only token) + # - the author is an owner/member/collaborator of the repo + needs: validate + if: >- + needs.validate.outputs.eligible == 'true' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), + github.event.pull_request.author_association) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Merge PR + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr merge --repo "$GITHUB_REPOSITORY" --merge --delete-branch \ + "${{ github.event.pull_request.number }}" + + update: + # Regenerate the autogenerated branch (@all.pub etc.) after the merge. + # This runs here because pushes made with GITHUB_TOKEN do not trigger + # the push-based regenerate workflow. + needs: automerge + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: update-autogenerated + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Run update script + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config branch.main.remote origin + ./update diff --git a/.github/workflows/regenerate.yml b/.github/workflows/regenerate.yml new file mode 100644 index 00000000..5ef1436e --- /dev/null +++ b/.github/workflows/regenerate.yml @@ -0,0 +1,33 @@ +name: Regenerate autogenerated branch + +# Covers key changes that land on main without going through auto-merge +# (manually merged PRs, direct pushes). Auto-merged PRs run ./update in the +# PR workflow instead, because GITHUB_TOKEN pushes don't trigger this one. +on: + push: + branches: [main] + paths: + - 'ssh/**' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-autogenerated + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Run update script + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config branch.main.remote origin + ./update diff --git a/README.rst b/README.rst index d928ea9a..e72b5245 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,11 @@ used for accessing the Sepia lab. SSH keys are stored as ``ssh/USER.pub``. A group called ``@all`` is automatically provided, that contains keys for all the known users. -After updating keys, run ``./update`` +Pull requests are validated by CI (``.github/workflows/pr-validate.yml``). +PRs from repo/org members that only touch ``ssh/*.pub`` files with valid +keys are merged automatically, and the ``autogenerated`` branch is then +regenerated by running ``./update`` in CI. If you update keys outside of +that flow, run ``./update`` manually. You use these keys by setting in ``/etc/ssh/ssh_import_id``:: From 5deecca58fb824ed584fd2bd06f2a2383e90fa34 Mon Sep 17 00:00:00 2001 From: David Galloway Date: Thu, 13 Aug 2026 15:17:16 -0400 Subject: [PATCH 2/2] Pin actions/checkout to a commit SHA Tags are mutable; pin to the v7.0.1 commit instead. Signed-off-by: David Galloway --- .github/workflows/pr-validate.yml | 4 ++-- .github/workflows/regenerate.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml index e8787a74..74171005 100644 --- a/.github/workflows/pr-validate.yml +++ b/.github/workflows/pr-validate.yml @@ -13,7 +13,7 @@ jobs: outputs: eligible: ${{ steps.check.outputs.eligible }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -57,7 +57,7 @@ jobs: concurrency: group: update-autogenerated steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/regenerate.yml b/.github/workflows/regenerate.yml index 5ef1436e..3dec4539 100644 --- a/.github/workflows/regenerate.yml +++ b/.github/workflows/regenerate.yml @@ -20,7 +20,7 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main fetch-depth: 0