Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release_draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:

- name: Generate Release Notes
id: generate_release_notes
uses: AbsaOSS/generate-release-notes@B90223510d1704301a36a36f0d86a72a0e72f0cf # v1.0.0
uses: AbsaOSS/generate-release-notes@bugfix/322-Compare-Mode-Fails-with-404-When-Target-Tag-Not-Yet-Created
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
Expand Down
54 changes: 52 additions & 2 deletions docs/features/compare_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,48 @@ Issues are always filtered by timestamp regardless of mode.

---

## Fallback Behavior — Compare-Endpoint 404 / Eventual Consistency

In CI/CD pipelines it is common to call the release notes generator immediately after pushing a new tag.
Due to eventual consistency, GitHub's compare endpoint may return a 404 even for a tag that was just pushed
and is technically resolvable via the API.

**Fallback strategy:**

If the compare API fails with a 404, the action performs the following:
1. Resolves the requested target ref (tag/branch/SHA) directly via `repo.get_commit()`
2. Retries the compare operation using the from-tag name and the resolved commit's SHA: `repo.compare(from_tag, commit_sha)`
3. A warning is logged to indicate the fallback occurred
4. If the target ref cannot be resolved, the action exits with a non-zero code
5. If the retry compare call fails (network error, GithubException), it is treated the same as the first call: errors are logged and the action exits

For non-404 errors on the initial compare (auth failures, rate limits, server errors) the action logs the error and exits immediately without attempting fallback.

This scenario is handled gracefully when tags are created in a sequence:

```yaml
- name: Build & Tag Release
run: |
./scripts/build.sh
git tag v2.6.4
git push origin v2.6.4

- name: Generate Release Notes # Runs immediately after tagging
uses: AbsaOSS/generate-release-notes@v1
with:
tag-name: v2.6.4
from-tag-name: v2.6.3
```

**Log output when fallback is triggered:**

```log
2026-06-25 12:22:27 - INFO - Compare mode: using repo.compare('v2.6.3', 'v2.6.4').
2026-06-25 12:22:27 - WARNING - Compare API returned 404 for 'v2.6.3'...'v2.6.4': target tag does not exist. Falling back to latest commit on default branch.
```

---

## Data Flow

```
Expand All @@ -109,8 +151,16 @@ from-tag-name provided?
GitHub Compare API: get_commits(since=data.since)
commits unique to to-tag get_pulls(state=closed)
│ │
extract PR numbers FilterByRelease drops
from commit messages PRs/commits before since
API fails (404)? FilterByRelease drops
│ PRs/commits before since
├─ YES: Fallback to
│ get_commit(target)
│ retry compare with
│ resolved SHA
│ (log warning)
extract PR numbers
from commit messages
fetch each PR by number
Expand Down
72 changes: 69 additions & 3 deletions release_notes_generator/data/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
import semver
from github import Github
from github.GitRelease import GitRelease
from github import GithubException
from github.Issue import Issue
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout, RequestException
from github.PullRequest import PullRequest
from github.Repository import Repository
from github.Commit import Commit as GithubCommit
Expand Down Expand Up @@ -56,6 +58,7 @@ class DataMiner:

def __init__(self, github_instance: Github, rate_limiter: GithubRateLimiter):
self.github_instance = github_instance
self._rate_limiter = rate_limiter
self._safe_call = safe_call_decorator(rate_limiter)

def mine_data(self) -> MinedData:
Expand Down Expand Up @@ -99,6 +102,11 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:

Logic:
- Fetch commits between from_tag and to_tag using repo.compare().
- If compare fails with 404 (target tag does not exist yet),
fall back to the latest commit on the default branch.
- If compare fails with other errors (auth, rate-limit, transient), log and exit.
- If fallback resolve succeeds, use the latest commit on default branch as baseline.
- If fallback resolve also fails, log and exit.
- Extract PR numbers from commit messages and fetch those PRs.
- Filter out commits that already have a PR reference to avoid duplication.
"""
Expand All @@ -107,14 +115,72 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
)
comparison = self._safe_call(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name())
if comparison is None:
comparison = None
try:
comparison = self._rate_limiter(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name())
except (RequestsConnectionError, Timeout, RequestException) as e:
Comment thread
miroslavpojer marked this conversation as resolved.
logger.error(
"Compare API returned no result for '%s'...'%s'. Ending!",
"Network error during compare API call for '%s'...'%s': %s",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
e,
)
sys.exit(1)
except GithubException as e:
if e.status == 404:
Comment thread
miroslavpojer marked this conversation as resolved.
logger.warning(
"Compare API returned 404 for '%s'...'%s': target tag does not exist. "
"Falling back to latest commit on default branch.",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
)
else:
logger.error(
"Compare API failed for '%s'...'%s' with status %d: %s",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
e.status,
e.data.get("message", str(e)) if isinstance(e.data, dict) else str(e),
)
sys.exit(1)

Comment thread
miroslavpojer marked this conversation as resolved.
if comparison is None:
# Fall back: target tag/ref does not exist yet; resolve it directly
target_commit = self._safe_call(repo.get_commit)(ActionInputs.get_tag_name())
if target_commit is None:
logger.error(
"Could not retrieve commit for target ref '%s'. Ending!",
ActionInputs.get_tag_name(),
)
sys.exit(1)
# Retry compare using the resolved commit SHA as the target
try:
comparison = self._rate_limiter(repo.compare)(ActionInputs.get_from_tag_name(), target_commit.sha)
except (RequestsConnectionError, Timeout, RequestException) as e:
logger.error(
"Network error during retry compare API call for '%s'...'%s': %s",
ActionInputs.get_from_tag_name(),
target_commit.sha,
e,
)
sys.exit(1)
except GithubException as e:
logger.error(
"Retry compare API failed for '%s'...'%s' with status %d: %s",
ActionInputs.get_from_tag_name(),
target_commit.sha,
e.status,
e.data.get("message", str(e)) if isinstance(e.data, dict) else str(e),
)
sys.exit(1)

if comparison is None:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
logger.error(
"Compare API returned no result for '%s'...'%s'. Ending!",
ActionInputs.get_from_tag_name(),
target_commit.sha,
)
sys.exit(1)
compare_commits: list[GithubCommit] = list(comparison.commits)
total_commits = getattr(comparison, "total_commits", None)
if isinstance(total_commits, int) and total_commits > len(compare_commits):
Expand Down
Loading
Loading