File size: 10,327 Bytes
a5784e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
"""
Tests for the GitHub Actions release workflow configuration.

Required test category notes:
- Concurrency/timing: Not applicable for static YAML validation.
- Performance: Not applicable for static configuration checks.
- Security-focused: Not applicable; no auth or security logic is executed.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
import yaml

REPO_ROOT = Path(__file__).resolve().parents[1]
WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml"


def _read_release_workflow() -> str:
    """Read the release workflow file with explicit error handling."""
    try:
        return WORKFLOW_PATH.read_text(encoding="utf-8")
    except FileNotFoundError as exc:
        pytest.fail(f"Release workflow not found at {WORKFLOW_PATH}: {exc}")


def _load_release_workflow_yaml() -> dict:
    """Parse the release workflow YAML and fail with context on errors."""
    content = _read_release_workflow()
    try:
        data = yaml.safe_load(content)
    except yaml.YAMLError as exc:
        pytest.fail(f"Invalid YAML syntax in {WORKFLOW_PATH}: {exc}")
    if not isinstance(data, dict):
        pytest.fail("Release workflow YAML did not parse into a mapping")
    return data


def _extract_step_block(content: str, step_name: str) -> str:
    """Extract a step block by name from the workflow text."""
    marker = f"      - name: {step_name}"
    start = content.find(marker)
    if start == -1:
        pytest.fail(f"Step '{step_name}' was not found in release workflow")

    next_step = content.find("\n      - name:", start + len(marker))
    if next_step == -1:
        return content[start:]
    return content[start:next_step]


class TestReleaseWorkflow:
    """Release workflow validation tests."""

    def test_yaml_syntax_is_valid_and_has_jobs(self) -> None:
        """Happy path: YAML parses and includes expected top-level keys."""
        # Arrange
        # Act
        data = _load_release_workflow_yaml()

        # Assert
        assert data, "Release workflow YAML should not be empty"
        assert "jobs" in data, "Release workflow should define jobs"

    def test_stable_changelog_uses_github_username_and_fallback_author(self) -> None:
        """Boundary: stable changelog should use GitHub username lookup with fallback."""
        # Arrange
        content = _read_release_workflow()
        stable_step = _extract_step_block(content, "Generate changelog")

        # Act
        has_commit_api_lookup = (
            'gh api "repos/${{ github.repository }}/commits/$commit_sha"' in stable_step
        )
        has_jq_username_extract = "jq -r '.author.login // empty'" in stable_step
        has_github_mention_format = "by @${github_username}" in stable_step
        has_git_fallback_format = "by ${git_author}" in stable_step
        has_old_direct_git_format = "- %s by %an (%h)" in stable_step

        # Assert
        assert has_commit_api_lookup, (
            "Stable changelog must query the GitHub commit API per commit SHA"
        )
        assert has_jq_username_extract, (
            "Stable changelog must extract '.author.login // empty' with jq"
        )
        assert has_github_mention_format, (
            "Stable changelog must format GitHub-linked authors as '@username'"
        )
        assert has_git_fallback_format, (
            "Stable changelog must fall back to git author name without '@'"
        )
        assert not has_old_direct_git_format, (
            "Stable changelog must not use direct git author formatting '- %s by %an (%h)'"
        )

    def test_nightly_changes_use_github_username_and_fallback_author(self) -> None:
        """Boundary: nightly changes should use GitHub username lookup with fallback."""
        # Arrange
        content = _read_release_workflow()
        nightly_step = _extract_step_block(content, "Generate recent changes")

        # Act
        has_commit_api_lookup = (
            'gh api "repos/${{ github.repository }}/commits/$commit_sha"'
            in nightly_step
        )
        has_jq_username_extract = "jq -r '.author.login // empty'" in nightly_step
        has_github_mention_format = "by @${github_username}" in nightly_step
        has_git_fallback_format = "by ${git_author}" in nightly_step
        has_old_direct_git_format = "- %s by %an (%h)" in nightly_step

        # Assert
        assert has_commit_api_lookup, (
            "Nightly changes must query the GitHub commit API per commit SHA"
        )
        assert has_jq_username_extract, (
            "Nightly changes must extract '.author.login // empty' with jq"
        )
        assert has_github_mention_format, (
            "Nightly changes must format GitHub-linked authors as '@username'"
        )
        assert has_git_fallback_format, (
            "Nightly changes must fall back to git author name without '@'"
        )
        assert not has_old_direct_git_format, (
            "Nightly changes must not use direct git author formatting '- %s by %an (%h)'"
        )

    def test_nightly_changelog_uses_latest_stable_tag_range(self) -> None:
        """Happy path: nightly changelog should use latest stable tag range."""
        # Arrange
        content = _read_release_workflow()
        nightly_step = _extract_step_block(content, "Generate recent changes")

        # Act
        has_latest_stable_tag_lookup = (
            "git tag --sort=-v:refname | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$'"
            in nightly_step
        )
        has_range_log = (
            'git log --pretty=format:"%H" "${LATEST_STABLE_TAG}..HEAD"' in nightly_step
        )
        has_fixed_ten_commit_log = 'git log --pretty=format:"%H" -10' in nightly_step

        # Assert
        assert has_latest_stable_tag_lookup, (
            "Nightly changelog must find latest stable tag using semantic version pattern"
        )
        assert has_range_log, (
            "Nightly changelog must use git log ${LATEST_STABLE_TAG}..HEAD range"
        )
        assert not has_fixed_ten_commit_log, (
            "Nightly changelog must not use a fixed -10 commit range"
        )

    def test_nightly_changelog_fallback_when_no_stable_tag(self) -> None:
        """Null/empty: nightly changelog should fall back when no stable tag exists."""
        # Arrange
        content = _read_release_workflow()
        nightly_step = _extract_step_block(content, "Generate recent changes")

        # Act
        has_fallback_log = 'git log --pretty=format:"%H" -20' in nightly_step
        has_fallback_note = (
            "_No stable release tag found. Showing last 20 commits (first release scenario)._"
            in nightly_step
        )

        # Assert
        assert has_fallback_log, (
            "Nightly changelog must fall back to the last 20 commits when no stable tag exists"
        )
        assert has_fallback_note, (
            "Nightly changelog must include a fallback note when no stable tag exists"
        )

    def test_nightly_changelog_includes_range_note(self) -> None:
        """Boundary: nightly changelog should include range and fallback notes."""
        # Arrange
        content = _read_release_workflow()
        nightly_step = _extract_step_block(content, "Generate recent changes")

        # Act
        has_range_note = "_Changes since ${LATEST_STABLE_TAG}_" in nightly_step
        has_fallback_note = (
            "_No stable release tag found. Showing last 20 commits (first release scenario)._"
            in nightly_step
        )

        # Assert
        assert has_range_note, (
            "Nightly changelog must include a 'Changes since vX.Y.Z' note"
        )
        assert has_fallback_note, (
            "Nightly changelog must include a fallback note when no stable tag exists"
        )

    def test_docs_guides_links_are_not_broken(self) -> None:
        """Invalid/malformed: docs/guides links must resolve to files."""
        # Arrange
        content = _read_release_workflow()
        pattern = re.compile(r"docs/guides/[A-Za-z0-9._/-]+")

        # Act
        referenced_paths = pattern.findall(content)

        # Assert
        if not referenced_paths:
            return

        for raw_path in referenced_paths:
            sanitized = raw_path.rstrip(").,;\"' ")
            full_path = REPO_ROOT / sanitized
            assert full_path.exists(), f"Broken documentation link: {sanitized}"

    def test_contributor_acknowledgement_uses_thank_you_message(self) -> None:
        """Error condition: workflow must include the thank-you contributor message."""
        # Arrange
        content = _read_release_workflow()

        # Act
        thank_you_occurrences = len(
            re.findall(r"\*\*Thank you to all contributors!\*\*", content)
        )

        # Assert
        assert thank_you_occurrences >= 2, (
            "Contributor acknowledgement must include '**Thank you to all contributors!**' "
            "in both stable and nightly release sections"
        )

    def test_contributor_list_generation_is_removed(self) -> None:
        """Error condition: contributor list files should not be generated in workflow."""
        # Arrange
        content = _read_release_workflow()

        # Act
        has_stable_contributors_file = "CONTRIBUTORS.md" in content
        has_nightly_contributors_file = "NIGHTLY_CONTRIBUTORS.md" in content

        # Assert
        assert not has_stable_contributors_file, (
            "Stable release workflow must not generate CONTRIBUTORS.md"
        )
        assert not has_nightly_contributors_file, (
            "Nightly release workflow must not generate NIGHTLY_CONTRIBUTORS.md"
        )

    def test_readme_reference_exists_for_installation_instructions(self) -> None:
        """Null/empty: README reference should exist for installation guidance."""
        # Arrange
        content = _read_release_workflow()

        # Act
        has_installation_section = "## Installation" in content
        has_readme_reference = "README.md" in content

        # Assert
        assert has_installation_section, (
            "Release body should include an Installation section"
        )
        assert has_readme_reference, (
            "Release body should reference README.md for installation instructions"
        )