From 665c8ab47c078c12e58dad7b9a0f9fbadb043406 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 16:39:59 -0400 Subject: [PATCH 1/6] fix(ci): add test CI, repair auto-tag releases, harden action pins (2.11.4) The repo had no CI: ruff/ty/pytest ran only in local prek pre-push hooks, so a commit pushed without hooks installed reached main unvalidated -- and auto-tag would then cut a release from it. Adds ci.yml (ruff, ty, pytest on 3.13) and gates tagging on it. Auto-tagged versions also never produced a release. The tag is pushed with the default GITHUB_TOKEN, and GitHub suppresses workflow triggers for GITHUB_TOKEN-created events, so the tag-triggered release.yml never fired -- v2.11.3 was tagged with no release. auto-tag now creates the release itself, idempotently; release.yml stays as the manual-tag path. Also fixes version logic that matched the `!` breaking marker but only ever bumped minor (and never saw BREAKING CHANGE: footers, since only subjects were inspected), serializes concurrent merges that both computed the same tag, and repins softprops/action-gh-release from an arbitrary master commit to v2.6.2 with all workflows on one actions/checkout version. Clears the pre-existing ty error in notify/tailer.py that would have made the new type-check step red: _read_new_lines read self._file_pos (int | None) while its only None-guard lived in the caller, so the position is passed in explicitly as an int. Behavior unchanged. Co-Authored-By: Claude --- .github/dependabot.yml | 29 ++++++++++ .github/workflows/auto-tag.yml | 99 ++++++++++++++++++++++++++++------ .github/workflows/ci.yml | 45 ++++++++++++++++ .github/workflows/release.yml | 4 +- CHANGELOG.md | 33 ++++++++++++ hate_crack/notify/tailer.py | 20 +++---- tests/test_notify_tailer.py | 43 +++++++++++++++ 7 files changed, 245 insertions(+), 28 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..942ce9f --- /dev/null +++ b/.github/dependabot.yml @@ -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)" diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 92ad803..3fde64d 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -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 <> "$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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ddd9cfa --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52ea998..97347e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdc581..620e3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/hate_crack/notify/tailer.py b/hate_crack/notify/tailer.py index 0808eb1..4645b62 100644 --- a/hate_crack/notify/tailer.py +++ b/hate_crack/notify/tailer.py @@ -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 diff --git a/tests/test_notify_tailer.py b/tests/test_notify_tailer.py index 7404ed2..ff63825 100644 --- a/tests/test_notify_tailer.py +++ b/tests/test_notify_tailer.py @@ -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" From ba6bb999bb5408869bcc7eca8d1cca7a410f3fef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:47:32 +0000 Subject: [PATCH 2/6] chore(ci): bump softprops/action-gh-release from 2.6.2 to 3.0.2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.2 to 3.0.2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/3bb12739c298aeb8a4eeaf626c5b8d85266b0e65...3d0d9888cb7fd7b750713d6e236d1fcb99157228) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97347e0..0654ee6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,6 @@ jobs: with: persist-credentials: false - - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: generate_release_notes: true From 415255e1ec40463d18ebebacb82e20ef95588161 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:01 +0000 Subject: [PATCH 3/6] chore(deps-dev): bump pytest-cov from 7.0.0 to 7.1.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 7.0.0 to 7.1.0. - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v7.0.0...v7.1.0) --- updated-dependencies: - dependency-name: pytest-cov dependency-version: 7.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..459a967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,6 @@ dev = [ "ty==0.0.17", "ruff==0.15.1", "pytest==9.0.3", - "pytest-cov==7.0.0", + "pytest-cov==7.1.0", "pytest-timeout>=2.4.0", ] From 14c332d2b162885d16173a073b2c1b96766e2e14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:07 +0000 Subject: [PATCH 4/6] chore(deps): update packaging requirement from >=21.0 to >=26.2 Updates the requirements on [packaging](https://github.com/pypa/packaging) to permit the latest version. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/21.0...26.2) --- updated-dependencies: - dependency-name: packaging dependency-version: '26.2' dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..c6d1227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "requests>=2.31.0", "beautifulsoup4>=4.12.0", "openpyxl>=3.0.0", - "packaging>=21.0", + "packaging>=26.2", "simple-term-menu==1.6.6", "click>=8.0.0", ] From 9297670980b18472bf9676f50201ca9576394e8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:08 +0000 Subject: [PATCH 5/6] chore(deps): update requests requirement from >=2.31.0 to >=2.34.2 Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.34.2) --- updated-dependencies: - dependency-name: requests dependency-version: 2.34.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..e40a19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Menu driven Python wrapper for hashcat" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "requests>=2.31.0", + "requests>=2.34.2", "beautifulsoup4>=4.12.0", "openpyxl>=3.0.0", "packaging>=21.0", From b385f391a408d535897c15c2f09f9b6e4b781d1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:13 +0000 Subject: [PATCH 6/6] chore(deps): update click requirement from >=8.0.0 to >=8.4.2 Updates the requirements on [click](https://github.com/pallets/click) to permit the latest version. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/main/CHANGES.md) - [Commits](https://github.com/pallets/click/compare/8.0.0...8.4.2) --- updated-dependencies: - dependency-name: click dependency-version: 8.4.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..2bb559c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "openpyxl>=3.0.0", "packaging>=21.0", "simple-term-menu==1.6.6", - "click>=8.0.0", + "click>=8.4.2", ] [project.scripts]