fix(ci): drop leftover changelog-guard and release-notifier (PLAT-219) (#266)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions

Prod is already a human GitHub Release. Remove the SAM tagging path so CHANGELOG.md stays App Home copy and leftover notifier IAM is destroyed on the next apply.
This commit is contained in:
Adam Moussa 2026-09-21 23:24:05 +00:00 • committed by GitHub
parent be1160192c
commit 26e9716df4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 46 additions and 566 deletions

View file

@ -1,38 +0,0 @@
name: Changelog Guard
# Repo-specific PR check (in addition to the reusable ci.yaml). Enforces that
# CHANGELOG.md drives versioning correctly: a changelog edit must be a clean
# SemVer bump above the latest tag, and the in-package copy must stay in sync.
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Validate CHANGELOG + version bump
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if git diff --name-only "$BASE_SHA" HEAD | grep -qx 'CHANGELOG.md'; then
CHANGELOG_CHANGED=true
else
CHANGELOG_CHANGED=false
fi
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1)
echo "CHANGELOG changed in PR: $CHANGELOG_CHANGED | latest tag: ${PREV_TAG:-none}"
CHANGELOG_CHANGED="$CHANGELOG_CHANGED" PREV_TAG="$PREV_TAG" \
python scripts/check_changelog.py

View file

@ -19,7 +19,6 @@ on:
- "docs/**"
- "*.md"
- ".github/workflows/ci.yaml"
- ".github/workflows/changelog-guard.yml"
- ".github/workflows/labeler.yml"
- ".github/workflows/dependency-review.yml"
release:

View file

@ -75,7 +75,6 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
| `afterhours-portal-api` | API Gateway (ANY /api/shifts, ANY /api/shifts/{proxy+}) | Cognito-authenticated employee/admin shift API for the internal portal |
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
| `afterhours-holiday-router` | EventBridge Scheduler (per-holiday one-off: 8am activate / 5pm deactivate ET) | Repoints the IVR to the holiday queue and sets queue agents for a holiday day shift; reverts at 5pm (see [Holidays](#holidays)) |
| `afterhours-release-notifier` | Skeleton only until tagging exists | Posts a "What's New" announcement to the shift channel |
### Project Layout
@ -89,10 +88,9 @@ src/
portal-api/ Cognito employee/admin shift API for the internal portal
ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
release-notifier/ Posts release announcements to Slack
shared/ Bundled into each function zip and the Fargate image
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
scripts/ changelog CLI + CI guard + in-package copy sync + cutover
scripts/ in-package changelog copy sync + cutover
tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
```
@ -267,13 +265,20 @@ transition normally. `ThrottledRequests` and `SystemErrors` are intentionally
## Releases & Versioning
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`**. The
**App Home** tab reads the copy that ships in the slack-bot zip. Run
`python scripts/sync_changelog.py` after editing the root file. Changelog Guard
enforces that the in-package copy matches.
Prod is a human GitHub Release:
GitHub Releases and git tags are not cut by `deploy-api.yaml`. `afterhours-release-notifier`
exists as leftover IAM from dual-run; CD does not invoke it.
```bash
gh release create vX.Y.Z --target main --generate-notes
```
That tag applies prod infra in HCP and queues `deploy-api.yaml` behind the prod
Environment. Merge to `main` deploys **dev**. Nothing in GitHub Actions creates
the Release.
**App Home "What's New"** still reads **`CHANGELOG.md`**. That file is product
copy, not the prod tag driver. After editing the root file, run
`python scripts/sync_changelog.py` so `src/slack-bot/CHANGELOG.md` stays in
sync. Pytest fails if the two copies drift.
## Testing

View file

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Thin CLI over ``shared.changelog`` for the release workflow.
Keeps all changelog parsing in one tested module instead of inline shell/Python
in the workflow. Reads the changelog file given as an argument.
Usage:
changelog_cli.py top-version <file> # newest entry's version
changelog_cli.py bump-kind <file> <prev> # major|minor|patch|none vs <prev>
changelog_cli.py payload <file> <version> # JSON {version, notes, date_label}
"""
import json
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared"))
from shared.changelog import bump_kind, entry_for, top_version # noqa: E402
def main(argv: list[str]) -> int:
if len(argv) < 3:
sys.exit(__doc__)
cmd, path = argv[1], argv[2]
text = pathlib.Path(path).read_text()
if cmd == "top-version":
sys.stdout.write(top_version(text) or "")
elif cmd == "bump-kind":
prev = argv[3].lstrip("v")
top = top_version(text)
sys.stdout.write((bump_kind(top, prev) or "none") if top else "none")
elif cmd == "payload":
version = argv[3].lstrip("v")
entry = entry_for(text, version)
sys.stdout.write(
json.dumps(
{
"version": version,
"notes": entry.body if entry else "",
"date_label": entry.date_label if entry else "",
}
)
)
else:
sys.exit(f"unknown command: {cmd}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View file

@ -1,72 +0,0 @@
#!/usr/bin/env python3
"""CI guard: validate CHANGELOG.md versioning and the in-package copy.
Run on pull requests. Two checks:
1. If CHANGELOG.md changed in the PR, its top version must be a clean
single-step SemVer bump above the latest ``v*`` tag. (Non-changing PRs —
dependabot bumps, docs — are not version-checked, so they don't release.)
2. ``src/slack-bot/CHANGELOG.md`` (the copy that ships with the bot and feeds the
App Home "What's New" tab) must match the canonical root CHANGELOG.md.
The workflow passes context via env: ``PREV_TAG`` (latest tag) and
``CHANGELOG_CHANGED`` ("true"/"false"). Run locally it assumes the changelog
changed so the bump is validated.
"""
import os
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared"))
from shared.changelog import bump_kind, top_version # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parents[1]
PKG_COPY = ROOT / "src" / "slack-bot" / "CHANGELOG.md"
def evaluate(
top: str | None, prev_tag: str, changelog_changed: bool, copies_match: bool
) -> list[str]:
"""Return a list of problems (empty == pass). Pure, for unit testing."""
problems = []
if not copies_match:
problems.append(
"src/slack-bot/CHANGELOG.md is out of sync with CHANGELOG.md — "
"run scripts/sync_changelog.py"
)
if changelog_changed:
if top is None:
problems.append("CHANGELOG.md changed but has no version entry at the top")
else:
prev = (prev_tag or "v0.0.0").lstrip("v")
if bump_kind(top, prev) is None:
problems.append(
f"top version v{top} is not a clean single-step SemVer bump "
f"above the latest tag v{prev} (expected one of "
f"inc-major / inc-minor / inc-patch)"
)
return problems
def main() -> int:
root_text = (ROOT / "CHANGELOG.md").read_text()
copies_match = PKG_COPY.exists() and PKG_COPY.read_text() == root_text
problems = evaluate(
top=top_version(root_text),
prev_tag=os.environ.get("PREV_TAG", ""),
changelog_changed=os.environ.get("CHANGELOG_CHANGED", "true") == "true",
copies_match=copies_match,
)
if problems:
for problem in problems:
print(f"::error::{problem}")
return 1
print("CHANGELOG guard passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -27,7 +27,6 @@ FUNCTIONS = {
"roster_api": ROOT / "src" / "roster-api",
"ring_scheduler": ROOT / "src" / "ring-scheduler",
"holiday_router": ROOT / "src" / "holiday-router",
"release_notifier": ROOT / "src" / "release-notifier",
"portal_api": ROOT / "src" / "portal-api",
}

View file

@ -1,10 +1,9 @@
#!/usr/bin/env python3
"""Copy the canonical root CHANGELOG.md into the slack-bot package.
The bot's App Home "What's New" tab reads CHANGELOG.md from its own deployment
package ($LAMBDA_TASK_ROOT), so a copy must live under src/slack-bot/ (the
function's CodeUri). The root file is the single source of truth; run this after
editing it. CI's check_changelog.py fails if the two drift.
The bot's App Home "What's New" tab reads CHANGELOG.md next to app.py in the
Fargate image, so a copy must live under src/slack-bot/. The root file is the
single source of truth; run this after editing it. Pytest fails if the two drift.
"""
import pathlib

View file

@ -1,46 +0,0 @@
"""Lambda handler — announces a new release to the shift channel.
Invoked by the release workflow (``.github/workflows/release.yaml``) once a
minor or major version has been tagged *and* the new code has deployed
successfully. The event carries the version and notes already extracted from
CHANGELOG.md by the workflow, so this function never reads the changelog file
itself (it ships only in the slack-bot package, not here).
Event shape (the frozen contract between release.yaml and this function):
{"version": "1.10.0", "notes": "<markdown>", "date_label": "June 11, 2026"}
"""
import logging
import os
from slack_sdk import WebClient
import shared.sentry_init # noqa: F401
from shared.blocks import build_release_announcement_blocks
from shared.secrets import get_secret
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
event = event or {}
version = event.get("version")
notes = event.get("notes")
date_label = event.get("date_label", "")
if not version or not notes:
raise ValueError("event requires non-empty 'version' and 'notes'")
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
channel_id = os.environ["SHIFT_CHANNEL"]
slack = WebClient(token=bot_token)
blocks = build_release_announcement_blocks(version, notes, date_label)
result = slack.chat_postMessage(
channel=channel_id,
blocks=blocks,
text=f"What's New — v{version}",
)
logger.info("Announced v%s to %s (ts=%s)", version, channel_id, result["ts"])
return {"announced": True, "version": version, "ts": result["ts"]}

View file

@ -1,2 +0,0 @@
slack_sdk>=3.44.1,<4.0
boto3>=1.43.97

View file

@ -32,39 +32,6 @@ def markdown_to_mrkdwn(text: str) -> str:
return text
def build_release_announcement_blocks(
version: str, notes: str, date_label: str = ""
) -> list[dict]:
"""Build the channel post announcing a new minor/major release.
Args:
version: SemVer string without the ``v`` prefix, e.g. ``"1.10.0"``.
notes: the changelog entry body in Markdown.
date_label: human date, e.g. ``"June 11, 2026"`` (optional).
"""
body = markdown_to_mrkdwn(notes.strip())
if len(body) > _SECTION_LIMIT:
body = (
body[:_SECTION_LIMIT].rstrip() + "\n\n_…see the full changelog for more._"
)
blocks: list[dict] = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"What's New — v{version}"},
}
]
if date_label:
blocks.append(
{
"type": "context",
"elements": [{"type": "mrkdwn", "text": f"Released {date_label}"}],
}
)
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": body}})
return blocks
SHIFT_LABELS = {
"day": "Day (8am–5pm)",
"night": "Night (5pm–8am)",
@ -344,9 +311,9 @@ def build_holiday_added_blocks(
) -> list[dict]:
"""Build the channel post announcing a newly scheduled holiday.
Mirrors :func:`build_release_announcement_blocks`: a header, an optional
context line, then a section describing the holiday day-shift (08:00–17:00
ET), its open slots, and the pay multiplier so people know to pick it up.
A header, an optional context line, then a section describing the holiday
day-shift (08:00–17:00 ET), its open slots, and the pay multiplier so people
know to pick it up.
Args:
date_str: the holiday date, ``YYYY-MM-DD``.

View file

@ -1,11 +1,9 @@
"""Parse CHANGELOG.md — the single source of truth for version and release notes.
"""Parse CHANGELOG.md for Slack App Home "What's New".
These are pure, text-in helpers shared by three consumers so the parsing rules
live in exactly one place:
* the Slack App Home tab — renders the newest entry ("What's New in vX.Y.Z"),
* the release workflow — pulls the notes for the tag it is about to create,
* the CI changelog guard — validates the top version is a clean SemVer bump.
The root CHANGELOG.md is the source of truth. ``scripts/sync_changelog.py``
copies it into ``src/slack-bot/CHANGELOG.md`` so the Fargate image can read it
next to ``app.py``. GitHub Releases are cut separately with
``gh release create``; the changelog is not the prod tag driver.
The parser tolerates the file's leading preamble (prose before the first ``##``
header), date-only historical headers like ``## May 1, 2026 — …`` (no version),
@ -49,10 +47,6 @@ def _version_key(version: str) -> tuple[int, int, int]:
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def _normalize(version: str) -> str:
return version.lstrip("v")
def _strip_rules(body: str) -> str:
"""Drop ``---`` thematic-break lines from the body's edges.
@ -95,42 +89,3 @@ def latest_entry(text: str) -> Entry | None:
if entry.versions:
return entry
return None
def top_version(text: str) -> str | None:
"""The version of the newest entry — what a new release tags against."""
entry = latest_entry(text)
return entry.version if entry else None
def entry_for(text: str, version: str) -> Entry | None:
"""The entry whose header includes ``version`` (``v`` prefix optional)."""
target = _normalize(version)
for entry in parse_changelog(text):
if any(_normalize(v) == target for v in entry.versions):
return entry
return None
def bump_kind(new: str, prev: str) -> str | None:
"""Classify ``new`` relative to ``prev`` as a single clean SemVer step.
Returns ``"major"``, ``"minor"``, or ``"patch"`` for an exact one-step
increment, or None for anything else (skip, multi-step, or downgrade). Note a
minor bump resets patch to 0 (``1.9.2 -> 1.10.0``), so this compares whole
tuples rather than counting changed components.
"""
nmaj, nmin, npat = _version_key(new)
pmaj, pmin, ppat = _version_key(prev)
if (nmaj, nmin, npat) == (pmaj + 1, 0, 0):
return "major"
if (nmaj, nmin, npat) == (pmaj, pmin + 1, 0):
return "minor"
if (nmaj, nmin, npat) == (pmaj, pmin, ppat + 1):
return "patch"
return None
def is_valid_bump(new: str, prev: str) -> bool:
"""True iff ``new`` is exactly one SemVer step above ``prev``."""
return bump_kind(new, prev) is not None

View file

@ -1,4 +1,4 @@
# Leftover Lambda IAM roles from dual-run. The eight functions are gone;
# Leftover Lambda IAM roles from dual-run. The seven functions are gone;
# weekly-post remains so paychex-checkcomponents can keep that principal
# until a follow-up queue-policy apply drops it.
@ -134,14 +134,6 @@ locals {
condition = null
},
]
release_notifier = [
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"]
condition = null
},
]
portal_api = [
{
sid = "DdbCrud"
@ -228,12 +220,6 @@ locals {
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
release_notifier = {
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SHIFT_CHANNEL = var.shift_channel
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
portal_api = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"

View file

@ -92,13 +92,6 @@ locals {
timeout = 60
duration_ms = 48000
}
release_notifier = {
function_name = "afterhours-release-notifier"
role_name = "afterhours-shift-manager-release-notifier"
handler = "app.handler"
timeout = 30
duration_ms = 24000
}
portal_api = {
function_name = "afterhours-portal-api"
role_name = "afterhours-shift-manager-portal-api"

View file

@ -128,7 +128,7 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
assert "checkcomponents_pair" in data_tf
def test_eight_function_names_still_on_leftover_roles():
def test_seven_function_names_still_on_leftover_roles():
for name in (
"afterhours-shift-manager",
"afterhours-weekly-post",
@ -136,10 +136,10 @@ def test_eight_function_names_still_on_leftover_roles():
"afterhours-roster-api",
"afterhours-ring-scheduler",
"afterhours-holiday-router",
"afterhours-release-notifier",
"afterhours-portal-api",
):
assert name in LOCALS
assert "afterhours-release-notifier" not in LOCALS
def test_weekly_post_role_is_tf_managed_name():

View file

@ -1,22 +0,0 @@
"""Load src/release-notifier/app.py under a unique module name."""
import importlib.util
import pathlib
import sys
import pytest
_ROOT = pathlib.Path(__file__).resolve().parents[2]
def _load(name, relpath):
spec = importlib.util.spec_from_file_location(name, _ROOT / relpath)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def notifier_app():
return _load("release_notifier_app", "src/release-notifier/app.py")

View file

@ -1,67 +0,0 @@
"""Tests for the release-notifier Lambda handler."""
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def slack(notifier_app, monkeypatch):
"""Fake Slack WebClient; chat_postMessage returns a message ts."""
fake = MagicMock(name="slack")
fake.chat_postMessage.return_value = {"ts": "111.222"}
monkeypatch.setattr(notifier_app, "WebClient", MagicMock(return_value=fake))
monkeypatch.setattr(notifier_app, "get_secret", lambda _id: "xoxb-test")
return fake
@pytest.fixture
def env(monkeypatch):
monkeypatch.setenv(
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
)
monkeypatch.setenv("SHIFT_CHANNEL", "C_RELEASES")
def test_posts_announcement_to_channel(notifier_app, slack, env):
result = notifier_app.handler(
{
"version": "1.10.0",
"notes": "**Release notes** now self-announce.",
"date_label": "June 11, 2026",
},
None,
)
assert result == {"announced": True, "version": "1.10.0", "ts": "111.222"}
slack.chat_postMessage.assert_called_once()
kwargs = slack.chat_postMessage.call_args.kwargs
assert kwargs["channel"] == "C_RELEASES"
assert kwargs["text"] == "What's New — v1.10.0"
# Markdown was converted to Slack mrkdwn in the rendered blocks.
rendered = str(kwargs["blocks"])
assert "*Release notes*" in rendered
def test_uses_the_slack_bot_token_secret(notifier_app, slack, env, monkeypatch):
seen = {}
monkeypatch.setattr(
notifier_app, "get_secret", lambda sid: seen.setdefault("id", sid) or "xoxb"
)
notifier_app.handler({"version": "2.0.0", "notes": "Big."}, None)
assert seen["id"] == "afterhours-shift-manager/slack-bot-token"
@pytest.mark.parametrize(
"event",
[
{},
{"version": "1.10.0"}, # missing notes
{"notes": "x"}, # missing version
{"version": "", "notes": "x"}, # empty version
],
)
def test_rejects_incomplete_event(notifier_app, slack, env, event):
with pytest.raises(ValueError):
notifier_app.handler(event, None)
slack.chat_postMessage.assert_not_called()

View file

@ -1,70 +0,0 @@
"""Tests for the release tooling scripts (CI guard + changelog CLI)."""
import importlib.util
import json
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parents[2]
def _load(name, relpath):
spec = importlib.util.spec_from_file_location(name, ROOT / relpath)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
check = _load("check_changelog", "scripts/check_changelog.py")
cli = _load("changelog_cli", "scripts/changelog_cli.py")
class TestGuardEvaluate:
def test_clean_minor_bump_passes(self):
assert check.evaluate("1.10.0", "v1.9.2", True, True) == []
def test_bad_bump_flagged(self):
problems = check.evaluate("1.11.0", "v1.9.2", True, True) # skips a minor
assert problems and "clean single-step" in problems[0]
def test_unchanged_changelog_is_not_version_checked(self):
# A docs/dependabot PR (no CHANGELOG edit) never trips the bump check.
assert check.evaluate("5.0.0", "v1.9.2", False, True) == []
def test_copy_drift_flagged_even_when_unchanged(self):
problems = check.evaluate("1.9.2", "v1.9.2", False, False)
assert any("out of sync" in p for p in problems)
def test_missing_top_version_flagged_when_changed(self):
problems = check.evaluate(None, "v1.9.2", True, True)
assert any("no version entry" in p for p in problems)
def test_first_release_from_no_tags(self):
assert check.evaluate("0.1.0", "", True, True) == []
class TestChangelogCli:
SAMPLE = "## v1.10.0 — June 11, 2026\n\n**Self-announcing** releases.\n"
def test_top_version(self, tmp_path, capsys):
f = tmp_path / "CHANGELOG.md"
f.write_text(self.SAMPLE)
cli.main(["x", "top-version", str(f)])
assert capsys.readouterr().out == "1.10.0"
def test_bump_kind(self, tmp_path, capsys):
f = tmp_path / "CHANGELOG.md"
f.write_text(self.SAMPLE)
cli.main(["x", "bump-kind", str(f), "v1.9.2"])
assert capsys.readouterr().out == "minor"
def test_payload(self, tmp_path, capsys):
f = tmp_path / "CHANGELOG.md"
f.write_text(self.SAMPLE)
cli.main(["x", "payload", str(f), "1.10.0"])
out = json.loads(capsys.readouterr().out)
assert out["version"] == "1.10.0"
assert out["date_label"] == "June 11, 2026"
# CLI emits raw markdown; Slack conversion happens later, in the Lambda.
assert "**Self-announcing**" in out["notes"]

View file

@ -11,7 +11,6 @@ from shared.blocks import (
build_pay_summary_blocks,
build_pickup_request_blocks,
build_pickup_resolved_blocks,
build_release_announcement_blocks,
build_roster_blocks,
build_shift_change_message,
build_swap_request_blocks,
@ -459,7 +458,7 @@ class TestBuildAdminOverview:
assert "0/2 filled" in text
class TestReleaseAnnouncement:
class TestMarkdownToMrkdwn:
def test_markdown_bold_becomes_slack_bold(self):
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"
@ -486,23 +485,3 @@ class TestReleaseAnnouncement:
start = time.perf_counter()
markdown_to_mrkdwn(evil)
assert time.perf_counter() - start < 1.0
def test_blocks_have_header_and_notes(self):
blocks = build_release_announcement_blocks(
"1.10.0", "**Release notes** now self-announce.", "June 11, 2026"
)
assert blocks[0]["type"] == "header"
assert blocks[0]["text"]["text"] == "What's New — v1.10.0"
assert any(b.get("type") == "context" for b in blocks)
section = blocks[-1]
assert section["type"] == "section"
assert "*Release notes*" in section["text"]["text"]
def test_date_label_optional(self):
blocks = build_release_announcement_blocks("2.0.0", "Notes.")
assert not any(b.get("type") == "context" for b in blocks)
def test_long_notes_truncated_under_section_limit(self):
blocks = build_release_announcement_blocks("1.10.0", "x " * 3000)
assert len(blocks[-1]["text"]["text"]) <= 3000
assert "full changelog" in blocks[-1]["text"]["text"]

View file

@ -1,16 +1,10 @@
"""Tests for the CHANGELOG parser — the single source of truth for versioning."""
"""Tests for the CHANGELOG parser used by Slack App Home."""
import pytest
from pathlib import Path
from shared.changelog import (
bump_kind,
entry_for,
is_valid_bump,
latest_entry,
parse_changelog,
top_version,
version_entries,
)
from shared.changelog import latest_entry, parse_changelog, version_entries
ROOT = Path(__file__).resolve().parents[2]
# A faithful slice of the real file: preamble prose, a plain version entry, a
# legacy combined entry, and date-only historical headers with no version.
@ -46,16 +40,18 @@ The first version.
"""
def test_package_copy_matches_root_changelog():
root = (ROOT / "CHANGELOG.md").read_text()
package = (ROOT / "src" / "slack-bot" / "CHANGELOG.md").read_text()
assert package == root
def test_preamble_is_not_an_entry():
# The "# Changelog" h1 and prose before the first "##" must not parse as entries.
entries = parse_changelog(SAMPLE)
assert all("Changelog" not in e.title for e in entries)
def test_top_version_skips_preamble():
assert top_version(SAMPLE) == "1.10.0"
def test_latest_entry_fields():
entry = latest_entry(SAMPLE)
assert entry.version == "1.10.0"
@ -64,18 +60,15 @@ def test_latest_entry_fields():
def test_combined_header_reports_higher_version():
entry = entry_for(SAMPLE, "1.9.0")
assert entry.versions == ("1.9.0", "1.9.1")
assert entry.version == "1.9.1" # the higher of the two
def test_combined_header_is_findable_by_either_version():
assert entry_for(SAMPLE, "1.9.1") == entry_for(SAMPLE, "v1.9.0")
combined = next(e for e in parse_changelog(SAMPLE) if "v1.9.0" in e.title)
assert combined.versions == ("1.9.0", "1.9.1")
assert combined.version == "1.9.1" # the higher of the two
def test_body_excludes_thematic_break_rules():
# The "---" rule separating eras must not bleed into the combined entry's notes.
assert "---" not in entry_for(SAMPLE, "1.9.0").body
combined = next(e for e in parse_changelog(SAMPLE) if "v1.9.0" in e.title)
assert "---" not in combined.body
def test_date_only_headers_carry_no_version():
@ -89,31 +82,5 @@ def test_version_entries_excludes_date_only():
assert versions == ["1.10.0", "1.9.2", "1.9.1"]
def test_entry_for_accepts_v_prefix():
assert entry_for(SAMPLE, "v1.10.0").version == "1.10.0"
def test_entry_for_unknown_version_is_none():
assert entry_for(SAMPLE, "2.0.0") is None
def test_empty_changelog_has_no_top_version():
assert top_version("# Changelog\n\nNothing yet.\n") is None
def test_empty_changelog_has_no_latest_entry():
assert latest_entry("# Changelog\n") is None
@pytest.mark.parametrize(
"new,prev,expected",
[
("1.10.0", "1.9.2", "minor"), # minor resets patch to 0
("2.0.0", "1.9.2", "major"),
("1.9.3", "1.9.2", "patch"),
("1.11.0", "1.9.2", None), # skips a minor
("1.9.2", "1.9.2", None), # no change
("1.9.1", "1.9.2", None), # downgrade
("3.0.0", "1.9.2", None), # skips a major
],
)
def test_bump_kind(new, prev, expected):
assert bump_kind(new, prev) == expected
assert is_valid_bump(new, prev) is (expected is not None)