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: on:
pull_request: pull_request:
branches: [main] branches: [main, hotfix/**, release/**]
merge_group: merge_group:
push:
branches: [hotfix/**, release/**]
permissions: permissions:
contents: read contents: read
jobs: jobs:
pytest: autofix:
name: Pytest 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 runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
@ -37,33 +57,16 @@ jobs:
run: pytest run: pytest
terraform: terraform:
name: Terraform needs: autofix
runs-on: ubuntu-latest if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
timeout-minutes: 15 uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false terraform-version: "1.16.0"
- 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
openapi: openapi:
name: OpenAPI name: OpenAPI
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
@ -82,35 +85,22 @@ jobs:
- name: Lint OpenAPI - name: Lint OpenAPI
run: npm run openapi:lint run: npm run openapi:lint
ci: ci-complete:
name: ci / ci name: ci-complete
needs: [pytest, terraform, openapi] needs: [autofix, lint, test, terraform, openapi]
if: ${{ always() && !cancelled() }} if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Check jobs - name: Require portions
env: env:
PYTEST_RESULT: ${{ needs.pytest.result }} LINT: ${{ needs.lint.result }}
TERRAFORM_RESULT: ${{ needs.terraform.result }} TEST: ${{ needs.test.result }}
OPENAPI_RESULT: ${{ needs.openapi.result }} TERRAFORM: ${{ needs.terraform.result }}
OPENAPI: ${{ needs.openapi.result }}
run: | run: |
set -euo pipefail set -euo pipefail
fail=0 test "${LINT}" = success
check() { test "${TEST}" = success
local name="$1" test "${TERRAFORM}" = success
local result="$2" test "${OPENAPI}" = success
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}"

View file

@ -7,4 +7,4 @@ permissions:
jobs: jobs:
review: 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 name: Deploy API
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes # Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes to ECR,
# to ECR, and registers a new task definition. Terraform owns the cluster, # and registers a new task definition. Terraform owns the cluster, service,
# service, ALB, and ignores container_definitions / task_definition. # ALB, and ignores container_definitions / task_definition.
# #
# push to main -> dev, at github.sha # push to main -> dev, at github.sha
# release: published -> prod, at the release tag # release: published -> prod, at the release tag
@ -40,206 +40,31 @@ permissions:
contents: read contents: read
jobs: jobs:
target: deploy-dev:
name: Resolve target name: Deploy API to dev
runs-on: ubuntu-latest if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
timeout-minutes: 5 uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
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
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
env: secrets: inherit
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
ref: ${{ needs.target.outputs.ref }} environment: dev
persist-credentials: false ref: ${{ inputs.ref }}
ssm-prefix: /afterhours-shift-manager/deploy
docker-platform: linux/arm64
- name: Resolve commit deploy-prod:
id: commit name: Deploy API to prod
run: | if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
set -euo pipefail uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
sha="$(git rev-parse HEAD)" permissions:
echo "sha=${sha}" >> "${GITHUB_OUTPUT}" contents: read
echo "Building ${sha}" id-token: write
secrets: inherit
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with: with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} environment: prod
aws-region: us-east-1 ref: ${{ github.event.release.tag_name || inputs.ref }}
audience: sts.amazonaws.com ssm-prefix: /afterhours-shift-manager/deploy
docker-platform: linux/arm64
- name: Get deploy parameters ship-gate: true
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

View file

@ -10,4 +10,4 @@ permissions:
jobs: jobs:
label: 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) src = _client(args.src_profile, args.region)
dst = _client(args.dst_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"] src_id = (
dst_id = boto3.Session(profile_name=args.dst_profile).client("sts").get_caller_identity()["Account"] 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: if src_id != SRC_ACCOUNT:
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr) print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
return 2 return 2

View file

@ -35,11 +35,17 @@ _NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestExcepti
def _client(profile: str, region: str): 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: 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: 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: for name in VERIFY_ONLY:
value = secret_string(dst, name) value = secret_string(dst, name)
if value is None: 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 rc = 1
elif not value.strip(): 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 rc = 1
else: else:
print(f"keep existing prod secret {name}") print(f"keep existing prod secret {name}")

View file

@ -19,7 +19,9 @@ from botocore.exceptions import ClientError
SRC_ACCOUNT = "328440206208" SRC_ACCOUNT = "328440206208"
DST_ACCOUNT = "011934824531" 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" PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
PREFIXES = ("holiday-activate-", "holiday-deactivate-") PREFIXES = ("holiday-activate-", "holiday-deactivate-")
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$") _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: 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: def schedule_when(detail: dict) -> datetime | None:
@ -123,13 +129,17 @@ def main() -> int:
if code == "ConflictException": if code == "ConflictException":
print(f"exists {name}") print(f"exists {name}")
elif code == "ValidationException": 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 skipped += 1
else: else:
print(f"failed {name}: {code}", file=sys.stderr) print(f"failed {name}: {code}", file=sys.stderr)
failed += 1 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: if not args.execute:
print("dry-run; pass --execute to CreateSchedule") print("dry-run; pass --execute to CreateSchedule")
return 1 if failed else 0 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.is_dir():
if item.name == "__pycache__": if item.name == "__pycache__":
continue continue
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) shutil.copytree(
item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
)
else: else:
shutil.copy2(item, target) shutil.copy2(item, target)

View file

@ -5,13 +5,11 @@ from __future__ import annotations
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
from shared.shift_clock import ( from shared.shift_clock import (
EASTERN, EASTERN,
holiday_window_active, holiday_window_active,
is_today,
shift_end, shift_end,
shift_ended, shift_ended,
shift_start, shift_start,
@ -70,7 +68,9 @@ def _json_safe(value):
return 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) ctx = schedule.get_shift_context(date_str, day_name, shift_type)
assignees = [ assignees = [
{"extension": a["extension"], "name": a["name"]} for a in ctx["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), "multiplier": _json_safe(ctx.get("multiplier") or 1),
"assignees": assignees, "assignees": assignees,
"mine": mine, "mine": mine,
"canPick": (not mine) and open_slots > 0 and not shift_ended(date_str, shift_type), "canPick": (not mine)
"canDrop": mine and not within_drop_lock(date_str, "day" if ctx["kind"] == "holiday" else shift_type), 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) "latePickup": (not mine)
and open_slots > 0 and open_slots > 0
and shift_started(date_str, shift_type) 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 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 = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
if date_str < datetime.now(EASTERN).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() token = effects.slack_token()
if ctx["kind"] == "holiday": if ctx["kind"] == "holiday":
return _pick_holiday(schedule, employee, date, date_str, ctx, token) 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: 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" 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"] assignees = ctx["assignees"]
if assignees and assignees[0]["extension"] != employee["extension"]: if assignees and assignees[0]["extension"] != employee["extension"]:
raise ActionError( 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): if shift_ended(date_str, shift_type):
raise ActionError(400, "ENDED", "That shift has already ended.") raise ActionError(400, "ENDED", "That shift has already ended.")
if shift_started(date_str, shift_type): if shift_started(date_str, shift_type):
return _request_late_pickup(schedule, employee, date_str, shift_type, False, token) return _request_late_pickup(
already_mine = bool(assignees) and assignees[0]["extension"] == employee["extension"] schedule, employee, date_str, shift_type, False, token
)
already_mine = (
bool(assignees) and assignees[0]["extension"] == employee["extension"]
)
if not already_mine: if not already_mine:
claimed = schedule.claim_open_shift( claimed = schedule.claim_open_shift(
date_str, employee["extension"], employee["name"], shift_type date_str, employee["extension"], employee["name"], shift_type
) )
if not claimed: 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.maybe_repoint_today(date_str, shift_type, employee["extension"])
effects.post_shift_change( effects.post_shift_change(
token, 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."} 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( schedule.create_pickup_request(
date_str, date_str,
shift_type, 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 = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
if date_str < datetime.now(EASTERN).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"): elif target in ("day", "night"):
target = target target = target
else: 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: if target == "day" and "holiday" in held:
target = "holiday" target = "holiday"
if target not in held: if target not in held:
@ -450,7 +473,11 @@ def swap(
def respond_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: ) -> dict:
date = _parse_date(date_str) date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
@ -474,11 +501,16 @@ def respond_swap(
return {"ok": True, "message": "Swap declined."} return {"ok": True, "message": "Swap declined."}
if shift_started(date_str, shift_type): if shift_started(date_str, shift_type):
schedule.clear_swap(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 is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
if is_holiday: if is_holiday:
moved = schedule.swap_holiday_assignee( 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: if not moved:
schedule.clear_swap(date_str, shift_type) 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 = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
if date_str < datetime.now(EASTERN).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: try:
slot_count = int(slots) slot_count = int(slots)
except (TypeError, ValueError) as exc: 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: if slot_count < 1:
raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.") raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.")
name = (label or "").strip() 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) effects.activate_holiday_inline(schedule, date_str)
holiday = schedule.get_holiday(date_str) holiday = schedule.get_holiday(date_str)
effects.post_holiday_added( 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) effects.refresh_schedule_post(schedule, token)
return {"ok": True, "message": f"Scheduled {name}."} 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."} 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) require_admin(schedule, employee)
date = _parse_date(date_str) date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)

View file

@ -293,7 +293,9 @@ class ShiftSchedule:
def list_pending_swaps(self) -> list[dict]: def list_pending_swaps(self) -> list[dict]:
resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP")) 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 ───────────────────────────────────────────── # ── Late-pickup requests ─────────────────────────────────────────────
@ -381,7 +383,9 @@ class ShiftSchedule:
def list_pending_pickup_requests(self) -> list[dict]: def list_pending_pickup_requests(self) -> list[dict]:
resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST")) 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 ──────────────────────────────────────────────────────── # ── 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 # One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
# to Environments dev and prod (immutable and classic subject forms) and # to Environments dev and prod. job_workflow_ref matches the reusable at any ref.
# job_workflow_ref to deploy-api.yaml at main and v*. # AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
data "aws_iam_policy_document" "github_deploy_assume" { data "aws_iam_policy_document" "github_deploy_assume" {
statement { statement {
@ -31,8 +32,7 @@ data "aws_iam_policy_document" "github_deploy_assume" {
test = "StringLike" test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref" variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [ values = [
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}", "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
] ]
} }
} }

View file

@ -76,12 +76,6 @@ variable "github_repo" {
default = "Sea-Haven-Industries/afterhours-shift-manager" 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" { variable "checkcomponents_queue_url" {
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage." description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
type = string 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_vpc.this.id" in ecs
assert "aws_subnet.public[*].id" in ecs assert "aws_subnet.public[*].id" in ecs
assert "ec2:CreateVpc" in HCP_IAM 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 "afterhours-shift-manager-ecs" in HCP_IAM
assert "hcptf_apply_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_policy" "hcptf_apply_ecs"' in HCP_IAM
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not 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 "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 assert "iam:CreateServiceLinkedRole" in HCP_IAM
@ -101,10 +101,13 @@ def test_ecs_task_boundary_uses_static_arns():
def test_deploy_api_workflow_exists(): def test_deploy_api_workflow_exists():
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text() deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api assert deploy_api.count(pin) == 2
assert "linux/arm64" in deploy_api 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 "gh release create" in deploy_api
assert "needs.target.outputs.environment" not in deploy_api
def test_in_repo_hcptf_roles(): def test_in_repo_hcptf_roles():
@ -116,10 +119,12 @@ def test_in_repo_hcptf_roles():
def test_ci_runs_pytest_and_terraform_validate(): def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI 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 "pytest" in CI
assert "terraform fmt -check" in CI assert "terraform fmt -check" not in CI
assert "terraform init -backend=false" in CI
assert "terraform validate" in CI
assert "openapi:lint" in CI assert "openapi:lint" in CI
assert "npm ci" 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] health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
assert '"403":' in health assert '"403":' in health
assert '"400":' not 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 '"openapi:lint"' in package
assert '"@redocly/cli": "2.52.1"' in package assert '"@redocly/cli": "2.52.1"' in package
assert "openapi: 3.1.0" in spec 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:prod" in iam or "environment:prod" in LOCALS
assert "environment:dev" in iam or "environment:dev" in LOCALS assert "environment:dev" in iam or "environment:dev" in LOCALS
assert "deploy.yaml@" not in iam assert "deploy.yaml@" not in iam
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam assert "deploy-api.yaml@" not in iam
assert "deploy-api.yaml@refs/tags/v*" in iam assert "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in iam
assert "ecs:ListTasks" 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(): def test_origins_use_fargate_url():
outputs = (TERRAFORM / "outputs.tf").read_text() outputs = (TERRAFORM / "outputs.tf").read_text()
assert "aws_apigatewayv2_api.http" not in outputs 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 "value = local.api_url" in outputs
assert "output \"vpc_id\"" in outputs assert 'output "vpc_id"' in outputs
assert "output \"public_subnet_ids\"" in outputs assert 'output "public_subnet_ids"' in outputs
assert "aws_vpc.this.id" 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( def test_renames_changed_member_preserving_link(
rostersync_app, schedule, seed, env, fake_3cx rostersync_app, schedule, seed, env, fake_3cx
): ):
seed.roster( seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
)
fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}]) fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}])
result = rostersync_app.handler({"force": True}, None) 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) rc = mod.copy_secrets(src, dst, execute=True)
assert rc == 0 assert rc == 0
assert [name for name, _ in dst.puts] == list(mod.COPY) 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(): 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", "ScheduleExpressionTimezone": "America/New_York",
} }
when = mod.schedule_when(detail) when = mod.schedule_when(detail)
expected = datetime(2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")).astimezone( expected = datetime(
timezone.utc 2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")
) ).astimezone(timezone.utc)
assert when == expected assert when == expected

View file

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

View file

@ -226,7 +226,9 @@ class TestRoster:
assert emp["email"] == "Alice@Seahavenind.com" assert emp["email"] == "Alice@Seahavenind.com"
def test_upsert_without_email_preserves_existing_email(self, schedule, seed): 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") schedule.upsert_roster_entry("114", "Alicia", "U_ALICE")
emp = schedule.get_employee_by_extension("114") emp = schedule.get_employee_by_extension("114")
assert emp["email"] == "alice@seahavenind.com" assert emp["email"] == "alice@seahavenind.com"