Compare commits

...

3 commits

Author SHA1 Message Date
Adam Moussa
26e9716df4
fix(ci): drop leftover changelog-guard and release-notifier (PLAT-219) (#266)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Prod is already a human GitHub Release. Remove the SAM tagging path so CHANGELOG.md stays App Home copy and leftover notifier IAM is destroyed on the next apply.
2026-09-21 23:24:05 +00:00
Adam Moussa
be1160192c
fix(infra): allow hcptf apply to create ECS and ELB service-linked roles (PLAT-216) (#265) 2026-09-21 22:56:09 +00:00
Adam Moussa
cbda46ba87
chore(infra): remove API Gateway and Lambda dual-run (PLAT-216) (#264)
Origins already point at the Fargate hostnames. Drop the HTTP API, eight
functions, zip CD, and Lambda/API Gateway alarms while keeping leftover
Lambda IAM so Paychex can still name weekly-post.
2026-09-21 22:37:13 +00:00
31 changed files with 114 additions and 1225 deletions

View file

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

View file

@ -9,7 +9,7 @@ name: Deploy API
# workflow_dispatch -> chosen environment at a chosen ref
#
# Releases are cut by a human with `gh release create vX.Y.Z --target main`.
# Nothing here creates an HCP run. Zip CD stays in deploy.yaml until cutover.
# Nothing here creates an HCP run.
on:
push:
@ -18,9 +18,7 @@ on:
- "terraform/**"
- "docs/**"
- "*.md"
- ".github/workflows/deploy.yaml"
- ".github/workflows/ci.yaml"
- ".github/workflows/changelog-guard.yml"
- ".github/workflows/labeler.yml"
- ".github/workflows/dependency-review.yml"
release:

View file

@ -1,154 +0,0 @@
name: Deploy
# Terraform owns Lambda skeletons. This workflow ships zips to prod and calls
# update-function-code. It never creates an HCP run. No GitHub Releases and no
# tagging in this workflow.
on:
push:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "README.md"
- "SETUP.md"
- "AGENTS.md"
workflow_dispatch:
inputs:
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
contents: read
jobs:
deploy:
name: Deploy to prod
runs-on: ubuntu-latest
timeout-minutes: 30
environment: prod
concurrency:
group: deploy-afterhours-prod
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
persist-credentials: false
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
echo "Building ${sha}"
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Build function zips
env:
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
python scripts/package_lambdas.py --git-sha "${GIT_SHA}" --out-dir build/packages
python - <<'PY'
import os, zipfile
from pathlib import Path
sha = os.environ["GIT_SHA"]
names = [
"slack_bot",
"weekly_post",
"roster_sync",
"roster_api",
"ring_scheduler",
"holiday_router",
"release_notifier",
"portal_api",
]
for name in names:
path = Path("build/packages") / f"{name}.zip"
if not path.is_file():
raise SystemExit(f"missing {path}")
with zipfile.ZipFile(path) as zf:
info = zf.read("shared/build_info.py").decode()
if sha not in info:
raise SystemExit(f"{path} missing GIT_SHA {sha}")
if "shared/sentry_init.py" not in zf.namelist():
raise SystemExit(f"{path} missing bundled shared package")
print("zips ok")
PY
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
run: |
set -euo pipefail
prefix=/afterhours-shift-manager/deploy
ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)
{
echo "artifacts_bucket=${ARTIFACTS_BUCKET}"
echo "slack_bot=$(aws ssm get-parameter --name "${prefix}/slack_bot-function-name" --query Parameter.Value --output text)"
echo "weekly_post=$(aws ssm get-parameter --name "${prefix}/weekly_post-function-name" --query Parameter.Value --output text)"
echo "roster_sync=$(aws ssm get-parameter --name "${prefix}/roster_sync-function-name" --query Parameter.Value --output text)"
echo "roster_api=$(aws ssm get-parameter --name "${prefix}/roster_api-function-name" --query Parameter.Value --output text)"
echo "ring_scheduler=$(aws ssm get-parameter --name "${prefix}/ring_scheduler-function-name" --query Parameter.Value --output text)"
echo "holiday_router=$(aws ssm get-parameter --name "${prefix}/holiday_router-function-name" --query Parameter.Value --output text)"
echo "release_notifier=$(aws ssm get-parameter --name "${prefix}/release_notifier-function-name" --query Parameter.Value --output text)"
echo "portal_api=$(aws ssm get-parameter --name "${prefix}/portal_api-function-name" --query Parameter.Value --output text)"
} >> "${GITHUB_OUTPUT}"
- name: Upload zips and update function code
env:
ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
SLACK_BOT: ${{ steps.deploy.outputs.slack_bot }}
WEEKLY_POST: ${{ steps.deploy.outputs.weekly_post }}
ROSTER_SYNC: ${{ steps.deploy.outputs.roster_sync }}
ROSTER_API: ${{ steps.deploy.outputs.roster_api }}
RING_SCHEDULER: ${{ steps.deploy.outputs.ring_scheduler }}
HOLIDAY_ROUTER: ${{ steps.deploy.outputs.holiday_router }}
RELEASE_NOTIFIER: ${{ steps.deploy.outputs.release_notifier }}
PORTAL_API: ${{ steps.deploy.outputs.portal_api }}
run: |
set -euo pipefail
keys=(
slack_bot:"${SLACK_BOT}"
weekly_post:"${WEEKLY_POST}"
roster_sync:"${ROSTER_SYNC}"
roster_api:"${ROSTER_API}"
ring_scheduler:"${RING_SCHEDULER}"
holiday_router:"${HOLIDAY_ROUTER}"
release_notifier:"${RELEASE_NOTIFIER}"
portal_api:"${PORTAL_API}"
)
for pair in "${keys[@]}"; do
name="${pair%%:*}"
fn="${pair#*:}"
key="functions/${name}/${GIT_SHA}.zip"
aws s3 cp "build/packages/${name}.zip" "s3://${ARTIFACTS_BUCKET}/${key}"
aws lambda update-function-code \
--function-name "${fn}" \
--s3-bucket "${ARTIFACTS_BUCKET}" \
--s3-key "${key}" \
--query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \
--output table
aws lambda wait function-updated-v2 --function-name "${fn}"
done

View file

@ -60,7 +60,7 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
- **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802`
- **Data**: DynamoDB single-table (`afterhours-shifts`)
- **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy.yaml` (zips) and `deploy-api.yaml` (image). Terraform does not package `src/`.
- **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy-api.yaml` (image). Terraform does not package `src/`.
- **Slack**: Slack Bolt on `POST /slack/events`
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
@ -75,7 +75,6 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
| `afterhours-portal-api` | API Gateway (ANY /api/shifts, ANY /api/shifts/{proxy+}) | Cognito-authenticated employee/admin shift API for the internal portal |
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
| `afterhours-holiday-router` | EventBridge Scheduler (per-holiday one-off: 8am activate / 5pm deactivate ET) | Repoints the IVR to the holiday queue and sets queue agents for a holiday day shift; reverts at 5pm (see [Holidays](#holidays)) |
| `afterhours-release-notifier` | Skeleton only until tagging exists | Posts a "What's New" announcement to the shift channel |
### Project Layout
@ -89,10 +88,9 @@ src/
portal-api/ Cognito employee/admin shift API for the internal portal
ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
release-notifier/ Posts release announcements to Slack
shared/ Bundled into each function zip and the Fargate image
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
scripts/ changelog CLI + CI guard + in-package copy sync + cutover
scripts/ in-package changelog copy sync + cutover
tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
```
@ -233,27 +231,22 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
## Deployment
Infrastructure is applied by HCP Terraform workspace `afterhours-shift-manager-prod` (VCS on `main`, working directory `terraform/`, file trigger `terraform/**` only). Function code is shipped by `.github/workflows/deploy.yaml` on push to `main` (`environment: prod`). A terraform-only merge does not run the zip deploy. A mixed app+terraform merge may race the apply; re-run the deploy job if the functions are still stubs.
Manual zip redeploy: Actions → Deploy → Run workflow (`workflow_dispatch`, always prod). Do not `terraform apply` locally to prod.
Infrastructure is applied by HCP Terraform workspace `afterhours-shift-manager-prod` (VCS on `main`, working directory `terraform/`, file trigger `terraform/**` only). The Flask image is shipped by `.github/workflows/deploy-api.yaml`.
## Monitoring & Alarms
All CloudWatch alarms are defined in `terraform/alarms.tf` and notify the shared
`site-alerts` SNS topic (→ AWS Chatbot → Slack). None set `OKActions` — recovery
is not paged. Alarm names follow `Lambda-<Metric>-<fn>`
(e.g. `Lambda-Errors-afterhours-ring-scheduler`).
is not paged. Alarm names follow `ALB-<Metric>-afterhours-shift-manager` and
`DDB-<Metric>-afterhours-shifts`.
**Lambda alarms** (all eight functions: `afterhours-shift-manager`,
`afterhours-weekly-post`, `afterhours-roster-sync`, `afterhours-roster-api`,
`afterhours-ring-scheduler`, `afterhours-holiday-router`,
`afterhours-release-notifier`, `afterhours-portal-api`):
**ALB alarms**:
| Alarm | Metric | Condition | Notes |
|---|---|---|---|
| `Lambda-Errors-<fn>` | `Errors` (Sum) | `>= 1` over one 5-min period | `TreatMissingData: notBreaching` |
| `Lambda-Duration-<fn>` | `Duration` (Maximum, ms) | `>= ~80% of timeout`, 2 of 3 5-min periods | Thresholds: 24000 ms (30s-timeout fns) / 48000 ms (60s-timeout fns) |
| `Lambda-Throttles-<fn>` | `Throttles` (Sum) | `>= 1` over one 5-min period | `TreatMissingData: notBreaching` |
| Alarm | Metric | Condition |
|---|---|---|
| `ALB-5xx-afterhours-shift-manager` | `HTTPCode_Target_5XX_Count` (Sum) | `> 0` over one 5-min period |
| `ALB-Latency-afterhours-shift-manager` | `TargetResponseTime` (p99, s) | `>= 3` s, 2 of 3 5-min periods |
| `ALB-UnhealthyHost-afterhours-shift-manager` | `UnHealthyHostCount` (Maximum) | `> 0`, 3 of 3 1-min periods |
**DynamoDB alarm** (`afterhours-shifts` table):
@ -268,26 +261,24 @@ transition normally. `ThrottledRequests` and `SystemErrors` are intentionally
**not** alarmed: AWS emits them only at `TableName`+`Operation` granularity, so a
`TableName`-only alarm would sit permanently in `INSUFFICIENT_DATA`.
**API Gateway alarms** (HTTP API, `AWS/ApiGateway` v2 metrics, `ApiId` dimension):
| Alarm | Metric | Condition |
|---|---|---|
| `ApiGateway-4xx-<apiId>` | `4xx` (Sum) | `>= 5` over one 5-min period |
| `ApiGateway-5xx-<apiId>` | `5xx` (Sum) | `>= 1` over one 5-min period |
| `ApiGateway-Latency-<apiId>` | `Latency` (p99, ms) | `>= 3000` ms, 2 of 3 5-min periods |
> Duration and API latency thresholds are starting points and may be tuned after
> observing real traffic.
**API Gateway alarms** were removed with the Fargate cutover (PLAT-216).
## Releases & Versioning
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`**. The
**App Home** tab reads the copy that ships in the slack-bot zip. Run
`python scripts/sync_changelog.py` after editing the root file. Changelog Guard
enforces that the in-package copy matches.
Prod is a human GitHub Release:
GitHub Releases and git tags are not cut by `deploy.yaml`. `afterhours-release-notifier`
exists as a function skeleton; CD does not invoke it until tagging exists.
```bash
gh release create vX.Y.Z --target main --generate-notes
```
That tag applies prod infra in HCP and queues `deploy-api.yaml` behind the prod
Environment. Merge to `main` deploys **dev**. Nothing in GitHub Actions creates
the Release.
**App Home "What's New"** still reads **`CHANGELOG.md`**. That file is product
copy, not the prod tag driver. After editing the root file, run
`python scripts/sync_changelog.py` so `src/slack-bot/CHANGELOG.md` stays in
sync. Pytest fails if the two copies drift.
## Testing

View file

@ -187,24 +187,12 @@ is copied.
## 9. Fargate cutover (PLAT-216)
Dual-run ECS beside API Gateway. Do not dual-write 3CX. Do not flip Slack
without the `afterhours-shift-manager-dev` workspace already serving
`/api/health`.
1. Image deploy via `deploy-api.yaml`. `GET /api/health` reports the real sha.
2. Recreate outstanding `holiday-activate-*` / `holiday-deactivate-*` onto
jobs SQS (same class of work as `recreate_holiday_schedules.py`):
`python scripts/cutover/retarget_holiday_schedules_to_sqs.py --profile prod --queue-arn <jobs_queue_arn>`
then `--execute`.
3. Instant cut: Slack Request URL, Paychex `AFTERHOURS_BASE_URL`, portal
`VITE_SHIFTS_API_BASE` → `https://afterhours.seahaven.com` (dev hostname in
portal-dev). Smoke `/oncall`, roster PUT/DELETE, `GET /api/shifts`, one
holiday GetSchedule, ring job.
4. Set `ecs_schedules_enabled=true` and keep `schedules_enabled=false`. Confirm
no 3CX writers remain on Lambda.
5. After smoke, remove API Gateway, the eight Lambdas, zip packaging, and
Lambda alarms in a follow-up apply. ALB 5xx/latency and unhealthy-host
alarms stay.
Completed 2026-09-21. Live path is ECS Fargate behind
`https://afterhours.seahaven.com` (dev: `https://afterhours.dev.seahaven.com`).
Slack Request URL, Paychex `AFTERHOURS_BASE_URL`, and portal `VITE_SHIFTS_API_BASE`
point at those hosts. Jobs use EventBridge Scheduler → SQS (`ecs_schedules_enabled=true`,
Lambda EventBridge `schedules_enabled=false`). Public DNS is out of band in the
mgmt `seahaven.com` zone and the external-dev `dev.seahaven.com` zone.
## Commands Reference

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,83 +1,3 @@
locals {
lambda_alarm_matrix = {
errors = {
metric_name = "Errors"
statistic = "Sum"
evaluation_periods = 1
datapoints_to_alarm = 1
threshold = 1
comparison = "GreaterThanOrEqualToThreshold"
period = 300
}
throttles = {
metric_name = "Throttles"
statistic = "Sum"
evaluation_periods = 1
datapoints_to_alarm = 1
threshold = 1
comparison = "GreaterThanOrEqualToThreshold"
period = 300
}
}
lambda_alarms = {
for pair in flatten([
for fn_key, fn in local.functions : [
for metric_key, metric in local.lambda_alarm_matrix : {
key = "${fn_key}-${metric_key}"
fn_key = fn_key
function = fn.function_name
metric_key = metric_key
metric_name = metric.metric_name
statistic = metric.statistic
evaluation = metric.evaluation_periods
datapoints = metric.datapoints_to_alarm
threshold = metric.threshold
comparison = metric.comparison
period = metric.period
description = metric_key == "errors" ? "${fn.function_name} reported one or more errors" : "${fn.function_name} was throttled (concurrency limit hit)"
}
]
]) : pair.key => pair
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors_throttles" {
for_each = local.lambda_alarms
alarm_name = "Lambda-${title(each.value.metric_key)}-${each.value.function}"
alarm_description = each.value.description
namespace = "AWS/Lambda"
metric_name = each.value.metric_name
dimensions = { FunctionName = each.value.function }
statistic = each.value.statistic
period = each.value.period
evaluation_periods = each.value.evaluation
datapoints_to_alarm = each.value.datapoints
threshold = each.value.threshold
comparison_operator = each.value.comparison
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
for_each = local.functions
alarm_name = "Lambda-Duration-${each.value.function_name}"
alarm_description = "${each.value.function_name} duration approaching its ${each.value.timeout}s timeout (>=${each.value.duration_ms}ms)"
namespace = "AWS/Lambda"
metric_name = "Duration"
dimensions = { FunctionName = each.value.function_name }
statistic = "Maximum"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = each.value.duration_ms
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "ddb_read_throttle" {
alarm_name = "DDB-ReadThrottle-${local.table_name}"
alarm_description = "afterhours-shifts table had one or more read throttle events"
@ -108,52 +28,6 @@ resource "aws_cloudwatch_metric_alarm" "ddb_write_throttle" {
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "api_4xx" {
alarm_name = "ApiGateway-4xx-${aws_apigatewayv2_api.http.id}"
alarm_description = "Elevated 4xx responses on the afterhours HTTP API"
namespace = "AWS/ApiGateway"
metric_name = "4xx"
dimensions = { ApiId = aws_apigatewayv2_api.http.id }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 5
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
alarm_name = "ApiGateway-5xx-${aws_apigatewayv2_api.http.id}"
alarm_description = "5xx responses on the afterhours HTTP API"
namespace = "AWS/ApiGateway"
metric_name = "5xx"
dimensions = { ApiId = aws_apigatewayv2_api.http.id }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "api_latency" {
alarm_name = "ApiGateway-Latency-${aws_apigatewayv2_api.http.id}"
alarm_description = "p99 latency on the afterhours HTTP API exceeded 3s"
namespace = "AWS/ApiGateway"
metric_name = "Latency"
dimensions = { ApiId = aws_apigatewayv2_api.http.id }
extended_statistic = "p99"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = 3000
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
}
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
alarm_name = "ALB-5xx-${local.project}"
alarm_description = "ALB 5xx from afterhours-shift-manager"

View file

@ -1,126 +0,0 @@
# HTTP API: Slack events, Paychex roster contract, and portal shift API.
resource "aws_apigatewayv2_api" "http" {
name = local.project
protocol_type = "HTTP"
description = "afterhours-shift-manager Slack, roster, and portal API"
cors_configuration {
allow_origins = [
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
]
allow_methods = ["GET", "POST", "DELETE", "OPTIONS"]
allow_headers = ["Authorization", "Content-Type"]
allow_credentials = false
max_age = 3600
}
}
resource "aws_apigatewayv2_integration" "slack_bot" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.this["slack_bot"].invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 30000
}
resource "aws_apigatewayv2_integration" "roster_api" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.this["roster_api"].invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 30000
}
resource "aws_apigatewayv2_integration" "portal_api" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.this["portal_api"].invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 30000
}
resource "aws_apigatewayv2_route" "slack_events" {
api_id = aws_apigatewayv2_api.http.id
route_key = "POST /slack/events"
target = "integrations/${aws_apigatewayv2_integration.slack_bot.id}"
}
resource "aws_apigatewayv2_route" "put_roster" {
api_id = aws_apigatewayv2_api.http.id
route_key = "PUT /roster"
target = "integrations/${aws_apigatewayv2_integration.roster_api.id}"
}
resource "aws_apigatewayv2_route" "delete_roster" {
api_id = aws_apigatewayv2_api.http.id
route_key = "DELETE /roster/{extension}"
target = "integrations/${aws_apigatewayv2_integration.roster_api.id}"
}
resource "aws_apigatewayv2_route" "portal_shifts" {
api_id = aws_apigatewayv2_api.http.id
route_key = "ANY /api/shifts"
target = "integrations/${aws_apigatewayv2_integration.portal_api.id}"
}
resource "aws_apigatewayv2_route" "portal_shifts_proxy" {
api_id = aws_apigatewayv2_api.http.id
route_key = "ANY /api/shifts/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.portal_api.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.http.id
name = "$default"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_access.arn
format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}"
}
default_route_settings {
throttling_burst_limit = 50
throttling_rate_limit = 100
}
depends_on = [
aws_apigatewayv2_route.slack_events,
aws_apigatewayv2_route.put_roster,
aws_apigatewayv2_route.delete_roster,
aws_apigatewayv2_route.portal_shifts,
aws_apigatewayv2_route.portal_shifts_proxy,
aws_iam_role_policy.hcptf_apply_services,
]
}
resource "aws_lambda_permission" "api_slack_bot" {
statement_id = "AllowApiGatewayInvokeSlackBot"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this["slack_bot"].function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}
resource "aws_lambda_permission" "api_roster_api" {
statement_id = "AllowApiGatewayInvokeRosterApi"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this["roster_api"].function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}
resource "aws_lambda_permission" "api_portal_api" {
statement_id = "AllowApiGatewayInvokePortalApi"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this["portal_api"].function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}

View file

@ -186,7 +186,6 @@ locals {
{ name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" },
{ name = "QUEUE_NUMBER", value = var.queue_number },
{ name = "TZ", value = var.timezone },
{ name = "HOLIDAY_ROUTER_ARN", value = local.holiday_router_arn },
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
{ name = "SENTRY_DSN", value = var.sentry_dsn },

View file

@ -1,76 +0,0 @@
# EventBridge schedules. Every schedule is an EST/EDT pair firing the same
# function one hour apart in UTC: EventBridge cron has no timezone. Both fire
# year-round and the handlers are idempotent. Keep schedules_enabled=false
# until Slack and Paychex point at this stack.
locals {
schedules = {
weekly-post-est = {
description = "Post weekly schedule Monday 7am EST"
schedule = "cron(0 12 ? * MON *)"
function_key = "weekly_post"
}
weekly-post-edt = {
description = "Post weekly schedule Monday 7am EDT"
schedule = "cron(0 11 ? * MON *)"
function_key = "weekly_post"
}
roster-sync-est = {
description = "Sync roster from 3CX at 6am EST"
schedule = "cron(0 11 ? * * *)"
function_key = "roster_sync"
}
roster-sync-edt = {
description = "Sync roster from 3CX at 6am EDT"
schedule = "cron(0 10 ? * * *)"
function_key = "roster_sync"
}
ring-scheduler-daily-est = {
description = "Update 3CX queue at 8am EST"
schedule = "cron(0 13 ? * * *)"
function_key = "ring_scheduler"
}
ring-scheduler-daily-edt = {
description = "Update 3CX queue at 8am EDT"
schedule = "cron(0 12 ? * * *)"
function_key = "ring_scheduler"
}
ring-scheduler-weekend-est = {
description = "Update 3CX queue at 5pm EST weekends"
schedule = "cron(0 22 ? * SAT,SUN *)"
function_key = "ring_scheduler"
}
ring-scheduler-weekend-edt = {
description = "Update 3CX queue at 5pm EDT weekends"
schedule = "cron(0 21 ? * SAT,SUN *)"
function_key = "ring_scheduler"
}
}
}
resource "aws_cloudwatch_event_rule" "schedule" {
for_each = local.schedules
name = "${local.project}-${each.key}"
description = each.value.description
schedule_expression = each.value.schedule
state = var.schedules_enabled ? "ENABLED" : "DISABLED"
}
resource "aws_cloudwatch_event_target" "schedule" {
for_each = local.schedules
rule = aws_cloudwatch_event_rule.schedule[each.key].name
target_id = "${local.project}-${each.key}"
arn = aws_lambda_function.this[each.value.function_key].arn
}
resource "aws_lambda_permission" "schedule" {
for_each = local.schedules
statement_id = "AllowEventBridgeInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this[each.value.function_key].function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn
}

View file

@ -634,6 +634,18 @@ data "aws_iam_policy_document" "hcptf_apply_ecs" {
resources = ["*"]
}
statement {
sid = "CreateElbAndEcsServiceLinkedRoles"
effect = "Allow"
actions = [
"iam:CreateServiceLinkedRole",
]
resources = [
"arn:aws:iam::${local.account_id}:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing",
"arn:aws:iam::${local.account_id}:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
]
}
statement {
sid = "EcrRepo"
effect = "Allow"

View file

@ -1,8 +1,8 @@
# GitHub Actions OIDC role for .github/workflows/deploy.yaml and deploy-api.yaml.
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
#
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
# to Environments dev and prod (immutable and classic subject forms) and
# job_workflow_ref to deploy.yaml at main plus deploy-api.yaml at main and v*.
# job_workflow_ref to deploy-api.yaml at main and v*.
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
@ -31,7 +31,6 @@ data "aws_iam_policy_document" "github_deploy_assume" {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}",
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}",
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
]
@ -42,43 +41,12 @@ data "aws_iam_policy_document" "github_deploy_assume" {
resource "aws_iam_role" "github_deploy" {
name = local.deploy_role
path = "/tf-managed/"
description = "GitHub Actions zip and image deploy role for ${var.github_repo}"
description = "GitHub Actions image deploy role for ${var.github_repo}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "ListArtifactsBucket"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = [aws_s3_bucket.artifacts.arn]
}
statement {
sid = "UploadFunctionArtifacts"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
]
resources = ["${aws_s3_bucket.artifacts.arn}/functions/*"]
}
statement {
sid = "UpdateFunctionCode"
effect = "Allow"
actions = [
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:UpdateFunctionCode",
]
resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"]
}
statement {
sid = "EcrAuth"
effect = "Allow"

View file

@ -1,9 +1,6 @@
# Terraform owns the function skeletons (role, runtime, memory, environment).
# Code is owned by .github/workflows/deploy.yaml, which uploads
# functions/<name>/<sha>.zip and calls update-function-code. The lifecycle
# block is the seam: an app deploy is not drift, and a Terraform apply never
# rolls the code back to the bootstrap stub. GIT_SHA is written into
# shared/build_info.py at zip time, not set here.
# Leftover Lambda IAM roles from dual-run. The seven functions are gone;
# weekly-post remains so paychex-checkcomponents can keep that principal
# until a follow-up queue-policy apply drops it.
data "aws_iam_policy_document" "lambda_assume" {
statement {
@ -137,14 +134,6 @@ locals {
condition = null
},
]
release_notifier = [
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"]
condition = null
},
]
portal_api = [
{
sid = "DdbCrud"
@ -231,12 +220,6 @@ locals {
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
release_notifier = {
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SHIFT_CHANNEL = var.shift_channel
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
portal_api = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
@ -306,33 +289,3 @@ resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda[each.key].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "this" {
for_each = local.functions
function_name = each.value.function_name
role = aws_iam_role.lambda[each.key].arn
handler = each.value.handler
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 1024
timeout = each.value.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.bootstrap_stub.key
source_code_hash = data.archive_file.bootstrap_stub.output_base64sha256
environment {
variables = local.lambda_env[each.key]
}
lifecycle {
ignore_changes = [filename, s3_bucket, s3_key, s3_object_version, source_code_hash]
}
depends_on = [
aws_cloudwatch_log_group.lambda,
aws_iam_role_policy.lambda,
aws_iam_role_policy_attachment.lambda_basic,
]
}

View file

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

View file

@ -1,15 +1,3 @@
resource "aws_cloudwatch_log_group" "lambda" {
for_each = local.functions
name = "/aws/lambda/${each.value.function_name}"
retention_in_days = 60
}
resource "aws_cloudwatch_log_group" "api_access" {
name = "/aws/apigateway/${local.project}"
retention_in_days = 90
}
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/${local.project}"
retention_in_days = local.is_prod ? 60 : 14

View file

@ -1,11 +1,11 @@
output "slack_request_url" {
description = "Slack app Request URL (slash command and interactivity)."
value = "${aws_apigatewayv2_api.http.api_endpoint}/slack/events"
value = "${local.api_url}/slack/events"
}
output "api_origin" {
description = "HTTP API origin for Paychex AFTERHOURS_BASE_URL. No /roster suffix."
value = aws_apigatewayv2_api.http.api_endpoint
description = "HTTP origin for Paychex AFTERHOURS_BASE_URL and portal VITE_SHIFTS_API_BASE. No /roster suffix."
value = local.api_url
}
output "shift_table_name" {
@ -14,17 +14,17 @@ output "shift_table_name" {
}
output "holiday_scheduler_role_arn" {
description = "Role EventBridge Scheduler assumes to invoke the holiday router."
description = "Role EventBridge Scheduler assumes to enqueue holiday jobs."
value = aws_iam_role.holiday_scheduler.arn
}
output "github_deploy_role_arn" {
description = "OIDC role ARN for deploy.yaml and deploy-api.yaml (GitHub Environment variable DEPLOY_ROLE_ARN)."
description = "OIDC role ARN for deploy-api.yaml (GitHub Environment variable DEPLOY_ROLE_ARN)."
value = aws_iam_role.github_deploy.arn
}
output "artifacts_bucket_name" {
description = "Lambda artifacts bucket. deploy.yaml uploads functions/<name>/<sha>.zip."
description = "Leftover Lambda artifacts bucket from dual-run zip CD."
value = aws_s3_bucket.artifacts.id
}

View file

@ -36,13 +36,6 @@ resource "aws_iam_role" "holiday_scheduler" {
}
data "aws_iam_policy_document" "holiday_scheduler" {
statement {
sid = "InvokeHolidayRouter"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [local.holiday_router_arn]
}
statement {
sid = "SendHolidayJobs"
effect = "Allow"

View file

@ -2,16 +2,7 @@ resource "aws_ssm_parameter" "deploy_artifacts_bucket" {
name = "${local.ssm_prefix}/deploy/artifacts-bucket"
type = "String"
value = aws_s3_bucket.artifacts.id
description = "Lambda artifacts bucket; deploy.yaml uploads functions/<name>/<sha>.zip"
}
resource "aws_ssm_parameter" "deploy_function_name" {
for_each = local.functions
name = "${local.ssm_prefix}/deploy/${each.key}-function-name"
type = "String"
value = each.value.function_name
description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code"
description = "Unused leftover Lambda artifacts bucket from the dual-run zip path."
}
resource "aws_ssm_parameter" "deploy_api_url" {

View file

@ -1,4 +1,4 @@
"""Contracts for the HCP Terraform seam (PLAT-74)."""
"""Contracts for the HCP Terraform seam (PLAT-74 / PLAT-216)."""
from pathlib import Path
@ -6,7 +6,6 @@ ROOT = Path(__file__).resolve().parents[2]
TERRAFORM = ROOT / "terraform"
LAMBDA_TF = (TERRAFORM / "lambda.tf").read_text()
HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text()
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
LOCALS = (TERRAFORM / "locals.tf").read_text()
VARIABLES = (TERRAFORM / "variables.tf").read_text()
@ -17,17 +16,11 @@ def test_sam_template_removed():
assert not (ROOT / "samconfig.toml.example").exists()
def test_lambda_ignore_changes_includes_code_attributes():
for attr in (
"filename",
"s3_bucket",
"s3_key",
"s3_object_version",
"source_code_hash",
):
assert attr in LAMBDA_TF
assert "lifecycle" in LAMBDA_TF
assert "ignore_changes" in LAMBDA_TF
def test_zip_cd_removed():
assert not (ROOT / ".github" / "workflows" / "deploy.yaml").exists()
assert 'resource "aws_lambda_function"' not in LAMBDA_TF
assert not (TERRAFORM / "apigateway.tf").exists()
assert not (TERRAFORM / "events.tf").exists()
def test_schedules_disabled_by_default():
@ -86,6 +79,8 @@ def test_stack_owns_a_vpc_instead_of_looking_up_default():
assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM
assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM
assert "sid = \"CreateElbAndEcsServiceLinkedRoles\"" in HCP_IAM
assert "iam:CreateServiceLinkedRole" in HCP_IAM
def test_ecs_task_boundary_uses_static_arns():
@ -115,16 +110,6 @@ def test_in_repo_hcptf_roles():
assert "DenyCreatePolicy" in HCP_IAM
def test_deploy_workflow_is_prod_zip_cd():
assert "release: published" not in DEPLOY
assert "cd-sam" not in DEPLOY
assert "environment: prod" in DEPLOY
assert "deploy-afterhours-prod" in DEPLOY
assert "gh release create" not in DEPLOY
assert "package_lambdas.py" in DEPLOY
assert "update-function-code" in DEPLOY
def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI
assert "pytest" in CI
@ -143,7 +128,7 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
assert "checkcomponents_pair" in data_tf
def test_eight_functions_named():
def test_seven_function_names_still_on_leftover_roles():
for name in (
"afterhours-shift-manager",
"afterhours-weekly-post",
@ -151,24 +136,23 @@ def test_eight_functions_named():
"afterhours-roster-api",
"afterhours-ring-scheduler",
"afterhours-holiday-router",
"afterhours-release-notifier",
"afterhours-portal-api",
):
assert name in LOCALS
assert "afterhours-release-notifier" not in LOCALS
def test_weekly_post_role_is_tf_managed_name():
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
def test_github_deploy_trust_covers_zip_and_image():
def test_github_deploy_trust_covers_image_only():
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
assert "environment:prod" in iam or "environment:prod" in LOCALS
assert "environment:dev" in iam or "environment:dev" in LOCALS
assert "deploy.yaml@refs/heads/${var.github_deploy_branch}" in iam
assert "deploy.yaml@" not in iam
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam
assert "deploy-api.yaml@refs/tags/v*" in iam
assert "deploy.yaml@*" not in iam
assert "ecs:ListTasks" in iam
@ -176,3 +160,19 @@ def test_plan_refresh_includes_provider6_s3_gets():
assert "s3:GetLifecycleConfiguration" in HCP_IAM
assert "s3:GetReplicationConfiguration" in HCP_IAM
assert "s3:GetBucketReplication" in HCP_IAM
def test_origins_use_fargate_url():
outputs = (TERRAFORM / "outputs.tf").read_text()
assert "aws_apigatewayv2_api.http" not in outputs
assert '${local.api_url}/slack/events' in outputs
assert "value = local.api_url" in outputs
def test_alb_alarms_remain():
alarms = (TERRAFORM / "alarms.tf").read_text()
assert "ALB-5xx-" in alarms
assert "ALB-Latency-" in alarms
assert "ALB-UnhealthyHost-" in alarms
assert "ApiGateway-" not in alarms
assert "Lambda-" not in alarms

View file

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

View file

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

View file

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

View file

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

View file

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