From 26e9716df4d38d00e23bc135831ccc8b9fe7a81d Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:24:05 +0000 Subject: [PATCH] fix(ci): drop leftover changelog-guard and release-notifier (PLAT-219) (#266) 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. --- .github/workflows/changelog-guard.yml | 38 -------------- .github/workflows/deploy-api.yaml | 1 - README.md | 23 ++++---- scripts/changelog_cli.py | 52 ------------------- scripts/check_changelog.py | 72 -------------------------- scripts/package_lambdas.py | 1 - scripts/sync_changelog.py | 7 ++- src/release-notifier/app.py | 46 ---------------- src/release-notifier/requirements.txt | 2 - src/shared/shared/blocks.py | 39 ++------------ src/shared/shared/changelog.py | 55 ++------------------ terraform/lambda.tf | 16 +----- terraform/locals.tf | 7 --- tests/infra/test_hcp_contract.py | 4 +- tests/release_notifier/conftest.py | 22 -------- tests/release_notifier/test_handler.py | 67 ------------------------ tests/scripts/test_release_tooling.py | 70 ------------------------- tests/shared/test_blocks.py | 23 +------- tests/shared/test_changelog.py | 67 ++++++------------------ 19 files changed, 46 insertions(+), 566 deletions(-) delete mode 100644 .github/workflows/changelog-guard.yml delete mode 100644 scripts/changelog_cli.py delete mode 100644 scripts/check_changelog.py delete mode 100644 src/release-notifier/app.py delete mode 100644 src/release-notifier/requirements.txt delete mode 100644 tests/release_notifier/conftest.py delete mode 100644 tests/release_notifier/test_handler.py delete mode 100644 tests/scripts/test_release_tooling.py diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml deleted file mode 100644 index f2fe3c7..0000000 --- a/.github/workflows/changelog-guard.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 40b87c5..5fe0145 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -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: diff --git a/README.md b/README.md index b217443..12c9c2d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/changelog_cli.py b/scripts/changelog_cli.py deleted file mode 100644 index 381d7db..0000000 --- a/scripts/changelog_cli.py +++ /dev/null @@ -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 # newest entry's version - changelog_cli.py bump-kind # major|minor|patch|none vs - changelog_cli.py payload # 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)) diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py deleted file mode 100644 index c1825ee..0000000 --- a/scripts/check_changelog.py +++ /dev/null @@ -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()) diff --git a/scripts/package_lambdas.py b/scripts/package_lambdas.py index 6f6fe23..d858b0a 100644 --- a/scripts/package_lambdas.py +++ b/scripts/package_lambdas.py @@ -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", } diff --git a/scripts/sync_changelog.py b/scripts/sync_changelog.py index 37eb5d8..1b9049f 100644 --- a/scripts/sync_changelog.py +++ b/scripts/sync_changelog.py @@ -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 diff --git a/src/release-notifier/app.py b/src/release-notifier/app.py deleted file mode 100644 index 41a7ece..0000000 --- a/src/release-notifier/app.py +++ /dev/null @@ -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": "", "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"]} diff --git a/src/release-notifier/requirements.txt b/src/release-notifier/requirements.txt deleted file mode 100644 index 4a39ce4..0000000 --- a/src/release-notifier/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -slack_sdk>=3.44.1,<4.0 -boto3>=1.43.97 diff --git a/src/shared/shared/blocks.py b/src/shared/shared/blocks.py index 9944398..1b1d858 100644 --- a/src/shared/shared/blocks.py +++ b/src/shared/shared/blocks.py @@ -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``. diff --git a/src/shared/shared/changelog.py b/src/shared/shared/changelog.py index 339cf28..e92e5e6 100644 --- a/src/shared/shared/changelog.py +++ b/src/shared/shared/changelog.py @@ -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 diff --git a/terraform/lambda.tf b/terraform/lambda.tf index 453bf7d..c2202c0 100644 --- a/terraform/lambda.tf +++ b/terraform/lambda.tf @@ -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" diff --git a/terraform/locals.tf b/terraform/locals.tf index 19facf0..d725baf 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -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" diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index 899ecb4..c622fab 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -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(): diff --git a/tests/release_notifier/conftest.py b/tests/release_notifier/conftest.py deleted file mode 100644 index 4823fb5..0000000 --- a/tests/release_notifier/conftest.py +++ /dev/null @@ -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") diff --git a/tests/release_notifier/test_handler.py b/tests/release_notifier/test_handler.py deleted file mode 100644 index c24c6b1..0000000 --- a/tests/release_notifier/test_handler.py +++ /dev/null @@ -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() diff --git a/tests/scripts/test_release_tooling.py b/tests/scripts/test_release_tooling.py deleted file mode 100644 index 9f20a8a..0000000 --- a/tests/scripts/test_release_tooling.py +++ /dev/null @@ -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"] diff --git a/tests/shared/test_blocks.py b/tests/shared/test_blocks.py index 3749127..d36394f 100644 --- a/tests/shared/test_blocks.py +++ b/tests/shared/test_blocks.py @@ -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"] diff --git a/tests/shared/test_changelog.py b/tests/shared/test_changelog.py index 135a5ea..573b4b5 100644 --- a/tests/shared/test_changelog.py +++ b/tests/shared/test_changelog.py @@ -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)