mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
a0dc56ce08
commit
665c8ab47c
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user