Add changelog-driven releases and App Home tab (#112)
* 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.
2026-06-11 19:41:31 -04:00
|
|
|
"""Tests for the App Home tab (about page + What's New)."""
|
|
|
|
|
|
2026-07-02 16:29:42 -04:00
|
|
|
from freezegun import freeze_time
|
|
|
|
|
|
Add changelog-driven releases and App Home tab (#112)
* 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.
2026-06-11 19:41:31 -04:00
|
|
|
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"])
|
2026-07-02 16:29:42 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_publish_home_includes_admin_section_for_admin(
|
|
|
|
|
slackbot_app, schedule, seed, client
|
|
|
|
|
):
|
|
|
|
|
seed.config(admin_users=["U_ADMIN"])
|
|
|
|
|
seed.override("2026-12-25", "114", "Alice")
|
|
|
|
|
seed.holiday("2026-12-25", slots=2, label="Christmas")
|
|
|
|
|
with freeze_time("2026-06-01"):
|
|
|
|
|
slackbot_app.publish_home(client, "U_ADMIN", "", schedule)
|
|
|
|
|
view = client.views_publish.call_args.kwargs["view"]
|
|
|
|
|
assert "Admin" in _headers(view)
|
|
|
|
|
assert "admin_open_override_modal" in str(view["blocks"])
|
|
|
|
|
assert "Christmas" in str(view["blocks"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_publish_home_omits_admin_section_for_non_admin(
|
|
|
|
|
slackbot_app, schedule, seed, client
|
|
|
|
|
):
|
|
|
|
|
seed.config(admin_users=["U_ADMIN"])
|
|
|
|
|
slackbot_app.publish_home(client, "U_BOB", "", schedule)
|
|
|
|
|
view = client.views_publish.call_args.kwargs["view"]
|
|
|
|
|
assert "Admin" not in _headers(view)
|
|
|
|
|
assert "admin_open_override_modal" not in str(view["blocks"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_publish_home_without_schedule_has_no_admin_section(slackbot_app, client):
|
|
|
|
|
slackbot_app.publish_home(client, "U_ADMIN", "")
|
|
|
|
|
view = client.views_publish.call_args.kwargs["view"]
|
|
|
|
|
assert "Admin" not in _headers(view)
|