ci(workflows): call org reusable CI and Fargate CD (#276)

* ci(workflows): call org reusable CI and Fargate CD

Local CI and the image deploy duplicated the org workflows and still required ci / ci. Pin the callers to those workflows and trust the reusable deploy ref.

* test(ci): probe ruff with an undefined name

* test(ci): remove the undefined-name ruff probe

* ci: retrigger checks after removing the ruff probe

* test(ci): probe ruff with an unused import

* style: apply formatter

* test(ci): remove the autofix probe

---------

Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
This commit is contained in:
Adam Moussa 2026-09-24 16:46:40 +00:00 • committed by GitHub
parent e600dd47a3
commit 8a23d06a64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 224 additions and 335 deletions

View file

@ -2,15 +2,35 @@ name: CI
on:
pull_request:
branches: [main]
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
pytest:
name: Pytest
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
permissions:
contents: write
secrets: inherit
with:
presets: ruff,terraform
terraform-version: "1.16.0"
lint:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
with:
python-version: "3.12"
test:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@ -37,33 +57,16 @@ jobs:
run: pytest
terraform:
name: Terraform
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate
terraform-version: "1.16.0"
openapi:
name: OpenAPI
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@ -82,35 +85,22 @@ jobs:
- name: Lint OpenAPI
run: npm run openapi:lint
ci:
name: ci / ci
needs: [pytest, terraform, openapi]
if: ${{ always() && !cancelled() }}
ci-complete:
name: ci-complete
needs: [autofix, lint, test, terraform, openapi]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check jobs
- name: Require portions
env:
PYTEST_RESULT: ${{ needs.pytest.result }}
TERRAFORM_RESULT: ${{ needs.terraform.result }}
OPENAPI_RESULT: ${{ needs.openapi.result }}
LINT: ${{ needs.lint.result }}
TEST: ${{ needs.test.result }}
TERRAFORM: ${{ needs.terraform.result }}
OPENAPI: ${{ needs.openapi.result }}
run: |
set -euo pipefail
fail=0
check() {
local name="$1"
local result="$2"
case "${result}" in
success)
echo "${name}: ${result}"
;;
*)
echo "${name}: ${result}" >&2
fail=1
;;
esac
}
check pytest "${PYTEST_RESULT}"
check terraform "${TERRAFORM_RESULT}"
check openapi "${OPENAPI_RESULT}"
exit "${fail}"
test "${LINT}" = success
test "${TEST}" = success
test "${TERRAFORM}" = success
test "${OPENAPI}" = success

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16

View file

@ -1,8 +1,8 @@
name: Deploy API
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes
# to ECR, and registers a new task definition. Terraform owns the cluster,
# service, ALB, and ignores container_definitions / task_definition.
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes to ECR,
# and registers a new task definition. Terraform owns the cluster, service,
# ALB, and ignores container_definitions / task_definition.
#
# push to main -> dev, at github.sha
# release: published -> prod, at the release tag
@ -40,206 +40,31 @@ permissions:
contents: read
jobs:
target:
name: Resolve target
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
environment: ${{ steps.resolve.outputs.environment }}
ref: ${{ steps.resolve.outputs.ref }}
steps:
- id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
GITHUB_REF_NAME_IN: ${{ github.ref }}
GITHUB_SHA_IN: ${{ github.sha }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
push)
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
echo "push deploys only run from main" >&2
exit 1
fi
environment=dev
ref="${GITHUB_SHA_IN}"
;;
release)
environment=prod
ref="${RELEASE_TAG}"
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
exit 1
fi
;;
workflow_dispatch)
environment="${INPUT_ENVIRONMENT}"
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
;;
*)
echo "unsupported event ${EVENT_NAME}" >&2
exit 1
;;
esac
{
echo "environment=${environment}"
echo "ref=${ref}"
} >> "${GITHUB_OUTPUT}"
echo "Deploying ${ref} to ${environment}"
deploy:
name: Deploy API to ${{ needs.target.outputs.environment }}
needs: target
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ needs.target.outputs.environment }}
concurrency:
group: deploy-api-${{ needs.target.outputs.environment }}
cancel-in-progress: false
deploy-dev:
name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
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
secrets: inherit
with:
ref: ${{ needs.target.outputs.ref }}
persist-credentials: false
environment: dev
ref: ${{ inputs.ref }}
ssm-prefix: /afterhours-shift-manager/deploy
docker-platform: linux/arm64
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
deploy-prod:
name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
permissions:
contents: read
id-token: write
secrets: inherit
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
get_param() {
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
}
CLUSTER=$(get_param /afterhours-shift-manager/deploy/cluster)
SERVICE=$(get_param /afterhours-shift-manager/deploy/service)
FAMILY=$(get_param /afterhours-shift-manager/deploy/task-family)
ECR=$(get_param /afterhours-shift-manager/deploy/ecr-repository)
CONTAINER=$(get_param /afterhours-shift-manager/deploy/container-name)
API_URL=$(get_param /afterhours-shift-manager/deploy/api-url)
{
echo "cluster=${CLUSTER}"
echo "service=${SERVICE}"
echo "family=${FAMILY}"
echo "ecr=${ECR}"
echo "container=${CONTAINER}"
echo "api_url=${API_URL}"
} >> "${GITHUB_OUTPUT}"
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Build and push image
env:
ECR: ${{ steps.deploy.outputs.ecr }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
ENVIRONMENT: ${{ needs.target.outputs.environment }}
run: |
set -euo pipefail
docker buildx build \
--platform linux/arm64 \
--build-arg "GIT_SHA=${GIT_SHA}" \
-t "${ECR}:${GIT_SHA}" \
-t "${ECR}:${ENVIRONMENT}" \
--push \
.
- name: Register task definition and update service
env:
CLUSTER: ${{ steps.deploy.outputs.cluster }}
SERVICE: ${{ steps.deploy.outputs.service }}
FAMILY: ${{ steps.deploy.outputs.family }}
CONTAINER: ${{ steps.deploy.outputs.container }}
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
aws ecs describe-task-definition \
--task-definition "${FAMILY}" \
--query taskDefinition \
--output json \
| python3 -c '
import json, os, sys
td = json.load(sys.stdin)
for key in (
"taskDefinitionArn",
"revision",
"status",
"requiresAttributes",
"compatibilities",
"registeredAt",
"registeredBy",
"deregisteredAt",
):
td.pop(key, None)
image = os.environ["IMAGE"]
sha = os.environ["GIT_SHA"]
name = os.environ["CONTAINER"]
for container in td["containerDefinitions"]:
if container["name"] != name:
continue
container["image"] = image
env = {item["name"]: item["value"] for item in container.get("environment", [])}
env["GIT_SHA"] = sha
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
container.pop("command", None)
json.dump(td, sys.stdout)
' > /tmp/task-def.json
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
aws ecs update-service \
--cluster "${CLUSTER}" \
--service "${SERVICE}" \
--task-definition "${FAMILY}:${REV}" \
--force-new-deployment \
>/dev/null
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
- name: Verify health SHA
env:
API_URL: ${{ steps.deploy.outputs.api_url }}
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
for _ in 1 2 3 4 5 6; do
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
echo "${BODY}"
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
exit 0
fi
sleep 10
done
echo "health SHA did not match ${EXPECTED_SHA}" >&2
exit 1
environment: prod
ref: ${{ github.event.release.tag_name || inputs.ref }}
ssm-prefix: /afterhours-shift-manager/deploy
docker-platform: linux/arm64
ship-gate: true

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16

View file

@ -64,8 +64,16 @@ def main() -> int:
src = _client(args.src_profile, args.region)
dst = _client(args.dst_profile, args.region)
src_id = boto3.Session(profile_name=args.src_profile).client("sts").get_caller_identity()["Account"]
dst_id = boto3.Session(profile_name=args.dst_profile).client("sts").get_caller_identity()["Account"]
src_id = (
boto3.Session(profile_name=args.src_profile)
.client("sts")
.get_caller_identity()["Account"]
)
dst_id = (
boto3.Session(profile_name=args.dst_profile)
.client("sts")
.get_caller_identity()["Account"]
)
if src_id != SRC_ACCOUNT:
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
return 2

View file

@ -35,11 +35,17 @@ _NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestExcepti
def _client(profile: str, region: str):
return boto3.Session(profile_name=profile, region_name=region).client("secretsmanager")
return boto3.Session(profile_name=profile, region_name=region).client(
"secretsmanager"
)
def _account(profile: str) -> str:
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
return (
boto3.Session(profile_name=profile)
.client("sts")
.get_caller_identity()["Account"]
)
def secret_string(client, name: str) -> str | None:
@ -72,10 +78,15 @@ def copy_secrets(src, dst, *, execute: bool) -> int:
for name in VERIFY_ONLY:
value = secret_string(dst, name)
if value is None:
print(f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr)
print(
f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr
)
rc = 1
elif not value.strip():
print(f"empty prod 3cx secret {name} (do not overwrite from mgmt)", file=sys.stderr)
print(
f"empty prod 3cx secret {name} (do not overwrite from mgmt)",
file=sys.stderr,
)
rc = 1
else:
print(f"keep existing prod secret {name}")

View file

@ -19,7 +19,9 @@ from botocore.exceptions import ClientError
SRC_ACCOUNT = "328440206208"
DST_ACCOUNT = "011934824531"
PROD_ROUTER_ARN = "arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
PROD_ROUTER_ARN = (
"arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
)
PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
@ -30,7 +32,11 @@ def _client(profile: str, region: str):
def _account(profile: str) -> str:
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
return (
boto3.Session(profile_name=profile)
.client("sts")
.get_caller_identity()["Account"]
)
def schedule_when(detail: dict) -> datetime | None:
@ -123,13 +129,17 @@ def main() -> int:
if code == "ConflictException":
print(f"exists {name}")
elif code == "ValidationException":
print(f"skip invalid {name}: {exc.response['Error'].get('Message', code)}")
print(
f"skip invalid {name}: {exc.response['Error'].get('Message', code)}"
)
skipped += 1
else:
print(f"failed {name}: {code}", file=sys.stderr)
failed += 1
print(f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}")
print(
f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}"
)
if not args.execute:
print("dry-run; pass --execute to CreateSchedule")
return 1 if failed else 0

View file

@ -58,7 +58,9 @@ def _copy_tree(src: Path, dest: Path) -> None:
if item.is_dir():
if item.name == "__pycache__":
continue
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
shutil.copytree(
item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
)
else:
shutil.copy2(item, target)

View file

@ -5,13 +5,11 @@ from __future__ import annotations
import re
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
from shared.shift_clock import (
EASTERN,
holiday_window_active,
is_today,
shift_end,
shift_ended,
shift_start,
@ -70,7 +68,9 @@ def _json_safe(value):
return value
def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str) -> dict:
def _slot_payload(
schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str
) -> dict:
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
assignees = [
{"extension": a["extension"], "name": a["name"]} for a in ctx["assignees"]
@ -86,8 +86,13 @@ def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_t
"multiplier": _json_safe(ctx.get("multiplier") or 1),
"assignees": assignees,
"mine": mine,
"canPick": (not mine) and open_slots > 0 and not shift_ended(date_str, shift_type),
"canDrop": mine and not within_drop_lock(date_str, "day" if ctx["kind"] == "holiday" else shift_type),
"canPick": (not mine)
and open_slots > 0
and not shift_ended(date_str, shift_type),
"canDrop": mine
and not within_drop_lock(
date_str, "day" if ctx["kind"] == "holiday" else shift_type
),
"latePickup": (not mine)
and open_slots > 0
and shift_started(date_str, shift_type)
@ -192,7 +197,9 @@ def _pickup_sk_parts(sk: str) -> tuple[str, str, str]:
return date_str, shift_type, ext
def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict:
def pick(
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
) -> dict:
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
@ -203,7 +210,9 @@ def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
token = effects.slack_token()
if ctx["kind"] == "holiday":
return _pick_holiday(schedule, employee, date, date_str, ctx, token)
return _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token)
return _pick_regular(
schedule, employee, date, date_str, day_name, shift_type, ctx, token
)
def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
@ -219,7 +228,9 @@ def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
return "night"
def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token) -> dict:
def _pick_regular(
schedule, employee, date, date_str, day_name, shift_type, ctx, token
) -> dict:
assignees = ctx["assignees"]
if assignees and assignees[0]["extension"] != employee["extension"]:
raise ActionError(
@ -230,14 +241,20 @@ def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx,
if shift_ended(date_str, shift_type):
raise ActionError(400, "ENDED", "That shift has already ended.")
if shift_started(date_str, shift_type):
return _request_late_pickup(schedule, employee, date_str, shift_type, False, token)
already_mine = bool(assignees) and assignees[0]["extension"] == employee["extension"]
return _request_late_pickup(
schedule, employee, date_str, shift_type, False, token
)
already_mine = (
bool(assignees) and assignees[0]["extension"] == employee["extension"]
)
if not already_mine:
claimed = schedule.claim_open_shift(
date_str, employee["extension"], employee["name"], shift_type
)
if not claimed:
raise ActionError(409, "TAKEN", "That shift was just picked up by someone else.")
raise ActionError(
409, "TAKEN", "That shift was just picked up by someone else."
)
effects.maybe_repoint_today(date_str, shift_type, employee["extension"])
effects.post_shift_change(
token,
@ -278,7 +295,9 @@ def _pick_holiday(schedule, employee, date, date_str, ctx, token) -> dict:
return {"ok": True, "latePickup": False, "message": "Holiday slot claimed."}
def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, token) -> dict:
def _request_late_pickup(
schedule, employee, date_str, shift_type, is_holiday, token
) -> dict:
schedule.create_pickup_request(
date_str,
shift_type,
@ -303,7 +322,9 @@ def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, t
}
def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict:
def drop(
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
) -> dict:
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
@ -317,7 +338,9 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
elif target in ("day", "night"):
target = target
else:
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.")
raise ActionError(
400, "INVALID_SHIFT", "Shift type must be day, night, or holiday."
)
if target == "day" and "holiday" in held:
target = "holiday"
if target not in held:
@ -450,7 +473,11 @@ def swap(
def respond_swap(
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str, accept: bool
schedule: ShiftSchedule,
employee: dict,
date_str: str,
shift_type: str,
accept: bool,
) -> dict:
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
@ -474,11 +501,16 @@ def respond_swap(
return {"ok": True, "message": "Swap declined."}
if shift_started(date_str, shift_type):
schedule.clear_swap(date_str, shift_type)
raise ActionError(409, "EXPIRED", "This swap expired because the shift has started.")
raise ActionError(
409, "EXPIRED", "This swap expired because the shift has started."
)
is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
if is_holiday:
moved = schedule.swap_holiday_assignee(
date_str, swap_row["requester_ext"], swap_row["target_ext"], employee["name"]
date_str,
swap_row["requester_ext"],
swap_row["target_ext"],
employee["name"],
)
if not moved:
schedule.clear_swap(date_str, shift_type)
@ -583,11 +615,15 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
raise ActionError(400, "PAST_SHIFT", "You can't schedule a holiday in the past.")
raise ActionError(
400, "PAST_SHIFT", "You can't schedule a holiday in the past."
)
try:
slot_count = int(slots)
except (TypeError, ValueError) as exc:
raise ActionError(400, "INVALID_SLOTS", "Slots must be a whole number.") from exc
raise ActionError(
400, "INVALID_SLOTS", "Slots must be a whole number."
) from exc
if slot_count < 1:
raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.")
name = (label or "").strip()
@ -620,7 +656,11 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
effects.activate_holiday_inline(schedule, date_str)
holiday = schedule.get_holiday(date_str)
effects.post_holiday_added(
token, date_str, name, slot_count, holiday["multiplier"] if holiday else multiplier
token,
date_str,
name,
slot_count,
holiday["multiplier"] if holiday else multiplier,
)
effects.refresh_schedule_post(schedule, token)
return {"ok": True, "message": f"Scheduled {name}."}
@ -639,7 +679,9 @@ def admin_holiday_remove(schedule, employee, date_str) -> dict:
return {"ok": True, "message": "Holiday removed."}
def admin_pickup(schedule, employee, date_str, shift_type, extension, approve: bool) -> dict:
def admin_pickup(
schedule, employee, date_str, shift_type, extension, approve: bool
) -> dict:
require_admin(schedule, employee)
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)

View file

@ -293,7 +293,9 @@ class ShiftSchedule:
def list_pending_swaps(self) -> list[dict]:
resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP"))
return [item for item in resp.get("Items", []) if item.get("status") == "pending"]
return [
item for item in resp.get("Items", []) if item.get("status") == "pending"
]
# ── Late-pickup requests ─────────────────────────────────────────────
@ -381,7 +383,9 @@ class ShiftSchedule:
def list_pending_pickup_requests(self) -> list[dict]:
resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST"))
return [item for item in resp.get("Items", []) if item.get("status") == "pending"]
return [
item for item in resp.get("Items", []) if item.get("status") == "pending"
]
# ── Holidays ────────────────────────────────────────────────────────

View file

@ -1,8 +1,9 @@
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of
# org reusable cd-hcp-fargate.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-api.yaml at main and v*.
# to Environments dev and prod. job_workflow_ref matches the reusable at any ref.
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
@ -31,8 +32,7 @@ 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-api.yaml@refs/heads/${var.github_deploy_branch}",
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
]
}
}

View file

@ -76,12 +76,6 @@ variable "github_repo" {
default = "Sea-Haven-Industries/afterhours-shift-manager"
}
variable "github_deploy_branch" {
description = "Git branch pinned in job_workflow_ref for the deploy role."
type = string
default = "main"
}
variable "checkcomponents_queue_url" {
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
type = string

View file

@ -77,13 +77,13 @@ def test_stack_owns_a_vpc_instead_of_looking_up_default():
assert "aws_vpc.this.id" in ecs
assert "aws_subnet.public[*].id" in ecs
assert "ec2:CreateVpc" in HCP_IAM
assert "sid = \"RefreshVpc\"" in HCP_IAM
assert 'sid = "RefreshVpc"' in HCP_IAM
assert "afterhours-shift-manager-ecs" in HCP_IAM
assert "hcptf_apply_ecs" in HCP_IAM
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 'sid = "CreateElbAndEcsServiceLinkedRoles"' in HCP_IAM
assert "iam:CreateServiceLinkedRole" in HCP_IAM
@ -101,10 +101,13 @@ def test_ecs_task_boundary_uses_static_arns():
def test_deploy_api_workflow_exists():
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api
assert "linux/arm64" in deploy_api
pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
assert deploy_api.count(pin) == 2
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
assert "docker-platform: linux/arm64" in deploy_api
assert "ship-gate: true" in deploy_api
assert "gh release create" in deploy_api
assert "needs.target.outputs.environment" not in deploy_api
def test_in_repo_hcptf_roles():
@ -116,10 +119,12 @@ def test_in_repo_hcptf_roles():
def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI
assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "name: ci-complete" in CI
assert "pytest" in CI
assert "terraform fmt -check" in CI
assert "terraform init -backend=false" in CI
assert "terraform validate" in CI
assert "terraform fmt -check" not in CI
assert "openapi:lint" in CI
assert "npm ci" in CI
@ -138,7 +143,7 @@ def test_openapi_uses_redocly_recommended():
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
assert '"403":' in health
assert '"400":' not in health
assert 'root: openapi.yaml' in redocly
assert "root: openapi.yaml" in redocly
assert '"openapi:lint"' in package
assert '"@redocly/cli": "2.52.1"' in package
assert "openapi: 3.1.0" in spec
@ -191,8 +196,8 @@ def test_github_deploy_trust_covers_image_only():
assert "environment:prod" in iam or "environment:prod" in LOCALS
assert "environment:dev" in iam or "environment:dev" in LOCALS
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-api.yaml@" not in iam
assert "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in iam
assert "ecs:ListTasks" in iam
@ -205,10 +210,10 @@ def test_plan_refresh_includes_provider6_s3_gets():
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 "${local.api_url}/slack/events" in outputs
assert "value = local.api_url" in outputs
assert "output \"vpc_id\"" in outputs
assert "output \"public_subnet_ids\"" in outputs
assert 'output "vpc_id"' in outputs
assert 'output "public_subnet_ids"' in outputs
assert "aws_vpc.this.id" in outputs

View file

@ -60,9 +60,7 @@ def test_adds_removes_and_preserves_links(
def test_renames_changed_member_preserving_link(
rostersync_app, schedule, seed, env, fake_3cx
):
seed.roster(
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
)
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}])
result = rostersync_app.handler({"force": True}, None)

View file

@ -64,7 +64,9 @@ def test_execute_puts_into_empty_terraform_shells():
rc = mod.copy_secrets(src, dst, execute=True)
assert rc == 0
assert [name for name, _ in dst.puts] == list(mod.COPY)
assert all(value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts)
assert all(
value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts
)
def test_skip_populated_copy_targets_and_never_write_3cx():

View file

@ -29,9 +29,9 @@ def test_schedule_when_parses_at_expression_in_eastern():
"ScheduleExpressionTimezone": "America/New_York",
}
when = mod.schedule_when(detail)
expected = datetime(2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")).astimezone(
timezone.utc
)
expected = datetime(
2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")
).astimezone(timezone.utc)
assert when == expected

View file

@ -33,9 +33,7 @@ def quiet_slack(monkeypatch):
def _alice(schedule, seed):
seed.roster(
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
)
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
seed.roster("115", "Bob", slack_user_id="U_BOB", email="bob@seahavenind.com")
return schedule.get_employee_by_extension("114")
@ -143,10 +141,9 @@ def test_admin_open_repoints_active_shift_to_fallback(
monkeypatch.setattr(
effects,
"maybe_repoint_today",
lambda date, shift_type, extension: calls.append(
(date, shift_type, extension)
)
or True,
lambda date, shift_type, extension: (
calls.append((date, shift_type, extension)) or True
),
)
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
result = admin_open(schedule, employee, "2026-06-10", "night")
@ -164,10 +161,9 @@ def test_admin_clear_repoints_active_shift_to_resolved_holder(
monkeypatch.setattr(
effects,
"maybe_repoint_today",
lambda date, shift_type, extension: calls.append(
(date, shift_type, extension)
)
or True,
lambda date, shift_type, extension: (
calls.append((date, shift_type, extension)) or True
),
)
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
result = admin_clear(schedule, employee, "2026-06-10", "night")

View file

@ -226,7 +226,9 @@ class TestRoster:
assert emp["email"] == "Alice@Seahavenind.com"
def test_upsert_without_email_preserves_existing_email(self, schedule, seed):
seed.roster("114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com")
seed.roster(
"114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com"
)
schedule.upsert_roster_entry("114", "Alicia", "U_ALICE")
emp = schedule.get_employee_by_extension("114")
assert emp["email"] == "alice@seahavenind.com"