Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .github/scripts/validate-keys.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Validate SSH public keys added or modified between two commits.
#
# Usage: validate-keys.sh <base-ref> <head-ref>
#
# 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)"
70 changes: 70 additions & 0 deletions .github/workflows/pr-validate.yml
Original file line number Diff line number Diff line change
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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
33 changes: 33 additions & 0 deletions .github/workflows/regenerate.yml
Original file line number Diff line number Diff line change
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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
6 changes: 5 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``::

Expand Down