diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index a0bc7cd..0c150d6 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -33,3 +33,25 @@ jobs: shell: bash - run: pre-commit run --show-diff-on-failure --color=always --all-files shell: bash + + test: + name: Shell script tests + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + # The tests build throwaway repos under /tmp with their own origin remote and + # their own committer identity, so they need no runner git config. + - run: tests/test-check-for-epoch-bump.sh + shell: bash diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4cfbfb9..d8fadaf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,3 +44,11 @@ repos: hooks: - id: misspell exclude: '^example\.pre-commit-config\.yaml$' + # This repo ships shell scripts as its product, but nothing was linting them. + # shellcheck-py rather than the Docker-based mirror, so it installs alongside + # the pip packages the Lint job already sets up. Default severity is `style`, + # which reports everything; all current scripts are clean at that level. + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1 + hooks: + - id: shellcheck diff --git a/scripts/check-for-epoch-bump.sh b/scripts/check-for-epoch-bump.sh index 0c4f669..61dc299 100755 --- a/scripts/check-for-epoch-bump.sh +++ b/scripts/check-for-epoch-bump.sh @@ -26,6 +26,37 @@ epoch_sed() { sed -r 's/^ epoch:[[:space:]]+([0-9]+).*$/\1/' } +# Pick the ref to compare against, preferring the remote-tracking branch. +# +# `main` on its own is the wrong baseline in a package repo: the convention there +# is to never commit to main, so the local branch is only as fresh as the last +# time someone happened to check it out. It can sit thousands of commits behind, +# which hands this check an old, lower epoch and lets an already-taken epoch look +# like a real bump. Worse, once local main predates a package's creation, +# `git show` finds nothing, the baseline collapses to 0, and every version +# compares as increased, so the hook passes unconditionally. +# +# Deliberately no `git fetch`: a pre-commit hook has to stay fast and keep working +# offline. Whatever the last fetch left in origin/main is still far closer to the +# truth than a branch nobody visits. +resolve_base_ref() { + local candidate + for candidate in "origin/main" "main" "origin/HEAD"; do + if git rev-parse --verify --quiet "${candidate}^{commit}" >/dev/null 2>&1; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +if ! base_ref="$(resolve_base_ref)"; then + echo "⚠️ Could not resolve a base ref (tried origin/main, main, origin/HEAD)." + echo " Skipping the epoch check rather than comparing against an empty" + echo " baseline, which would report every file as bumped." + exit 0 +fi + for yaml_file in "$@"; do echo "Checking $yaml_file:" @@ -34,17 +65,18 @@ for yaml_file in "$@"; do version_line="$(version_grep < "$yaml_file")" version_local="$(echo "$version_line" | version_sed)" if [ -z "$version_local" ]; then + echo "⚠️ No top-level 'version:' field found in $yaml_file; treating it as 0" version_local="0" fi epoch_line="$(epoch_grep < "$yaml_file")" epoch_local="$(echo "$epoch_line" | epoch_sed)" if [ -z "$epoch_local" ]; then + echo "⚠️ No top-level 'epoch:' field found in $yaml_file; treating it as 0" epoch_local="0" fi - # Extract version and epoch from the file on the main branch using git show - # Treat missing file as version 0 and epoch 0 + # Extract version and epoch from the file on the base ref using git show. # Try all three known package directories so epoch bumps are enforced when # a package is moved between os/, enterprise-packages/, and extra-packages/. first_component="${yaml_file%%/*}" @@ -52,13 +84,18 @@ for yaml_file in "$@"; do main_content="" if [[ "$first_component" == "os" || "$first_component" == "enterprise-packages" || "$first_component" == "extra-packages" ]]; then for prefix in "os" "enterprise-packages" "extra-packages"; do - main_content="$(git show main:"${prefix}/${rest_of_path}" 2>/dev/null)" + main_content="$(git show "${base_ref}:${prefix}/${rest_of_path}" 2>/dev/null)" [ -n "$main_content" ] && break done else - main_content="$(git show main:"$yaml_file" 2>/dev/null)" + main_content="$(git show "${base_ref}:${yaml_file}" 2>/dev/null)" fi + if [ -z "$main_content" ]; then + # Genuinely absent from the base ref, so a 0 baseline is the right answer. + # Say so out loud: a bare "✅ increased" here reads as though a real + # comparison happened. + echo "ℹ️ Not found on ${base_ref} (new package?); comparing against 0-r0" version_main="0" epoch_main="0" else @@ -77,19 +114,19 @@ for yaml_file in "$@"; do # Compare version first, then epoch only if versions are the same if [ "$version_local" != "$version_main" ]; then - # Versions are different - version comparison is sufficient - # Use sort -V for version comparison - if [ "$(printf '%s\n' "$version_local" "$version_main" | sort -V | head -n1)" = "$version_main" ] && [ "$version_local" != "$version_main" ]; then - echo "✅ Version has been increased compared to main: $version_local > $version_main" + # Versions differ, so the version comparison settles it. sort -V puts the + # lower version first; if that is the base ref's, the local one is newer. + if [ "$(printf '%s\n' "$version_local" "$version_main" | sort -V | head -n1)" = "$version_main" ]; then + echo "✅ Version has been increased compared to ${base_ref}: $version_local > $version_main" else - echo "⚠️ Version HAS NOT been increased compared to main: $version_local <= $version_main" + echo "⚠️ Version HAS NOT been increased compared to ${base_ref}: $version_local < $version_main" fi else # Versions are the same - check epoch if (( epoch_local > epoch_main )); then - echo "✅ Epoch has been increased compared to main: $epoch_local > $epoch_main (version: $version_local)" + echo "✅ Epoch has been increased compared to ${base_ref}: $epoch_local > $epoch_main (version: $version_local)" else - echo "⚠️ Epoch HAS NOT been increased compared to main: $epoch_local <= $epoch_main (version: $version_local)" + echo "⚠️ Epoch HAS NOT been increased compared to ${base_ref}: $epoch_local <= $epoch_main (version: $version_local)" fi fi diff --git a/tests/test-check-for-epoch-bump.sh b/tests/test-check-for-epoch-bump.sh new file mode 100755 index 0000000..b0d1fc5 --- /dev/null +++ b/tests/test-check-for-epoch-bump.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Tests for scripts/check-for-epoch-bump.sh +# +# Builds a throwaway repo with a real `origin` remote so the two refs can +# disagree, which is the situation that matters: in package repos nobody checks +# out `main` locally, so the local branch rots while `origin/main` stays current. +# +# Run: tests/test-check-for-epoch-bump.sh [path-to-script] +# Exits non-zero on the first failing case and leaves the scratch repo in place +# for inspection. +set -uo pipefail + +SCRIPT="${1:-$(cd "$(dirname "$0")/.." && pwd)/scripts/check-for-epoch-bump.sh}" +ROOT=/tmp/epoch-hook-test +PKG=enterprise-packages/foo.yaml + +pass=0 +fail=0 + +say() { printf '%s\n' "$*"; } + +# Scratch repos must never sign. gitsign is enabled globally on dev machines +# (gpg.format=x509, commit.gpgsign=true), so an unconfigured test repo would +# round-trip to Fulcio and Rekor for every throwaway commit: slow, offline-hostile, +# and it writes junk into the transparency log. Call this right after init/clone. +init_repo_config() { + git config commit.gpgsign false + git config tag.gpgsign false + git config user.name "epoch hook test" + git config user.email "epoch-hook-test@example.invalid" +} + +# write a melange-ish yaml with the given version and epoch +write_pkg() { + mkdir -p "$(dirname "$2")" + cat > "$2" <, local main is left at +# args: ahead_version ahead_epoch stale_mode +# stale_mode=behind -> local main points one commit back (file present, older epoch) +# stale_mode=nofile -> local main points at a commit before the file existed +# stale_mode=current -> local main == origin/main +build_repo() { + local av=$1 ae=$2 mode=$3 + rm -rf "$ROOT" + mkdir -p "$ROOT" + git init -q --bare "$ROOT/origin.git" + git clone -q "$ROOT/origin.git" "$ROOT/work" 2>/dev/null + cd "$ROOT/work" || exit 1 + init_repo_config + git symbolic-ref HEAD refs/heads/main + + echo seed > README.md + git add README.md + git commit -qm "seed" + local seed + seed=$(git rev-parse HEAD) + + write_pkg "1.0.0" "$PKG" 3 + git add "$PKG" + git commit -qm "add foo 1.0.0-r3" + local older + older=$(git rev-parse HEAD) + + write_pkg "$av" "$PKG" "$ae" + git add "$PKG" + git commit -qm "foo $av-r$ae" + git push -q origin main + + # Leave main before moving it: git refuses to force-update a branch that is + # checked out in the current worktree. + git checkout -q -b feature + case "$mode" in + behind) git branch -f main "$older" ;; + nofile) git branch -f main "$seed" ;; + current) : ;; + esac + + # sanity-check that the scenario is actually set up, so a silently broken + # harness cannot report a green run + local local_main origin_main + local_main=$(git rev-parse main) + origin_main=$(git rev-parse origin/main) + case "$mode" in + behind|nofile) + if [ "$local_main" = "$origin_main" ]; then + say " HARNESS BROKEN: local main was not made stale for mode=$mode" + exit 1 + fi ;; + current) + if [ "$local_main" != "$origin_main" ]; then + say " HARNESS BROKEN: local main should equal origin/main for mode=current" + exit 1 + fi ;; + esac +} + +# run_case