Add changelog-driven releases and App Home tab (#112)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions

* Add changelog-driven releases and App Home tab

Version the bot continuously from CHANGELOG.md (the single source of
truth for both the version and the staff-readable notes) and surface
changes to users in two ways:

- A new afterhours-release-notifier Lambda posts a "What's New" message
  to the shift channel on minor/major releases (patches stay silent).
- The bot gains an App Home "About" tab showing what it does, the
  command list, and the current version's notes.

release.yaml runs on Deploy success (not release:published — GITHUB_TOKEN
events don't start downstream workflows), checks out the deployed commit,
and tags + publishes a GitHub Release + invokes the notifier. It assumes a
dedicated, boundary-carrying OIDC role scoped to InvokeFunction on the
notifier; the account's cfn role gates role creation on that boundary.
The manual Version Bump workflow is retired. A CI guard enforces that a
CHANGELOG edit is a clean SemVer bump and that the in-package copy matches.

* Harden release workflow and regex against CodeQL findings

Address three code-scanning alerts on the PR:

- Critical (actions/untrusted-checkout): split release.yaml into a
  read-only `prepare` job that checks out and runs repo code, and a
  privileged `publish` job (contents:write + OIDC) that never checks out
  repo code — it tags, releases, and invokes purely through the GitHub
  and AWS APIs. Also assert head_branch == main.
- High x2 (py/polynomial-redos): rewrite the italic and link regexes in
  markdown_to_mrkdwn with possessive quantifiers and exclusive character
  classes so they run in linear time on adversarial input. Adds a
  regression test.

* Move release/announce into Deploy workflow to clear CodeQL

The workflow_run-triggered release.yaml kept tripping CodeQL's
privileged-context rules (untrusted-checkout, then cache-poisoning) —
CodeQL distrusts any workflow_run that checks out a ref, regardless of
the main-only guarantee, and there is no autofix.

Fold the release job into deploy.yaml gated on `needs: deploy`. A
push-to-main run is a trusted context, so checking out and running repo
code with write/OIDC is safe there. This still gates on deploy success
and serializes via the deploy concurrency group, and removes the
separate workflow entirely.
This commit is contained in:
Adam Moussa 2026-06-11 19:41:31 -04:00 • committed by GitHub
parent 1c9ddaeadf
commit 53c85f7eed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1281 additions and 51 deletions

38
.github/workflows/changelog-guard.yml vendored Normal file
View file

@ -0,0 +1,38 @@
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@v6
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v5
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

@ -1,45 +0,0 @@
name: Version Bump
# Manual only. Semantic version tags are applied deliberately (patch/minor/major);
# there is no daily auto-bump and no Slack notification.
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump type"
type: choice
options:
- patch
- minor
- major
default: patch
permissions:
contents: write
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Compute and push next version tag
run: |
LAST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1)
LAST_TAG="${LAST_TAG:-v0.0.0}"
MAJOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f1)
MINOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f2)
PATCH=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f3)
case "${{ inputs.bump }}" in
major) VERSION="v$((MAJOR + 1)).0.0" ;;
minor) VERSION="v${MAJOR}.$((MINOR + 1)).0" ;;
*) VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
esac
echo "Bumping $LAST_TAG -> $VERSION"
git tag -a "$VERSION" -m "$VERSION"
git push origin "$VERSION"

View file

@ -20,3 +20,101 @@ jobs:
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}
# Tag + announce a release once the deploy succeeds. This lives in the deploy
# workflow (gated on `needs: deploy`) rather than a separate workflow_run-
# triggered job on purpose: a push-to-main run is a trusted context, so
# checking out and running repo code with write/OIDC is safe here — unlike
# workflow_run, which CodeQL (rightly) flags for untrusted checkout + cache
# poisoning. Gating on `needs: deploy` still guarantees we never announce a
# version that isn't live, and the `deploy` concurrency group serializes
# releases. When the top CHANGELOG version already has a Release, this no-ops.
release:
needs: deploy
runs-on: ubuntu-latest
permissions:
contents: write # create the tag + GitHub Release
id-token: write # OIDC to assume the notifier-invoke role
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Determine release
id: rel
env:
GH_TOKEN: ${{ github.token }}
run: |
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
if [ -z "$TOP" ]; then
echo "No version entry in CHANGELOG.md — nothing to release."
echo "release=false" >> "$GITHUB_OUTPUT"; exit 0
fi
PREV=$(git tag -l 'v*' --sort=-v:refname | head -1)
PREV="${PREV:-v0.0.0}"
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
RELEASE_EXISTS=false
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
echo "version=$TOP" >> "$GITHUB_OUTPUT"
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
# Act only on a clean SemVer bump whose Release isn't published yet.
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
echo "release=true" >> "$GITHUB_OUTPUT"
else
echo "release=false" >> "$GITHUB_OUTPUT"
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
fi
- name: Build release notes
if: ${{ steps.rel.outputs.release == 'true' }}
run: |
python scripts/changelog_cli.py payload CHANGELOG.md "${{ steps.rel.outputs.version }}" > payload.json
python -c "import json; print(json.load(open('payload.json'))['notes'])" > notes.md
# Announce BEFORE publishing the Release: the Release is the durable "done"
# marker (the step above skips once it exists), so announcing first keeps
# this retryable. Minor/major only, and only once the invoke-role variable
# has been bootstrapped (see README).
- name: Configure AWS credentials
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
aws-region: us-east-1
- name: Announce in Slack
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
run: |
aws lambda invoke \
--function-name afterhours-release-notifier \
--cli-binary-format raw-in-base64-out \
--payload file://payload.json \
--output json response.json > invoke-meta.json
# aws lambda invoke only emits a FunctionError key when the handler errored.
if grep -q '"FunctionError"' invoke-meta.json; then
echo "::error::release-notifier returned an error"; cat response.json; exit 1
fi
echo "Announced v${{ steps.rel.outputs.version }}."
- name: Warn if announcement skipped (not bootstrapped)
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagging + releasing but not announcing. Set the repo variable from the stack output."
- name: Publish GitHub Release
if: ${{ steps.rel.outputs.release == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
# gh creates the tag at the deployed commit and the Release together.
gh release create "v${{ steps.rel.outputs.version }}" \
--repo "${{ github.repository }}" \
--title "v${{ steps.rel.outputs.version }}" \
--notes-file notes.md \
--target "${{ github.sha }}"

View file

@ -5,9 +5,22 @@ after-hours on-call phone duty. Newest first, in plain language.
Versions are `MAJOR.MINOR.PATCH`: a **minor** bump adds a new feature, a **patch**
is a fix or tidy-up, and a **major** would be a big change to how things work.
A few older entries cover two versions at once (e.g. `v1.9.0 / v1.9.1`) — that's
fine and still supported.
---
## v1.10.0 — June 11, 2026
**The bot now tells you what's new.** Two things:
- When a noticeable update ships (a new feature or a bigger change), the bot
posts a short "What's New" note right in the on-call channel — so you hear
about changes without having to go looking. Small fixes stay quiet.
- The bot now has an **About** page. Click the bot's name in Slack and open its
**Home** tab to see what it does, the full list of `/oncall` commands, and the
latest "What's New". It always shows the current version.
## v1.9.2 — June 1, 2026
**Setup-guide fix.** Corrected the install instructions so secrets (the Slack and

View file

@ -13,6 +13,8 @@ A recurring weekly schedule assigns employees to after-hours phone duty. Weekend
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
The bot also has an **About** page: open the bot in Slack and click its **Home** tab to see what it does, the full command list, and the latest "What's New" (see [Releases & Versioning](#releases--versioning)).
## Slack Commands
| Command | Description |
@ -62,17 +64,20 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
| `afterhours-release-notifier` | Invoked by the Deploy workflow's release job on minor/major releases | Posts a "What's New" announcement to the shift channel |
### Project Layout
```
src/
slack-bot/ Slack Bolt Lambda (handler + app)
weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync
ring-scheduler/ 3CX queue routing updates
shared/ Lambda Layer (schedule, blocks, 3CX client, secrets)
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home
weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync
ring-scheduler/ 3CX queue routing updates
release-notifier/ Posts release announcements to Slack
shared/ Lambda Layer (schedule, blocks, changelog, 3CX client, secrets)
scripts/ changelog CLI + CI guard + in-package copy sync
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
```
### DynamoDB Schema
@ -114,6 +119,43 @@ sam build
sam deploy
```
## Releases & Versioning
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`** — it is
the single source of truth for both the version number and the human-readable
notes. There is no separate tagging tool.
**To cut a release**, in your feature PR add a new `## vX.Y.Z — Month D, YYYY`
section at the top of `CHANGELOG.md` (plain language, written for on-call staff),
bumping per SemVer, then run `python scripts/sync_changelog.py` to update the
in-package copy. The `Changelog Guard` PR check enforces that the bump is a clean
single SemVer step above the latest tag and that the two copies match.
On the **deploy-then-merge** path, once the merge's Deploy succeeds, the Deploy
workflow's `release` job (`needs: deploy`) tags the new version, publishes a
GitHub Release with the notes, and — for **minor and major** bumps only (patches
stay silent) — invokes `afterhours-release-notifier` to post a "What's New"
message in the shift channel. The **App Home** tab ("About" page on the bot)
always shows the current version's notes, read from the CHANGELOG that ships in
the slack-bot package.
> The release job lives inside the Deploy workflow (gated on `needs: deploy`)
> rather than a separate `workflow_run`-triggered workflow. A push-to-main run is
> a trusted context, so checking out and running repo code with write/OIDC is safe
> — whereas `workflow_run` is flagged by CodeQL for untrusted checkout. Gating on
> `needs: deploy` still guarantees we never announce a version that isn't live.
**One-time setup (per environment):** after the first deploy creates the
`ReleaseNotifyInvokeRole`, copy its ARN from the `ReleaseNotifyInvokeRoleArn` stack
output into the repo **variable** `RELEASE_NOTIFY_INVOKE_ROLE_ARN` (Settings →
Secrets and variables → Actions → Variables). Until it's set, releases still tag
and publish but skip the Slack announcement (with a warning).
> **Convention note (deliberate deviation).** The Sea Haven handbook says internal
> SAM stacks generally need no versioning and that tags are applied manually. This
> bot is versioned by owner choice (it has staff-facing release notes) and tagged
> automatically by the Deploy workflow's release job. This is intentional — not drift.
## Testing
Unit tests use `pytest` with all external boundaries mocked — DynamoDB / SES /

52
scripts/changelog_cli.py Normal file
View file

@ -0,0 +1,52 @@
#!/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

@ -0,0 +1,72 @@
#!/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())

24
scripts/sync_changelog.py Normal file
View file

@ -0,0 +1,24 @@
#!/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.
"""
import pathlib
import shutil
ROOT = pathlib.Path(__file__).resolve().parents[1]
def main() -> None:
src = ROOT / "CHANGELOG.md"
dst = ROOT / "src" / "slack-bot" / "CHANGELOG.md"
shutil.copyfile(src, dst)
print(f"Synced {src} -> {dst}")
if __name__ == "__main__":
main()

49
slack-app-manifest.yaml Normal file
View file

@ -0,0 +1,49 @@
# Slack app manifest — After-Hours Shift Manager
#
# Version-controlled source of truth for the Slack app configuration. The app
# predates this file, so before applying it wholesale via the Slack manifest API,
# reconcile it against the live app config (App settings → App Manifest) so no
# existing scope or setting is dropped.
#
# The ONLY delta this release introduces is the App Home tab:
# - settings.event_subscriptions.bot_events: + app_home_opened
# - features.app_home.home_tab_enabled: true
# Neither needs a new OAuth scope, so no reinstall is required (see README →
# "Releases & versioning"). Replace <API_URL> with the SlackBotApiUrl stack output.
display_information:
name: After-Hours Shift Manager
description: Manage after-hours on-call phone duty from Slack.
features:
bot_user:
display_name: oncall
always_online: true
slash_commands:
- command: /oncall
url: <API_URL>
description: Manage after-hours on-call shifts
usage_hint: "[next|pick|drop|swap|register|pay|rate|roster|help] …"
should_escape: false
app_home:
home_tab_enabled: true
messages_tab_enabled: true
messages_tab_read_only_enabled: false
oauth_config:
scopes:
bot:
- commands
- chat:write
- im:write
- users:read
settings:
event_subscriptions:
request_url: <API_URL>
bot_events:
- app_home_opened
interactivity:
is_enabled: true
request_url: <API_URL>
org_deploy_enabled: false
socket_mode_enabled: false

View file

@ -0,0 +1,45 @@
"""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
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

@ -0,0 +1,2 @@
slack_sdk>=3.42.0,<4.0
boto3>=1.43.27

View file

@ -1,5 +1,6 @@
"""Slack Block Kit message builders for shift schedule display."""
import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
@ -7,6 +8,62 @@ from shared.schedule import WEEKEND_DAYS
EASTERN = ZoneInfo("America/New_York")
# Slack section text hard limit is 3000 chars; leave headroom for the truncation note.
_SECTION_LIMIT = 2900
def markdown_to_mrkdwn(text: str) -> str:
"""Convert the CHANGELOG's standard Markdown to Slack ``mrkdwn``.
The changelog is authored in GitHub-flavoured Markdown (``**bold**``,
``*italic*``, ``[text](url)``, ``- `` bullets), but Slack uses a different
dialect (``*bold*``, ``_italic_``, ``<url|text>``, ``•`` bullets). Bold is
swapped via a placeholder first so the italic pass cannot mangle it.
The italic and link patterns use possessive quantifiers (``++``) and exclusive
character classes so they run in linear time on adversarial input — no
catastrophic backtracking (py/polynomial-redos).
"""
text = re.sub(r"\*\*([^\n]+?)\*\*", "\x00\\1\x00", text) # bold -> placeholder
text = re.sub(r"\*([^*\n]++)\*", r"_\1_", text) # italic (single asterisks)
text = text.replace("\x00", "*") # placeholder -> Slack bold
text = re.sub(r"\[([^\]]++)\]\(([^)\n]++)\)", r"<\2|\1>", text) # links
text = re.sub(r"(?m)^(\s*)[-*]\s+", r"\1• ", text) # bullets
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)",
@ -228,6 +285,62 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]:
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_home_view(
version: str | None = None, notes: str = "", date_label: str = ""
) -> dict:
"""Build the App Home tab: what the bot does, the commands, and what's new.
``version``/``notes``/``date_label`` come from the newest CHANGELOG entry; when
absent (e.g. the changelog could not be read) the "What's New" section is
simply omitted.
"""
blocks: list[dict] = [
{
"type": "header",
"text": {"type": "plain_text", "text": "After-Hours Shift Manager"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
"I manage the after-hours on-call phone duty. Use `/oncall` in "
"any channel to see the schedule, pick up or drop shifts, and "
"swap with teammates — changes update the phone routing "
"automatically."
),
},
},
*build_help_blocks(is_admin=False),
]
if version:
blocks.append({"type": "divider"})
blocks.append(
{
"type": "header",
"text": {"type": "plain_text", "text": f"What's New in v{version}"},
}
)
if date_label:
blocks.append(
{
"type": "context",
"elements": [{"type": "mrkdwn", "text": f"Released {date_label}"}],
}
)
if notes:
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.append({"type": "section", "text": {"type": "mrkdwn", "text": body}})
return {"type": "home", "blocks": blocks}
def build_pay_summary_blocks(
week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]
) -> list[dict]:

View file

@ -0,0 +1,136 @@
"""Parse CHANGELOG.md — the single source of truth for version and release notes.
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 parser tolerates the file's leading preamble (prose before the first ``##``
header), date-only historical headers like ``## May 1, 2026 — …`` (no version),
and legacy combined entries like ``## v1.9.0 / v1.9.1 — June 1, 2026``.
"""
import re
from dataclasses import dataclass
# Headers are level-2 ATX (``## ...``). Capture the title text only.
_HEADER_RE = re.compile(r"^##\s+(?P<title>.+?)\s*$", re.MULTILINE)
# A dotted MAJOR.MINOR.PATCH triple, with an optional leading ``v``.
_VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)")
@dataclass(frozen=True)
class Entry:
"""One changelog section, delimited by ``##`` headers."""
versions: tuple[str, ...] # e.g. ("1.9.0", "1.9.1"); empty for date-only headers
title: str # header text, minus the leading "## "
date_label: str # text after the em dash, e.g. "June 1, 2026"
body: str # markdown between this header and the next "## "
@property
def version(self) -> str | None:
"""Highest SemVer in the header, or None for a date-only header.
Combined entries (``v1.9.0 / v1.9.1``) report the higher version, which is
what "What's New" should surface.
"""
if not self.versions:
return None
return max(self.versions, key=_version_key)
def _version_key(version: str) -> tuple[int, int, int]:
match = _VERSION_RE.search(version)
if match is None:
raise ValueError(f"not a semver: {version!r}")
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.
The file uses horizontal rules to separate eras; they delimit content but are
not part of any entry's notes, so they should not leak into a rendered post.
"""
lines = body.splitlines()
while lines and re.fullmatch(r"-{3,}", lines[0].strip()):
lines.pop(0)
while lines and re.fullmatch(r"-{3,}", lines[-1].strip()):
lines.pop()
return "\n".join(lines).strip()
def parse_changelog(text: str) -> list[Entry]:
"""Split the changelog into entries, newest first (file order is preserved)."""
entries: list[Entry] = []
headers = list(_HEADER_RE.finditer(text))
for i, match in enumerate(headers):
title = match.group("title").strip()
body_start = match.end()
body_end = headers[i + 1].start() if i + 1 < len(headers) else len(text)
body = _strip_rules(text[body_start:body_end].strip())
versions = tuple(f"{a}.{b}.{c}" for a, b, c in _VERSION_RE.findall(title))
date_label = title.split("—")[-1].strip() if "—" in title else ""
entries.append(
Entry(versions=versions, title=title, date_label=date_label, body=body)
)
return entries
def version_entries(text: str) -> list[Entry]:
"""Only the entries that carry at least one version (skips date-only headers)."""
return [entry for entry in parse_changelog(text) if entry.versions]
def latest_entry(text: str) -> Entry | None:
"""The newest version-bearing entry, or None if the file has no versions yet."""
for entry in parse_changelog(text):
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

101
src/slack-bot/CHANGELOG.md Normal file
View file

@ -0,0 +1,101 @@
# Changelog
What's changed in the **After-Hours Shift Manager** — the Slack bot that runs our
after-hours on-call phone duty. Newest first, in plain language.
Versions are `MAJOR.MINOR.PATCH`: a **minor** bump adds a new feature, a **patch**
is a fix or tidy-up, and a **major** would be a big change to how things work.
A few older entries cover two versions at once (e.g. `v1.9.0 / v1.9.1`) — that's
fine and still supported.
---
## v1.10.0 — June 11, 2026
**The bot now tells you what's new.** Two things:
- When a noticeable update ships (a new feature or a bigger change), the bot
posts a short "What's New" note right in the on-call channel — so you hear
about changes without having to go looking. Small fixes stay quiet.
- The bot now has an **About** page. Click the bot's name in Slack and open its
**Home** tab to see what it does, the full list of `/oncall` commands, and the
latest "What's New". It always shows the current version.
## v1.9.2 — June 1, 2026
**Setup-guide fix.** Corrected the install instructions so secrets (the Slack and
phone-system passwords) are stored in the right, secure place. No change to the
bot itself — this only affects someone setting it up from scratch.
## v1.9.0 / v1.9.1 — June 1, 2026
**You can no longer drop a shift at the last minute.** If a shift starts within
the next 24 hours, you can't just drop it and walk away — you have to hand it to
someone specific (a swap they accept), or ask an admin. This stops the phones
from being left uncovered with no notice. *(v1.9.1 was a routine update of
behind-the-scenes software libraries.)*
## v1.8.0 — June 1, 2026
**Swaps now need the other person to say yes.** Before, `/oncall swap` instantly
dumped your shift on someone else. Now they get a Slack message with **Accept**
and **Decline** buttons, and the shift only moves if they accept. Until then it
stays yours. If they don't respond before the shift starts, the request quietly
expires.
## v1.7.19 — June 1, 2026
**Quality safety net (no visible change).** Added an automated test suite that
checks the bot's behavior on every change, so bugs get caught before they ship.
You won't notice anything different day-to-day — it just makes future updates
safer.
---
## May 2026 — Phone-system automation & a big fix
- **Live phone routing follows the schedule.** The system that decides which
phone rings after hours now reads directly from the on-call schedule, so
pickups, drops, and swaps take effect automatically.
- **Fixed a release that had broken the whole bot.** A packaging mistake stopped
all of the bot's functions from running; this was found and fixed.
- Ongoing routine updates to behind-the-scenes software libraries.
- **Behind the scenes:** moved to an automated build-and-release pipeline, added
automatic code checks and formatting, and turned on automated dependency and
code-review tooling. None of this changes how you use the bot.
## May 1, 2026 — Reliability & notifications
- **No more double-booking.** Fixed a timing bug where two people could grab the
same open shift at once.
- **Shift changes are announced.** When someone picks up, drops, or swaps a
shift, a note is posted to the team channel.
- **Fixed a confusing error** that showed up when picking a shift even though the
pickup had actually worked.
## April 8, 2026 — Schedule & pay polish
- The weekly schedule now always starts on **Monday** (it used to start from
today).
- Tidied up the weekly pay report (recipient and wording).
## April 7, 2026 — Scheduling & pay
- **Two-week schedule view** instead of just the current week.
- **Weekly pay totals** for on-call shifts, with a configurable flat rate.
- **Per-person pay rates** and an `/oncall rate` command to manage them from
Slack.
- **Weekend day shifts** — weekends are split into a daytime and an overnight
shift.
- **Automatic employee roster sync** — the bot pulls the staff list from the
phone system each day, so the roster stays current on its own.
- **Weekly "Bonus Pay Summary"** emailed to payroll (and sent as a Slack DM to
the admin) every Monday.
## April 3, 2026 — Launch 🎉
The first version of the After-Hours Shift Manager:
- See the on-call schedule in Slack with `/oncall`.
- **Pick up** an open shift and **drop** a shift you're covering.
- Link your Slack account to your phone extension with `/oncall register`.

View file

@ -6,6 +6,7 @@ can be unit-tested directly. ``schedule`` (a ``ShiftSchedule``) and
is a thin wiring layer that registers the Bolt routes and delegates to them.
"""
import functools
import logging
import os
import re
@ -16,6 +17,7 @@ from slack_bolt import App
from shared.blocks import (
build_help_blocks,
build_home_view,
build_pay_summary_blocks,
build_roster_blocks,
build_shift_change_message,
@ -23,6 +25,7 @@ from shared.blocks import (
build_swap_resolved_blocks,
build_week_schedule,
)
from shared.changelog import latest_entry
from shared.ring_scheduler import update_queue_routing
from shared.schedule import (
FALLBACK_EXTENSION,
@ -927,6 +930,37 @@ def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_c
respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.")
# ── App Home ────────────────────────────────────────────────────────────
@functools.lru_cache(maxsize=1)
def _changelog_text() -> str:
"""Read the CHANGELOG shipped in this function's package.
Lazy (never at import) and tolerant of a missing file, so the App Home tab
degrades to "no What's New section" rather than erroring. The copy lives at
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root).
"""
path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md")
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError:
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path)
return ""
def publish_home(client, user_id: str, changelog_text: str) -> None:
"""Render and publish the App Home view for ``user_id``."""
entry = latest_entry(changelog_text)
view = build_home_view(
version=entry.version if entry else None,
notes=entry.body if entry else "",
date_label=entry.date_label if entry else "",
)
client.views_publish(user_id=user_id, view=view)
# ── App factory ─────────────────────────────────────────────────────────
@ -960,4 +994,11 @@ def create_app(
ack()
handle_swap_decline(body, respond, client, schedule, schedule_channel)
@app.event("app_home_opened")
def handle_app_home_opened(event, client):
# Fires for the Messages tab too; only (re)publish the Home tab.
if event.get("tab") != "home":
return
publish_home(client, event["user"], _changelog_text())
return app

View file

@ -249,6 +249,69 @@ Resources:
Description: "Update 3CX queue at 5pm EDT weekends"
Enabled: true
# --- Release Notifier (invoked by release.yaml on minor/major releases) ---
ReleaseNotifierFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: afterhours-release-notifier
Handler: app.handler
CodeUri: src/release-notifier/
Layers:
- !Ref SharedLayer
Environment:
Variables:
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
SHIFT_CHANNEL: !Ref ShiftChannel
TZ: !Ref Timezone
Policies:
# Least privilege: only the Slack bot token, not the whole namespace.
- Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/slack-bot-token-*"
# GitHub-OIDC role assumed by release.yaml to invoke the notifier. Auto-named
# (no RoleName) so the deploy's CAPABILITY_IAM is sufficient — cd-sam does not
# pass CAPABILITY_NAMED_IAM. Trust + permission are scoped to the minimum: this
# repo's main ref + release workflow, and InvokeFunction on the notifier alone.
# Its ARN is surfaced as a stack output and set once as the
# RELEASE_NOTIFY_INVOKE_ROLE_ARN repo variable (see README).
#
# The permissions boundary is REQUIRED, not optional: the scoped
# github-cfn-execution-role's IAM policy gates iam:CreateRole/PutRolePolicy on
# the role carrying exactly this boundary, so the CI deploy is denied without
# it. The boundary itself permits lambda:InvokeFunction (Sid LambdaInvoke), so
# it does not restrict this role's one job.
ReleaseNotifyInvokeRole:
Type: AWS::IAM::Role
Properties:
PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com"
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: "repo:Sea-Haven-Industries/afterhours-shift-manager:ref:refs/heads/main"
# Defense-in-depth: only the release workflow may assume this role,
# not any workflow running on main.
token.actions.githubusercontent.com:job_workflow_ref: "Sea-Haven-Industries/afterhours-shift-manager/.github/workflows/release.yaml@refs/heads/main"
Policies:
- PolicyName: invoke-release-notifier
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt ReleaseNotifierFunction.Arn
# --- CloudWatch Log Groups (explicit 60-day retention) ---
SlackBotLogGroup:
Type: AWS::Logs::LogGroup
@ -274,6 +337,12 @@ Resources:
LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}"
RetentionInDays: 60
ReleaseNotifierLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${ReleaseNotifierFunction}"
RetentionInDays: 60
Outputs:
SlackBotApiUrl:
Description: URL for Slack app Request URL configuration
@ -288,3 +357,8 @@ Outputs:
Value: !GetAtt RosterSyncFunction.Arn
RingSchedulerFunctionArn:
Value: !GetAtt RingSchedulerFunction.Arn
ReleaseNotifierFunctionArn:
Value: !GetAtt ReleaseNotifierFunction.Arn
ReleaseNotifyInvokeRoleArn:
Description: Set this as the RELEASE_NOTIFY_INVOKE_ROLE_ARN repo variable for release.yaml
Value: !GetAtt ReleaseNotifyInvokeRole.Arn

View file

@ -0,0 +1,22 @@
"""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

@ -0,0 +1,67 @@
"""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

@ -0,0 +1,70 @@
"""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

@ -5,11 +5,13 @@ from freezegun import freeze_time
from shared.blocks import (
build_help_blocks,
build_pay_summary_blocks,
build_release_announcement_blocks,
build_roster_blocks,
build_shift_change_message,
build_swap_request_blocks,
build_swap_resolved_blocks,
build_week_schedule,
markdown_to_mrkdwn,
)
@ -157,3 +159,52 @@ class TestBuildRosterBlocks:
assert text.index("Alice") < text.index("Bob")
assert "<@U_ALICE>" in text
assert "_not linked_" in text
class TestReleaseAnnouncement:
def test_markdown_bold_becomes_slack_bold(self):
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"
def test_markdown_italic_becomes_underscore(self):
# Single asterisks are italic in Markdown; Slack uses underscores.
assert markdown_to_mrkdwn("a *note* here") == "a _note_ here"
def test_bold_and_italic_together(self):
out = markdown_to_mrkdwn("**Bold.** Then *(an aside)*")
assert out == "*Bold.* Then _(an aside)_"
def test_links_and_bullets(self):
out = markdown_to_mrkdwn("- see [docs](http://x)\n- next")
assert "• see <http://x|docs>" in out
assert "• next" in out
def test_adversarial_input_runs_in_linear_time(self):
# py/polynomial-redos regression: possessive quantifiers must keep these
# patterns from catastrophic backtracking. Pathological inputs that would
# hang a backtracking engine complete effectively instantly here.
import time
for evil in ("*" + "*a" * 4000, "[" + "[\\(" * 4000, "[" + "](" * 4000):
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

@ -0,0 +1,119 @@
"""Tests for the CHANGELOG parser — the single source of truth for versioning."""
import pytest
from shared.changelog import (
bump_kind,
entry_for,
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
# legacy combined entry, and date-only historical headers with no version.
SAMPLE = """# Changelog
What's changed in the **After-Hours Shift Manager**. Newest first.
Versions are `MAJOR.MINOR.PATCH`.
---
## v1.10.0 — June 11, 2026
**Release notes now announce themselves.** Big new feature line.
## v1.9.2 — June 1, 2026
**Setup-guide fix.** A patch.
## v1.9.0 / v1.9.1 — June 1, 2026
**Last-minute drops blocked.** Combined entry. *(v1.9.1 was a library update.)*
---
## May 2026 — Phone-system automation
- Live phone routing follows the schedule.
## April 3, 2026 — Launch 🎉
The first version.
"""
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"
assert entry.date_label == "June 11, 2026"
assert "announce themselves" in entry.body
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")
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
def test_date_only_headers_carry_no_version():
titles = {e.title for e in parse_changelog(SAMPLE) if not e.versions}
assert "May 2026 — Phone-system automation" in titles
assert "April 3, 2026 — Launch 🎉" in titles
def test_version_entries_excludes_date_only():
versions = [e.version for e in version_entries(SAMPLE)]
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
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)

View file

@ -0,0 +1,46 @@
"""Tests for the App Home tab (about page + What's New)."""
from shared.blocks import build_home_view
def _headers(view):
return [b["text"]["text"] for b in view["blocks"] if b["type"] == "header"]
class TestBuildHomeView:
def test_includes_about_and_commands(self):
view = build_home_view()
assert view["type"] == "home"
assert "After-Hours Shift Manager" in _headers(view)
assert "/oncall" in str(view["blocks"])
def test_omits_whats_new_without_version(self):
view = build_home_view()
assert all("What's New" not in h for h in _headers(view))
def test_includes_whats_new_with_version(self):
view = build_home_view(
"1.10.0", "**Self-announcing** releases.", "June 11, 2026"
)
assert "What's New in v1.10.0" in _headers(view)
# Notes are converted from Markdown to Slack mrkdwn.
assert "*Self-announcing*" in str(view["blocks"])
assert any(b.get("type") == "context" for b in view["blocks"])
def test_publish_home_publishes_latest_entry(slackbot_app, client):
text = "## v1.10.0 — June 11, 2026\n\n**New stuff.**\n\n## v1.9.2 — June 1, 2026\n\nOld.\n"
slackbot_app.publish_home(client, "U_ALICE", text)
client.views_publish.assert_called_once()
kwargs = client.views_publish.call_args.kwargs
assert kwargs["user_id"] == "U_ALICE"
assert kwargs["view"]["type"] == "home"
assert "What's New in v1.10.0" in str(kwargs["view"])
assert "v1.9.2" not in str(kwargs["view"]) # only the newest entry
def test_publish_home_degrades_without_changelog(slackbot_app, client):
slackbot_app.publish_home(client, "U_BOB", "")
view = client.views_publish.call_args.kwargs["view"]
assert all("What's New" not in str(b) for b in view["blocks"])