Merge pull request #120 from trustedsec/fix/ci-hardening

fix(ci): add test CI, repair auto-tag releases, harden action pins (2.11.4)
This commit is contained in:
Justin Bollinger
2026-07-24 16:47:01 -04:00
committed by GitHub
7 changed files with 245 additions and 28 deletions
+29
View File
@@ -0,0 +1,29 @@
version: 2
updates:
# Python dependencies (PEP 621 pyproject.toml + uv.lock).
# The native "uv" ecosystem is used instead of "pip" because it reads
# pyproject.toml and uv.lock directly (GA since 2025-03; security updates
# since 2025-12).
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
# "chore" deliberately does NOT match the feat|fix|perf patterns in
# .github/workflows/auto-tag.yml, so dependency PRs never auto-cut a tag.
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
# GitHub Actions are SHA-pinned in .github/workflows; Dependabot keeps the
# pins current (and refreshes the trailing "# vN" comment).
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
# "chore(ci)" deliberately does NOT match the feat|fix|perf patterns in
# .github/workflows/auto-tag.yml, so dependency PRs never auto-cut a tag.
commit-message:
prefix: "chore(ci)"
+82 -17
View File
@@ -1,26 +1,50 @@
name: Auto Tag
# Runs only after the CI workflow finishes successfully on main, so a broken
# commit never gets tagged or released.
on:
push:
workflow_run:
workflows: ["CI"]
types:
- completed
branches:
- main
permissions:
contents: write
# Two merges landing back-to-back would otherwise both compute the same new tag
# and the second push would fail. Serialize instead of cancelling so no merge
# gets skipped.
concurrency:
group: auto-tag
cancel-in-progress: false
jobs:
tag:
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# workflow_run defaults to the tip of the default branch, which is not
# necessarily the commit CI validated.
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Configure git identity
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- name: Determine version bump
id: bump
run: |
# Get the latest semver tag
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
# Get the latest semver tag. The `|| true` keeps `set -o pipefail`
# from aborting the step when there are no matching tags yet.
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
if [ -z "$latest_tag" ]; then
echo "No semver tag found, starting at v0.1.0"
echo "new_tag=v0.1.0" >> "$GITHUB_OUTPUT"
@@ -29,10 +53,12 @@ jobs:
echo "Latest tag: $latest_tag"
# Get commits since last tag
commits=$(git log "$latest_tag"..HEAD --pretty=format:"%s")
# Commit SHAs since the last tag. We iterate SHAs rather than reading
# subjects line-by-line so that commit *bodies* (and therefore
# BREAKING CHANGE footers) are visible.
shas=$(git log "$latest_tag"..HEAD --format=%H)
if [ -z "$commits" ]; then
if [ -z "$shas" ]; then
echo "No new commits since $latest_tag"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
@@ -44,23 +70,42 @@ jobs:
minor=$(echo "$version" | cut -d. -f2)
patch=$(echo "$version" | cut -d. -f3)
# Check commit prefixes for bump type
# Determine bump type. Precedence: major > minor > patch.
bump="none"
while IFS= read -r msg; do
if echo "$msg" | grep -qE '^feat(\(.+\))?!?:'; then
while IFS= read -r sha; do
[ -n "$sha" ] || continue
body=$(git log -1 --format=%B "$sha")
subject=${body%%$'\n'*}
# Note: matches are done with bash [[ =~ ]] / here-strings rather
# than pipelines, so `set -o pipefail` cannot turn an early-exiting
# `grep -q` (SIGPIPE, status 141) into a false negative.
if [[ "$subject" =~ ^[a-zA-Z]+(\(.+\))?!: ]]; then
# e.g. "feat!: ..." / "refactor(api)!: ..."
bump="major"
elif grep -qE '^(BREAKING CHANGE|BREAKING-CHANGE):' <<<"$body"; then
# Conventional Commits breaking-change footer in the body.
bump="major"
elif [[ "$subject" =~ ^feat(\(.+\))?: ]] && [ "$bump" != "major" ]; then
bump="minor"
elif echo "$msg" | grep -qE '^(fix|perf)(\(.+\))?:' && [ "$bump" != "minor" ]; then
elif [[ "$subject" =~ ^(fix|perf)(\(.+\))?: ]] && [ "$bump" = "none" ]; then
bump="patch"
fi
done <<< "$commits"
done <<EOF
$shas
EOF
if [ "$bump" = "none" ]; then
echo "No feat/fix/perf commits, skipping tag"
echo "No feat/fix/perf/breaking commits, skipping tag"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$bump" = "minor" ]; then
if [ "$bump" = "major" ]; then
major=$((major + 1))
minor=0
patch=0
elif [ "$bump" = "minor" ]; then
minor=$((minor + 1))
patch=0
else
@@ -68,7 +113,7 @@ jobs:
fi
new_tag="v${major}.${minor}.${patch}"
echo "Bump: $bump $new_tag"
echo "Bump: $bump -> $new_tag"
echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT"
- name: Create tag
@@ -76,5 +121,25 @@ jobs:
env:
NEW_TAG: ${{ steps.bump.outputs.new_tag }}
run: |
git tag "$NEW_TAG"
git push origin "$NEW_TAG"
if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then
echo "Tag $NEW_TAG already exists, nothing to push"
else
git tag "$NEW_TAG"
git push origin "refs/tags/$NEW_TAG"
fi
# GitHub never dispatches workflow events for refs pushed with
# GITHUB_TOKEN, so release.yml will not fire for the tag above. Create the
# release here instead. release.yml remains the path for tags pushed
# manually by a human.
- name: Create GitHub release
if: steps.bump.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEW_TAG: ${{ steps.bump.outputs.new_tag }}
run: |
if gh release view "$NEW_TAG" >/dev/null 2>&1; then
echo "Release $NEW_TAG already exists, nothing to do"
exit 0
fi
gh release create "$NEW_TAG" --generate-notes
+45
View File
@@ -0,0 +1,45 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
name: Lint, type-check, and tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# tests/test_main_pcfg.py needs pcfg_cracker/pcfg_guesser.py on disk
submodules: true
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.13"
enable-cache: true
cache-dependency-glob: "**/uv.lock"
- name: Install dependencies
run: uv sync --dev
- name: Ruff
run: uv run ruff check hate_crack
- name: Ty
run: uv run ty check hate_crack
- name: Pytest
env:
HATE_CRACK_SKIP_INIT: "1"
run: uv run pytest -q
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # master
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
generate_release_notes: true
+33
View File
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Dates are omitted for releases predating this file; see the git tags for exact timing.
## [2.11.4] - 2026-07-24
### Added
- **CI now runs lint, type checks, and tests on every pull request and push to `main`.**
Previously these ran only in local `prek` pre-push hooks, so a commit pushed without hooks
installed (or with `--no-verify`) reached `main` unvalidated — and the auto-tag workflow
would then cut a release from it. The new `ci.yml` runs `ruff`, `ty`, and `pytest` on
Python 3.13, and tagging is gated on it passing.
- **Dependabot configuration** for Python (`uv`) and GitHub Actions dependencies, weekly.
Action pins are updated automatically instead of drifting. Bumps use `chore` commit
prefixes so they never auto-cut a release.
### Fixed
- **Auto-tagged versions now actually get a GitHub release.** The tag was pushed using the
default `GITHUB_TOKEN`, and GitHub suppresses workflow triggers for `GITHUB_TOKEN`-created
events, so the tag-triggered release workflow never fired — `v2.11.3` was tagged with no
release to show for it. The auto-tag workflow now creates the release itself, idempotently.
- **Breaking changes no longer ship as a minor bump.** The version logic matched the `!`
breaking-change marker but only ever bumped the minor version, and `BREAKING CHANGE:`
footers were invisible because only commit subjects were inspected. Both now correctly
trigger a major bump.
- **Concurrent merges to `main` no longer collide.** Two merges in quick succession both
computed the same new tag and the second push failed; tagging is now serialized. A re-run
against an already-tagged commit is also a clean no-op instead of a hard error.
- **`softprops/action-gh-release` is pinned to a real release** (`v2.6.2`) rather than an
arbitrary mid-development `master` commit, and all workflows now pin the same
`actions/checkout` version with accurate version comments.
- **`ty` type error in the crack tailer.** `_read_new_lines` read `self._file_pos`
(`int | None`) while its only None-guard lived in the caller, so the position is now
passed in explicitly as an `int`. Behavior is unchanged.
## [2.11.3] - 2026-07-24
### Fixed
+11 -9
View File
@@ -158,19 +158,21 @@ class CrackTailer(threading.Thread):
except OSError:
return
if self._file_pos is None:
pos = self._file_pos
if pos is None:
self._file_pos = size
return
if size < self._file_pos:
if size < pos:
# File was truncated / rewritten — reset rather than read garbage.
self._file_pos = 0
pos = 0
self._buffer = b""
self._file_pos = pos
if size == self._file_pos:
if size == pos:
return
new_lines = self._read_new_lines(size)
new_lines = self._read_new_lines(pos, size)
if not new_lines:
return
@@ -187,15 +189,15 @@ class CrackTailer(threading.Thread):
label = self._extract_username(line) or self.attack_name
self._notify_crack(label, self.attack_name)
def _read_new_lines(self, size: int) -> list[bytes]:
"""Read from ``self._file_pos`` up to ``size`` and return full lines.
def _read_new_lines(self, start: int, size: int) -> list[bytes]:
"""Read from ``start`` up to ``size`` and return full lines.
Incomplete trailing bytes are buffered for the next tick.
"""
try:
with open(self.out_path, "rb") as f:
f.seek(self._file_pos)
data = f.read(size - self._file_pos)
f.seek(start)
data = f.read(size - start)
except OSError:
return []
self._file_pos = size
+43
View File
@@ -190,6 +190,49 @@ class TestCrackTailerStop:
tailer.stop()
class TestCrackTailerPollOnceInternals:
"""Direct unit tests for _poll_once edge cases via the internal API."""
def _make_uninitialised(self, out_path: Path):
"""Return a tailer whose _file_pos is still None (seek never called)."""
tailer, notify, aggregate = _make_tailer(out_path)
# Do NOT call start() or _seek_to_eof(); leave _file_pos = None.
return tailer, notify, aggregate
def test_first_poll_with_file_pos_none_seeds_position(
self, tmp_path: Path
) -> None:
"""_poll_once with _file_pos=None must record size and return without notifying."""
out = tmp_path / "hashes.out"
out.write_text("alice:hash:plain\n")
tailer, notify, aggregate = self._make_uninitialised(out)
assert tailer._file_pos is None
tailer._poll_once()
# Position seeded to current size; no notification fired.
assert tailer._file_pos == out.stat().st_size
assert notify.call_count == 0
assert aggregate.call_count == 0
def test_truncation_resets_position_directly(self, tmp_path: Path) -> None:
"""_poll_once must reset position and buffer when the file shrinks."""
out = tmp_path / "hashes.out"
content = "alice:hash:plain\nbob:hash:plain\n"
out.write_text(content)
tailer, notify, aggregate = _make_tailer(out)
tailer._file_pos = len(content.encode()) # pretend we've read it all
tailer._buffer = b"leftover"
# Truncate to a smaller file.
out.write_text("c:h:p\n")
tailer._poll_once()
# Position must have been reset and buffer cleared.
assert tailer._file_pos == len(b"c:h:p\n")
assert tailer._buffer == b""
# One new line notified.
assert notify.call_count == 1
class TestCrackTailerFileHandling:
def test_missing_file_then_appearing(self, tmp_path: Path) -> None:
out = tmp_path / "missing.out"