mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-07 16:19:00 +00:00
Compare commits
3 commits
6c4018d6b8
...
26e9716df4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e9716df4 | ||
|
|
be1160192c | ||
|
|
cbda46ba87 |
31 changed files with 114 additions and 1225 deletions
38
.github/workflows/changelog-guard.yml
vendored
38
.github/workflows/changelog-guard.yml
vendored
|
|
@ -1,38 +0,0 @@
|
|||
name: Changelog Guard
|
||||
|
||||
# Repo-specific PR check (in addition to the reusable ci.yaml). Enforces that
|
||||
# CHANGELOG.md drives versioning correctly: a changelog edit must be a clean
|
||||
# SemVer bump above the latest tag, and the in-package copy must stay in sync.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Validate CHANGELOG + version bump
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if git diff --name-only "$BASE_SHA" HEAD | grep -qx 'CHANGELOG.md'; then
|
||||
CHANGELOG_CHANGED=true
|
||||
else
|
||||
CHANGELOG_CHANGED=false
|
||||
fi
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
echo "CHANGELOG changed in PR: $CHANGELOG_CHANGED | latest tag: ${PREV_TAG:-none}"
|
||||
CHANGELOG_CHANGED="$CHANGELOG_CHANGED" PREV_TAG="$PREV_TAG" \
|
||||
python scripts/check_changelog.py
|
||||
4
.github/workflows/deploy-api.yaml
vendored
4
.github/workflows/deploy-api.yaml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
154
.github/workflows/deploy.yaml
vendored
154
.github/workflows/deploy.yaml
vendored
|
|
@ -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
|
||||
59
README.md
59
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
24
SETUP.md
24
SETUP.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Thin CLI over ``shared.changelog`` for the release workflow.
|
||||
|
||||
Keeps all changelog parsing in one tested module instead of inline shell/Python
|
||||
in the workflow. Reads the changelog file given as an argument.
|
||||
|
||||
Usage:
|
||||
changelog_cli.py top-version <file> # newest entry's version
|
||||
changelog_cli.py bump-kind <file> <prev> # major|minor|patch|none vs <prev>
|
||||
changelog_cli.py payload <file> <version> # JSON {version, notes, date_label}
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared"))
|
||||
|
||||
from shared.changelog import bump_kind, entry_for, top_version # noqa: E402
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
cmd, path = argv[1], argv[2]
|
||||
text = pathlib.Path(path).read_text()
|
||||
|
||||
if cmd == "top-version":
|
||||
sys.stdout.write(top_version(text) or "")
|
||||
elif cmd == "bump-kind":
|
||||
prev = argv[3].lstrip("v")
|
||||
top = top_version(text)
|
||||
sys.stdout.write((bump_kind(top, prev) or "none") if top else "none")
|
||||
elif cmd == "payload":
|
||||
version = argv[3].lstrip("v")
|
||||
entry = entry_for(text, version)
|
||||
sys.stdout.write(
|
||||
json.dumps(
|
||||
{
|
||||
"version": version,
|
||||
"notes": entry.body if entry else "",
|
||||
"date_label": entry.date_label if entry else "",
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
sys.exit(f"unknown command: {cmd}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""CI guard: validate CHANGELOG.md versioning and the in-package copy.
|
||||
|
||||
Run on pull requests. Two checks:
|
||||
|
||||
1. If CHANGELOG.md changed in the PR, its top version must be a clean
|
||||
single-step SemVer bump above the latest ``v*`` tag. (Non-changing PRs —
|
||||
dependabot bumps, docs — are not version-checked, so they don't release.)
|
||||
2. ``src/slack-bot/CHANGELOG.md`` (the copy that ships with the bot and feeds the
|
||||
App Home "What's New" tab) must match the canonical root CHANGELOG.md.
|
||||
|
||||
The workflow passes context via env: ``PREV_TAG`` (latest tag) and
|
||||
``CHANGELOG_CHANGED`` ("true"/"false"). Run locally it assumes the changelog
|
||||
changed so the bump is validated.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared"))
|
||||
|
||||
from shared.changelog import bump_kind, top_version # noqa: E402
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
PKG_COPY = ROOT / "src" / "slack-bot" / "CHANGELOG.md"
|
||||
|
||||
|
||||
def evaluate(
|
||||
top: str | None, prev_tag: str, changelog_changed: bool, copies_match: bool
|
||||
) -> list[str]:
|
||||
"""Return a list of problems (empty == pass). Pure, for unit testing."""
|
||||
problems = []
|
||||
if not copies_match:
|
||||
problems.append(
|
||||
"src/slack-bot/CHANGELOG.md is out of sync with CHANGELOG.md — "
|
||||
"run scripts/sync_changelog.py"
|
||||
)
|
||||
if changelog_changed:
|
||||
if top is None:
|
||||
problems.append("CHANGELOG.md changed but has no version entry at the top")
|
||||
else:
|
||||
prev = (prev_tag or "v0.0.0").lstrip("v")
|
||||
if bump_kind(top, prev) is None:
|
||||
problems.append(
|
||||
f"top version v{top} is not a clean single-step SemVer bump "
|
||||
f"above the latest tag v{prev} (expected one of "
|
||||
f"inc-major / inc-minor / inc-patch)"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root_text = (ROOT / "CHANGELOG.md").read_text()
|
||||
copies_match = PKG_COPY.exists() and PKG_COPY.read_text() == root_text
|
||||
|
||||
problems = evaluate(
|
||||
top=top_version(root_text),
|
||||
prev_tag=os.environ.get("PREV_TAG", ""),
|
||||
changelog_changed=os.environ.get("CHANGELOG_CHANGED", "true") == "true",
|
||||
copies_match=copies_match,
|
||||
)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"::error::{problem}")
|
||||
return 1
|
||||
print("CHANGELOG guard passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -27,7 +27,6 @@ FUNCTIONS = {
|
|||
"roster_api": ROOT / "src" / "roster-api",
|
||||
"ring_scheduler": ROOT / "src" / "ring-scheduler",
|
||||
"holiday_router": ROOT / "src" / "holiday-router",
|
||||
"release_notifier": ROOT / "src" / "release-notifier",
|
||||
"portal_api": ROOT / "src" / "portal-api",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
"""Lambda handler — announces a new release to the shift channel.
|
||||
|
||||
Invoked by the release workflow (``.github/workflows/release.yaml``) once a
|
||||
minor or major version has been tagged *and* the new code has deployed
|
||||
successfully. The event carries the version and notes already extracted from
|
||||
CHANGELOG.md by the workflow, so this function never reads the changelog file
|
||||
itself (it ships only in the slack-bot package, not here).
|
||||
|
||||
Event shape (the frozen contract between release.yaml and this function):
|
||||
{"version": "1.10.0", "notes": "<markdown>", "date_label": "June 11, 2026"}
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from slack_sdk import WebClient
|
||||
|
||||
import shared.sentry_init # noqa: F401
|
||||
from shared.blocks import build_release_announcement_blocks
|
||||
from shared.secrets import get_secret
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
event = event or {}
|
||||
version = event.get("version")
|
||||
notes = event.get("notes")
|
||||
date_label = event.get("date_label", "")
|
||||
|
||||
if not version or not notes:
|
||||
raise ValueError("event requires non-empty 'version' and 'notes'")
|
||||
|
||||
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
|
||||
channel_id = os.environ["SHIFT_CHANNEL"]
|
||||
slack = WebClient(token=bot_token)
|
||||
|
||||
blocks = build_release_announcement_blocks(version, notes, date_label)
|
||||
result = slack.chat_postMessage(
|
||||
channel=channel_id,
|
||||
blocks=blocks,
|
||||
text=f"What's New — v{version}",
|
||||
)
|
||||
logger.info("Announced v%s to %s (ts=%s)", version, channel_id, result["ts"])
|
||||
return {"announced": True, "version": version, "ts": result["ts"]}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
slack_sdk>=3.44.1,<4.0
|
||||
boto3>=1.43.97
|
||||
|
|
@ -32,39 +32,6 @@ def markdown_to_mrkdwn(text: str) -> str:
|
|||
return text
|
||||
|
||||
|
||||
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``.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}/*/*"
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
"""Load src/release-notifier/app.py under a unique module name."""
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load(name, relpath):
|
||||
spec = importlib.util.spec_from_file_location(name, _ROOT / relpath)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier_app():
|
||||
return _load("release_notifier_app", "src/release-notifier/app.py")
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"""Tests for the release-notifier Lambda handler."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack(notifier_app, monkeypatch):
|
||||
"""Fake Slack WebClient; chat_postMessage returns a message ts."""
|
||||
fake = MagicMock(name="slack")
|
||||
fake.chat_postMessage.return_value = {"ts": "111.222"}
|
||||
monkeypatch.setattr(notifier_app, "WebClient", MagicMock(return_value=fake))
|
||||
monkeypatch.setattr(notifier_app, "get_secret", lambda _id: "xoxb-test")
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
|
||||
)
|
||||
monkeypatch.setenv("SHIFT_CHANNEL", "C_RELEASES")
|
||||
|
||||
|
||||
def test_posts_announcement_to_channel(notifier_app, slack, env):
|
||||
result = notifier_app.handler(
|
||||
{
|
||||
"version": "1.10.0",
|
||||
"notes": "**Release notes** now self-announce.",
|
||||
"date_label": "June 11, 2026",
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
assert result == {"announced": True, "version": "1.10.0", "ts": "111.222"}
|
||||
slack.chat_postMessage.assert_called_once()
|
||||
kwargs = slack.chat_postMessage.call_args.kwargs
|
||||
assert kwargs["channel"] == "C_RELEASES"
|
||||
assert kwargs["text"] == "What's New — v1.10.0"
|
||||
# Markdown was converted to Slack mrkdwn in the rendered blocks.
|
||||
rendered = str(kwargs["blocks"])
|
||||
assert "*Release notes*" in rendered
|
||||
|
||||
|
||||
def test_uses_the_slack_bot_token_secret(notifier_app, slack, env, monkeypatch):
|
||||
seen = {}
|
||||
monkeypatch.setattr(
|
||||
notifier_app, "get_secret", lambda sid: seen.setdefault("id", sid) or "xoxb"
|
||||
)
|
||||
notifier_app.handler({"version": "2.0.0", "notes": "Big."}, None)
|
||||
assert seen["id"] == "afterhours-shift-manager/slack-bot-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
[
|
||||
{},
|
||||
{"version": "1.10.0"}, # missing notes
|
||||
{"notes": "x"}, # missing version
|
||||
{"version": "", "notes": "x"}, # empty version
|
||||
],
|
||||
)
|
||||
def test_rejects_incomplete_event(notifier_app, slack, env, event):
|
||||
with pytest.raises(ValueError):
|
||||
notifier_app.handler(event, None)
|
||||
slack.chat_postMessage.assert_not_called()
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"""Tests for the release tooling scripts (CI guard + changelog CLI)."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load(name, relpath):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / relpath)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
check = _load("check_changelog", "scripts/check_changelog.py")
|
||||
cli = _load("changelog_cli", "scripts/changelog_cli.py")
|
||||
|
||||
|
||||
class TestGuardEvaluate:
|
||||
def test_clean_minor_bump_passes(self):
|
||||
assert check.evaluate("1.10.0", "v1.9.2", True, True) == []
|
||||
|
||||
def test_bad_bump_flagged(self):
|
||||
problems = check.evaluate("1.11.0", "v1.9.2", True, True) # skips a minor
|
||||
assert problems and "clean single-step" in problems[0]
|
||||
|
||||
def test_unchanged_changelog_is_not_version_checked(self):
|
||||
# A docs/dependabot PR (no CHANGELOG edit) never trips the bump check.
|
||||
assert check.evaluate("5.0.0", "v1.9.2", False, True) == []
|
||||
|
||||
def test_copy_drift_flagged_even_when_unchanged(self):
|
||||
problems = check.evaluate("1.9.2", "v1.9.2", False, False)
|
||||
assert any("out of sync" in p for p in problems)
|
||||
|
||||
def test_missing_top_version_flagged_when_changed(self):
|
||||
problems = check.evaluate(None, "v1.9.2", True, True)
|
||||
assert any("no version entry" in p for p in problems)
|
||||
|
||||
def test_first_release_from_no_tags(self):
|
||||
assert check.evaluate("0.1.0", "", True, True) == []
|
||||
|
||||
|
||||
class TestChangelogCli:
|
||||
SAMPLE = "## v1.10.0 — June 11, 2026\n\n**Self-announcing** releases.\n"
|
||||
|
||||
def test_top_version(self, tmp_path, capsys):
|
||||
f = tmp_path / "CHANGELOG.md"
|
||||
f.write_text(self.SAMPLE)
|
||||
cli.main(["x", "top-version", str(f)])
|
||||
assert capsys.readouterr().out == "1.10.0"
|
||||
|
||||
def test_bump_kind(self, tmp_path, capsys):
|
||||
f = tmp_path / "CHANGELOG.md"
|
||||
f.write_text(self.SAMPLE)
|
||||
cli.main(["x", "bump-kind", str(f), "v1.9.2"])
|
||||
assert capsys.readouterr().out == "minor"
|
||||
|
||||
def test_payload(self, tmp_path, capsys):
|
||||
f = tmp_path / "CHANGELOG.md"
|
||||
f.write_text(self.SAMPLE)
|
||||
cli.main(["x", "payload", str(f), "1.10.0"])
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["version"] == "1.10.0"
|
||||
assert out["date_label"] == "June 11, 2026"
|
||||
# CLI emits raw markdown; Slack conversion happens later, in the Lambda.
|
||||
assert "**Self-announcing**" in out["notes"]
|
||||
|
|
@ -11,7 +11,6 @@ from shared.blocks import (
|
|||
build_pay_summary_blocks,
|
||||
build_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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue