diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml index b17a6ff..7d65e35 100644 --- a/.gitea/workflows/auto-merge.yml +++ b/.gitea/workflows/auto-merge.yml @@ -2,13 +2,16 @@ name: Auto-merge on: pull_request: - types: [labeled] + types: [labeled, unlabeled] jobs: merge: - if: contains(github.event.pull_request.labels.*.name, 'ready-to-merge') + # Always run — the Python script checks for the label via API. + # Gitea's `labeled` event payload may not populate pull_request.labels + # correctly, so we can't rely on the YAML-level condition. + if: github.event.label.name == 'ready-to-merge' runs-on: docker - timeout-minutes: 5 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install dependencies diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 39476ac..3ad802d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,7 +31,14 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 scripts/ci/doc_coverage.py + python3 scripts/ci/doc_coverage.py --fail-on-missing + - name: Dependency security scan + run: | + . .venv/bin/activate + # Install pip in venv if missing (needed by pip-audit) + .venv/bin/python -m ensurepip 2>/dev/null || true + PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \ + pip-audit --desc --skip-editable 2>&1 || true release-dry-run: needs: [quality, detect-changes] diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 907d714..3c50f0c 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -151,7 +151,7 @@ jobs: env: VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} PYTHONPATH: src - run: python3 scripts/ci/post_merge.py --from-git + run: python3 scripts/ci/post_merge.py --git-sha "${{ github.sha }}" - name: Notify on failure if: failure() env: diff --git a/Makefile b/Makefile index c7edb86..af3d6e5 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,12 @@ lint: lint-ruff lint-format typecheck lint-bandit lint-bandit: $(BIN)/bandit -r src/ scripts/ +lint-deps: + @echo "Checking dependencies for known vulnerabilities..." + @.venv/bin/python -m ensurepip 2>/dev/null || true + @PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python \ + .venv/bin/pip-audit --desc --skip-editable 2>&1 || true + ansible-lint: $(BIN)/ansible-lint ansible/ diff --git a/pyproject.toml b/pyproject.toml index dcb12e0..8c33011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "molecule-docker>=2.1.0", "ansible-lint>=26.4.0", "bandit>=1.8.2", + "pip-audit>=2.10", "pre-commit>=4.6.0", # Non-Python dev dependency: checkmake (Makefile linter) # Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest diff --git a/scripts/ci/auto_merge.py b/scripts/ci/auto_merge.py index 8c14f4e..3b06c43 100644 --- a/scripts/ci/auto_merge.py +++ b/scripts/ci/auto_merge.py @@ -36,7 +36,7 @@ from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ READY_TO_MERGE = "ready-to-merge" -MAX_WAIT_SECONDS = 180 # 3 minutes max — CI should already be running +MAX_WAIT_SECONDS = 600 # 10 minutes max — CI may still be running when label is added POLL_INTERVAL_SECONDS = 15 # Poll every 15 seconds diff --git a/scripts/ci/post_merge.py b/scripts/ci/post_merge.py index bc64758..8c0d960 100644 --- a/scripts/ci/post_merge.py +++ b/scripts/ci/post_merge.py @@ -99,13 +99,33 @@ def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str: @click.argument("commit_msg", required=False) @click.option("--commit-sha", default="", help="Commit SHA") @click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.") -def main(commit_msg: str | None, commit_sha: str, from_git: bool) -> None: - if from_git: +@click.option( + "--git-sha", + default="", + help="Read commit message from a specific git SHA (avoids race condition with parallel jobs).", +) +def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) -> None: + if git_sha: + # Read commit message from a specific SHA — this avoids the race + # condition where a parallel job (e.g., release) pushes a new commit + # to master before this job reads HEAD. + result = subprocess.run( # nosec B603 B607 + ["git", "log", "-1", "--pretty=%B", git_sha], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise click.ClickException(f"git log failed for SHA {git_sha}: {result.stderr.strip()}") + commit_msg = result.stdout.strip() + if not commit_sha: + commit_sha = git_sha + elif from_git: commit_msg = _get_git_commit_message() if not commit_sha: commit_sha = _get_git_commit_sha() if not commit_msg: - raise click.ClickException("commit_msg argument is required (or use --from-git)") + raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)") token = os.environ.get("VIKUNJA_TOKEN", "") if not token: raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) diff --git a/tests/unit/test_post_merge.py b/tests/unit/test_post_merge.py index 721bdf9..28d4fa0 100644 --- a/tests/unit/test_post_merge.py +++ b/tests/unit/test_post_merge.py @@ -275,3 +275,54 @@ class TestFromGit: result = runner.invoke(main, []) assert result.exit_code != 0 assert "commit_msg" in result.output + + +class TestGitSha: + """Tests for the --git-sha option (race condition fix).""" + + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + @patch("scripts.ci.post_merge.VikunjaClient") + def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None: + """--git-sha reads commit message from a specific SHA, not HEAD.""" + mock_client = MagicMock() + mock_client.list_project_tasks.return_value = [ + {"id": 267, "identifier": "GRM-20"}, + ] + mock_client_cls.return_value = mock_client + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="") + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke(main, ["--git-sha", "abc123"]) + assert result.exit_code == 0 + assert "updated and marked done" in result.output + mock_client.post_comment.assert_called_once() + # Verify the SHA was passed to the comment + args, _ = mock_client.post_comment.call_args + assert "abc123" in args[1] + + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_git_sha_failure_raises(self) -> None: + """--git-sha with invalid SHA should raise.""" + mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha") + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke(main, ["--git-sha", "badsha"]) + assert result.exit_code != 0 + assert "git log failed" in result.output + + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + @patch("scripts.ci.post_merge.VikunjaClient") + def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None: + """--git-sha with --commit-sha uses the explicit SHA for the comment.""" + mock_client = MagicMock() + mock_client.list_project_tasks.return_value = [ + {"id": 267, "identifier": "GRM-20"}, + ] + mock_client_cls.return_value = mock_client + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="") + with patch("subprocess.run", return_value=mock_result): + runner = CliRunner() + result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"]) + assert result.exit_code == 0 + args, _ = mock_client.post_comment.call_args + assert "explicit_sha" in args[1]