mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-04 13:42:07 +00:00
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.
This commit is contained in:
parent
be1160192c
commit
26e9716df4
19 changed files with 46 additions and 566 deletions
38
.github/workflows/changelog-guard.yml
vendored
38
.github/workflows/changelog-guard.yml
vendored
|
|
@ -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
|
|
||||||
1
.github/workflows/deploy-api.yaml
vendored
1
.github/workflows/deploy-api.yaml
vendored
|
|
@ -19,7 +19,6 @@ on:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "*.md"
|
- "*.md"
|
||||||
- ".github/workflows/ci.yaml"
|
- ".github/workflows/ci.yaml"
|
||||||
- ".github/workflows/changelog-guard.yml"
|
|
||||||
- ".github/workflows/labeler.yml"
|
- ".github/workflows/labeler.yml"
|
||||||
- ".github/workflows/dependency-review.yml"
|
- ".github/workflows/dependency-review.yml"
|
||||||
release:
|
release:
|
||||||
|
|
|
||||||
23
README.md
23
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-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-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-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
|
### Project Layout
|
||||||
|
|
||||||
|
|
@ -89,10 +88,9 @@ src/
|
||||||
portal-api/ Cognito employee/admin shift API for the internal portal
|
portal-api/ Cognito employee/admin shift API for the internal portal
|
||||||
ring-scheduler/ 3CX queue routing updates
|
ring-scheduler/ 3CX queue routing updates
|
||||||
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
|
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
|
shared/ Bundled into each function zip and the Fargate image
|
||||||
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
|
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)
|
tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -267,13 +265,20 @@ transition normally. `ThrottledRequests` and `SystemErrors` are intentionally
|
||||||
|
|
||||||
## Releases & Versioning
|
## Releases & Versioning
|
||||||
|
|
||||||
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`**. The
|
Prod is a human GitHub Release:
|
||||||
**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.
|
|
||||||
|
|
||||||
GitHub Releases and git tags are not cut by `deploy-api.yaml`. `afterhours-release-notifier`
|
```bash
|
||||||
exists as leftover IAM from dual-run; CD does not invoke it.
|
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
|
## Testing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -27,7 +27,6 @@ FUNCTIONS = {
|
||||||
"roster_api": ROOT / "src" / "roster-api",
|
"roster_api": ROOT / "src" / "roster-api",
|
||||||
"ring_scheduler": ROOT / "src" / "ring-scheduler",
|
"ring_scheduler": ROOT / "src" / "ring-scheduler",
|
||||||
"holiday_router": ROOT / "src" / "holiday-router",
|
"holiday_router": ROOT / "src" / "holiday-router",
|
||||||
"release_notifier": ROOT / "src" / "release-notifier",
|
|
||||||
"portal_api": ROOT / "src" / "portal-api",
|
"portal_api": ROOT / "src" / "portal-api",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Copy the canonical root CHANGELOG.md into the slack-bot package.
|
"""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
|
The bot's App Home "What's New" tab reads CHANGELOG.md next to app.py in the
|
||||||
package ($LAMBDA_TASK_ROOT), so a copy must live under src/slack-bot/ (the
|
Fargate image, so a copy must live under src/slack-bot/. The root file is the
|
||||||
function's CodeUri). The root file is the single source of truth; run this after
|
single source of truth; run this after editing it. Pytest fails if the two drift.
|
||||||
editing it. CI's check_changelog.py fails if the two drift.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
|
||||||
|
|
@ -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"]}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
slack_sdk>=3.44.1,<4.0
|
|
||||||
boto3>=1.43.97
|
|
||||||
|
|
@ -32,39 +32,6 @@ def markdown_to_mrkdwn(text: str) -> str:
|
||||||
return text
|
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 = {
|
SHIFT_LABELS = {
|
||||||
"day": "Day (8am–5pm)",
|
"day": "Day (8am–5pm)",
|
||||||
"night": "Night (5pm–8am)",
|
"night": "Night (5pm–8am)",
|
||||||
|
|
@ -344,9 +311,9 @@ def build_holiday_added_blocks(
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the channel post announcing a newly scheduled holiday.
|
"""Build the channel post announcing a newly scheduled holiday.
|
||||||
|
|
||||||
Mirrors :func:`build_release_announcement_blocks`: a header, an optional
|
A header, an optional context line, then a section describing the holiday
|
||||||
context line, then a section describing the holiday day-shift (08:00–17:00
|
day-shift (08:00–17:00 ET), its open slots, and the pay multiplier so people
|
||||||
ET), its open slots, and the pay multiplier so people know to pick it up.
|
know to pick it up.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
date_str: the holiday date, ``YYYY-MM-DD``.
|
date_str: the holiday date, ``YYYY-MM-DD``.
|
||||||
|
|
|
||||||
|
|
@ -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
|
The root CHANGELOG.md is the source of truth. ``scripts/sync_changelog.py``
|
||||||
live in exactly one place:
|
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
|
||||||
* the Slack App Home tab — renders the newest entry ("What's New in vX.Y.Z"),
|
``gh release create``; the changelog is not the prod tag driver.
|
||||||
* 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 parser tolerates the file's leading preamble (prose before the first ``##``
|
The parser tolerates the file's leading preamble (prose before the first ``##``
|
||||||
header), date-only historical headers like ``## May 1, 2026 — …`` (no version),
|
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)))
|
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:
|
def _strip_rules(body: str) -> str:
|
||||||
"""Drop ``---`` thematic-break lines from the body's edges.
|
"""Drop ``---`` thematic-break lines from the body's edges.
|
||||||
|
|
||||||
|
|
@ -95,42 +89,3 @@ def latest_entry(text: str) -> Entry | None:
|
||||||
if entry.versions:
|
if entry.versions:
|
||||||
return entry
|
return entry
|
||||||
return None
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
# weekly-post remains so paychex-checkcomponents can keep that principal
|
||||||
# until a follow-up queue-policy apply drops it.
|
# until a follow-up queue-policy apply drops it.
|
||||||
|
|
||||||
|
|
@ -134,14 +134,6 @@ locals {
|
||||||
condition = null
|
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 = [
|
portal_api = [
|
||||||
{
|
{
|
||||||
sid = "DdbCrud"
|
sid = "DdbCrud"
|
||||||
|
|
@ -228,12 +220,6 @@ locals {
|
||||||
TZ = var.timezone
|
TZ = var.timezone
|
||||||
SENTRY_DSN = var.sentry_dsn
|
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 = {
|
portal_api = {
|
||||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||||
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
|
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,6 @@ locals {
|
||||||
timeout = 60
|
timeout = 60
|
||||||
duration_ms = 48000
|
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 = {
|
portal_api = {
|
||||||
function_name = "afterhours-portal-api"
|
function_name = "afterhours-portal-api"
|
||||||
role_name = "afterhours-shift-manager-portal-api"
|
role_name = "afterhours-shift-manager-portal-api"
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
||||||
assert "checkcomponents_pair" in data_tf
|
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 (
|
for name in (
|
||||||
"afterhours-shift-manager",
|
"afterhours-shift-manager",
|
||||||
"afterhours-weekly-post",
|
"afterhours-weekly-post",
|
||||||
|
|
@ -136,10 +136,10 @@ def test_eight_function_names_still_on_leftover_roles():
|
||||||
"afterhours-roster-api",
|
"afterhours-roster-api",
|
||||||
"afterhours-ring-scheduler",
|
"afterhours-ring-scheduler",
|
||||||
"afterhours-holiday-router",
|
"afterhours-holiday-router",
|
||||||
"afterhours-release-notifier",
|
|
||||||
"afterhours-portal-api",
|
"afterhours-portal-api",
|
||||||
):
|
):
|
||||||
assert name in LOCALS
|
assert name in LOCALS
|
||||||
|
assert "afterhours-release-notifier" not in LOCALS
|
||||||
|
|
||||||
|
|
||||||
def test_weekly_post_role_is_tf_managed_name():
|
def test_weekly_post_role_is_tf_managed_name():
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -11,7 +11,6 @@ from shared.blocks import (
|
||||||
build_pay_summary_blocks,
|
build_pay_summary_blocks,
|
||||||
build_pickup_request_blocks,
|
build_pickup_request_blocks,
|
||||||
build_pickup_resolved_blocks,
|
build_pickup_resolved_blocks,
|
||||||
build_release_announcement_blocks,
|
|
||||||
build_roster_blocks,
|
build_roster_blocks,
|
||||||
build_shift_change_message,
|
build_shift_change_message,
|
||||||
build_swap_request_blocks,
|
build_swap_request_blocks,
|
||||||
|
|
@ -459,7 +458,7 @@ class TestBuildAdminOverview:
|
||||||
assert "0/2 filled" in text
|
assert "0/2 filled" in text
|
||||||
|
|
||||||
|
|
||||||
class TestReleaseAnnouncement:
|
class TestMarkdownToMrkdwn:
|
||||||
def test_markdown_bold_becomes_slack_bold(self):
|
def test_markdown_bold_becomes_slack_bold(self):
|
||||||
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"
|
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"
|
||||||
|
|
||||||
|
|
@ -486,23 +485,3 @@ class TestReleaseAnnouncement:
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
markdown_to_mrkdwn(evil)
|
markdown_to_mrkdwn(evil)
|
||||||
assert time.perf_counter() - start < 1.0
|
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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
from shared.changelog import latest_entry, parse_changelog, version_entries
|
||||||
bump_kind,
|
|
||||||
entry_for,
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
is_valid_bump,
|
|
||||||
latest_entry,
|
|
||||||
parse_changelog,
|
|
||||||
top_version,
|
|
||||||
version_entries,
|
|
||||||
)
|
|
||||||
|
|
||||||
# A faithful slice of the real file: preamble prose, a plain version entry, a
|
# 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.
|
# 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():
|
def test_preamble_is_not_an_entry():
|
||||||
# The "# Changelog" h1 and prose before the first "##" must not parse as entries.
|
# The "# Changelog" h1 and prose before the first "##" must not parse as entries.
|
||||||
entries = parse_changelog(SAMPLE)
|
entries = parse_changelog(SAMPLE)
|
||||||
assert all("Changelog" not in e.title for e in entries)
|
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():
|
def test_latest_entry_fields():
|
||||||
entry = latest_entry(SAMPLE)
|
entry = latest_entry(SAMPLE)
|
||||||
assert entry.version == "1.10.0"
|
assert entry.version == "1.10.0"
|
||||||
|
|
@ -64,18 +60,15 @@ def test_latest_entry_fields():
|
||||||
|
|
||||||
|
|
||||||
def test_combined_header_reports_higher_version():
|
def test_combined_header_reports_higher_version():
|
||||||
entry = entry_for(SAMPLE, "1.9.0")
|
combined = next(e for e in parse_changelog(SAMPLE) if "v1.9.0" in e.title)
|
||||||
assert entry.versions == ("1.9.0", "1.9.1")
|
assert combined.versions == ("1.9.0", "1.9.1")
|
||||||
assert entry.version == "1.9.1" # the higher of the two
|
assert combined.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")
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_excludes_thematic_break_rules():
|
def test_body_excludes_thematic_break_rules():
|
||||||
# The "---" rule separating eras must not bleed into the combined entry's notes.
|
# 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():
|
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"]
|
assert versions == ["1.10.0", "1.9.2", "1.9.1"]
|
||||||
|
|
||||||
|
|
||||||
def test_entry_for_accepts_v_prefix():
|
def test_empty_changelog_has_no_latest_entry():
|
||||||
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
|
|
||||||
assert latest_entry("# Changelog\n") is None
|
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)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue