mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 03:43:10 +00:00
feat(ci): add HCP reusable workflows and drop Mergify
Callers can pin org Fargate/SPA CD and parallel CI instead of copying per-repo deploy jobs.
This commit is contained in:
parent
22c47f924f
commit
9e3d0d4430
17 changed files with 1465 additions and 55 deletions
318
.github/workflows/cd-hcp-fargate.yaml
vendored
Normal file
318
.github/workflows/cd-hcp-fargate.yaml
vendored
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
name: CD — HCP Fargate
|
||||
|
||||
# Reusable Fargate image CD for HCP app repos. The caller owns triggers and
|
||||
# passes `environment` as a `with:` input. This job owns `environment:`,
|
||||
# concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. GitHub rejects `environment:`
|
||||
# beside `uses:`.
|
||||
#
|
||||
# Caller example (one job per GitHub Environment):
|
||||
# jobs:
|
||||
# deploy-prod:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@<sha> # vX.Y.Z
|
||||
# permissions: { contents: read, id-token: write }
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# environment: prod
|
||||
# ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
# ssm-prefix: /meal-order-manager/deploy
|
||||
# docker-platform: linux/amd64
|
||||
# ship-gate: true
|
||||
#
|
||||
# Nothing here creates an HCP run. Terraform owns the cluster, service, ALB,
|
||||
# and ignores container_definitions / task_definition.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: "GitHub Environment to deploy to (dev, staging, prod)"
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: "Git ref to build. Empty means github.sha."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
ssm-prefix:
|
||||
description: "SSM prefix for deploy parameters (e.g. /meal-order-manager/deploy)"
|
||||
type: string
|
||||
required: true
|
||||
docker-platform:
|
||||
description: "docker build --platform value"
|
||||
type: string
|
||||
required: false
|
||||
default: "linux/amd64"
|
||||
health-path:
|
||||
description: "Health endpoint path appended to SSM api-url"
|
||||
type: string
|
||||
required: false
|
||||
default: "/api/health"
|
||||
ship-gate:
|
||||
description: "Require the ref to be on main or a legal hotfix/release tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
extra-task-env:
|
||||
description: "JSON object of extra container environment keys to merge (e.g. {\"SENTRY_DSN_PARAM\":\"/app/sentry-dsn\"})"
|
||||
type: string
|
||||
required: false
|
||||
default: "{}"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Fargate to ${{ inputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: ${{ inputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Ship-gate
|
||||
if: ${{ inputs.ship-gate }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
ENVIRONMENT: ${{ inputs.environment }}
|
||||
HEAD_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)"
|
||||
if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then
|
||||
echo "ship-gate: ${INPUT_REF} is ${status} relative to main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path"
|
||||
|
||||
TAG="${INPUT_REF}"
|
||||
if [[ ! "${TAG}" =~ ^v ]]; then
|
||||
TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ "${ENVIRONMENT}" = "staging" ]; then
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$'
|
||||
else
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
fi
|
||||
|
||||
if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then
|
||||
echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATTERN TAG
|
||||
PREV="$(
|
||||
gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c '
|
||||
import os, re, sys
|
||||
pattern = re.compile(os.environ["PATTERN"])
|
||||
current = os.environ["TAG"]
|
||||
tags = [
|
||||
line.strip()
|
||||
for line in sys.stdin
|
||||
if pattern.fullmatch(line.strip()) and line.strip() != current
|
||||
]
|
||||
def key(tag):
|
||||
body = tag[1:]
|
||||
core = body.split("-", 1)[0]
|
||||
return tuple(int(part) for part in core.split("."))
|
||||
tags.sort(key=key)
|
||||
print(tags[-1] if tags else "")
|
||||
'
|
||||
)"
|
||||
|
||||
if [ -z "${PREV}" ]; then
|
||||
echo "ship-gate: no previous matching release tag; hotfix path requires a prior ${PATTERN} release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ff_status="$(gh api "repos/${REPO}/compare/${PREV}...${TAG}" --jq .status)"
|
||||
if [ "${ff_status}" != "ahead" ]; then
|
||||
echo "ship-gate: ${TAG} is not a fast-forward of ${PREV} (status: ${ff_status})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
from_train=false
|
||||
TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)"
|
||||
if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then
|
||||
from_train=true
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true
|
||||
if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then
|
||||
from_train=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})"
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Get deploy parameters
|
||||
id: deploy
|
||||
env:
|
||||
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
get_param() {
|
||||
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
|
||||
}
|
||||
prefix="${SSM_PREFIX%/}"
|
||||
CLUSTER=$(get_param "${prefix}/cluster")
|
||||
SERVICE=$(get_param "${prefix}/service")
|
||||
FAMILY=$(get_param "${prefix}/task-family")
|
||||
ECR=$(get_param "${prefix}/ecr-repository")
|
||||
CONTAINER=$(get_param "${prefix}/container-name")
|
||||
API_URL=$(get_param "${prefix}/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@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- 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: ${{ inputs.environment }}
|
||||
DOCKER_PLATFORM: ${{ inputs.docker-platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx build \
|
||||
--platform "${DOCKER_PLATFORM}" \
|
||||
--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 }}
|
||||
EXTRA_TASK_ENV: ${{ inputs.extra-task-env }}
|
||||
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"]
|
||||
extra_raw = os.environ.get("EXTRA_TASK_ENV") or "{}"
|
||||
extra_env = json.loads(extra_raw)
|
||||
if not isinstance(extra_env, dict):
|
||||
sys.exit("extra-task-env must be a JSON object")
|
||||
found = False
|
||||
for container in td["containerDefinitions"]:
|
||||
if container["name"] != name:
|
||||
continue
|
||||
found = True
|
||||
container["image"] = image
|
||||
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
||||
env["GIT_SHA"] = sha
|
||||
for key, value in extra_env.items():
|
||||
env[str(key)] = str(value)
|
||||
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
||||
container.pop("command", None)
|
||||
if not found:
|
||||
sys.exit(f"container {name} not in task definition")
|
||||
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 }}
|
||||
HEALTH_PATH: ${{ inputs.health-path }}
|
||||
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
path="${HEALTH_PATH}"
|
||||
case "${path}" in
|
||||
/*) ;;
|
||||
*) path="/${path}" ;;
|
||||
esac
|
||||
url="${API_URL%/}${path}"
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
BODY="$(curl -fsS "${url}" || 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
|
||||
303
.github/workflows/cd-hcp-spa.yaml
vendored
Normal file
303
.github/workflows/cd-hcp-spa.yaml
vendored
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
name: CD — HCP SPA
|
||||
|
||||
# Reusable CloudFront/S3 SPA CD for HCP app repos. The caller owns triggers
|
||||
# and passes `environment` as a `with:` input. This job owns `environment:`,
|
||||
# concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. GitHub rejects `environment:`
|
||||
# beside `uses:`.
|
||||
#
|
||||
# Build env comes from the GitHub Environment: every `vars.VITE_*` value plus
|
||||
# `secrets.SENTRY_AUTH_TOKEN`. Pass `required-vite-vars` for keys that must
|
||||
# be set before `npm run build`.
|
||||
#
|
||||
# Caller example (one job per GitHub Environment):
|
||||
# jobs:
|
||||
# deploy-prod:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@<sha> # vX.Y.Z
|
||||
# permissions: { contents: read, id-token: write }
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# environment: prod
|
||||
# ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
# ssm-prefix: /internal-portal/deploy
|
||||
# ship-gate: true
|
||||
#
|
||||
# Nothing here creates an HCP run. Terraform owns the bucket and distribution.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: "GitHub Environment to deploy to (dev, staging, prod)"
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: "Git ref to build. Empty means github.sha."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
ssm-prefix:
|
||||
description: "SSM prefix for deploy parameters (e.g. /internal-portal/deploy)"
|
||||
type: string
|
||||
required: true
|
||||
ship-gate:
|
||||
description: "Require the ref to be on main or a legal hotfix/release tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
required-vite-vars:
|
||||
description: "Comma-separated VITE_* GitHub Environment variable names that must be set"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy SPA to ${{ inputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: ${{ inputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-${{ inputs.ssm-prefix }}-${{ inputs.environment }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Ship-gate
|
||||
if: ${{ inputs.ship-gate }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }}
|
||||
ENVIRONMENT: ${{ inputs.environment }}
|
||||
HEAD_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)"
|
||||
if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then
|
||||
echo "ship-gate: ${INPUT_REF} is ${status} relative to main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path"
|
||||
|
||||
TAG="${INPUT_REF}"
|
||||
if [[ ! "${TAG}" =~ ^v ]]; then
|
||||
TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ "${ENVIRONMENT}" = "staging" ]; then
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$'
|
||||
else
|
||||
PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
fi
|
||||
|
||||
if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then
|
||||
echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATTERN TAG
|
||||
PREV="$(
|
||||
gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c '
|
||||
import os, re, sys
|
||||
pattern = re.compile(os.environ["PATTERN"])
|
||||
current = os.environ["TAG"]
|
||||
tags = [
|
||||
line.strip()
|
||||
for line in sys.stdin
|
||||
if pattern.fullmatch(line.strip()) and line.strip() != current
|
||||
]
|
||||
def key(tag):
|
||||
body = tag[1:]
|
||||
core = body.split("-", 1)[0]
|
||||
return tuple(int(part) for part in core.split("."))
|
||||
tags.sort(key=key)
|
||||
print(tags[-1] if tags else "")
|
||||
'
|
||||
)"
|
||||
|
||||
if [ -z "${PREV}" ]; then
|
||||
echo "ship-gate: no previous matching release tag; hotfix path requires a prior ${PATTERN} release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ff_status="$(gh api "repos/${REPO}/compare/${PREV}...${TAG}" --jq .status)"
|
||||
if [ "${ff_status}" != "ahead" ]; then
|
||||
echo "ship-gate: ${TAG} is not a fast-forward of ${PREV} (status: ${ff_status})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
from_train=false
|
||||
TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)"
|
||||
if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then
|
||||
from_train=true
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true
|
||||
if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then
|
||||
from_train=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${from_train}" = false ]; then
|
||||
echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Build SPA
|
||||
env:
|
||||
VARS_JSON: ${{ toJSON(vars) }}
|
||||
REQUIRED_VITE_VARS: ${{ inputs.required-vite-vars }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
TARGET_ENVIRONMENT: ${{ inputs.environment }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -c '
|
||||
import json, os, shlex, sys
|
||||
required = [s.strip() for s in os.environ.get("REQUIRED_VITE_VARS", "").split(",") if s.strip()]
|
||||
vars_obj = json.loads(os.environ["VARS_JSON"])
|
||||
missing = [key for key in required if not vars_obj.get(key)]
|
||||
if missing:
|
||||
print("Missing required GitHub Environment vars: " + ", ".join(missing), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open("/tmp/vite.env", "w", encoding="utf-8") as fh:
|
||||
for key, value in vars_obj.items():
|
||||
if key.startswith("VITE_") and value:
|
||||
fh.write(f"export {key}={shlex.quote(str(value))}\n")
|
||||
'
|
||||
# shellcheck source=/dev/null
|
||||
source /tmp/vite.env
|
||||
export VITE_SENTRY_ENVIRONMENT="${TARGET_ENVIRONMENT}"
|
||||
export VITE_SENTRY_RELEASE="${GIT_SHA}"
|
||||
npm ci
|
||||
npm run build
|
||||
test -f dist/index.html
|
||||
find dist -name '*.map' -delete
|
||||
if find dist -name '*.map' | grep -q .; then
|
||||
echo "SPA source maps must not ship in dist/" >&2
|
||||
exit 1
|
||||
fi
|
||||
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
|
||||
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||
echo "dist/index.html sha256=${index_sha}"
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Get deploy parameters
|
||||
id: deploy
|
||||
env:
|
||||
SSM_PREFIX: ${{ inputs.ssm-prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="${SSM_PREFIX%/}"
|
||||
BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text)
|
||||
DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text)
|
||||
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
|
||||
origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \
|
||||
--query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')"
|
||||
if [ -n "${origin_paths}" ]; then
|
||||
echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2
|
||||
echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "bucket=${BUCKET}"
|
||||
echo "distribution_id=${DIST_ID}"
|
||||
echo "site_url=https://${DOMAIN}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Sync dist/ to the bucket root
|
||||
env:
|
||||
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
|
||||
|
||||
- name: Invalidate CloudFront
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalidation_id="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DISTRIBUTION_ID}" \
|
||||
--paths "/*" \
|
||||
--query Invalidation.Id --output text)"
|
||||
echo "Invalidation ${invalidation_id} created; waiting"
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DISTRIBUTION_ID}" \
|
||||
--id "${invalidation_id}"
|
||||
|
||||
- name: Verify served release
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
SITE_URL: ${{ steps.deploy.outputs.site_url }}
|
||||
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
sha256_of() {
|
||||
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
|
||||
}
|
||||
last_status="Unknown"
|
||||
last_hash="Unknown"
|
||||
for attempt in $(seq 1 40); do
|
||||
last_status="$(aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --query Distribution.Status --output text)"
|
||||
if last_hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [ -n "${last_hash}" ]; then
|
||||
:
|
||||
else
|
||||
last_hash="unreachable"
|
||||
fi
|
||||
echo "poll ${attempt}/40: status=${last_status} served_sha256=${last_hash}"
|
||||
if [ "${last_status}" = "Deployed" ] && [ "${last_hash}" = "${EXPECTED_INDEX_SHA256}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2
|
||||
exit 1
|
||||
181
.github/workflows/ci-autofix.yaml
vendored
Normal file
181
.github/workflows/ci-autofix.yaml
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
name: CI — Autofix
|
||||
|
||||
# Convenience formatter on pull_request. Keeps format:check / lint in the
|
||||
# parallel portions as the fail-closed gate. GITHUB_TOKEN commits do not
|
||||
# retrigger workflows, so this mints a GitHub App token.
|
||||
#
|
||||
# Skip forks, merge_group, push, and when the actor is the App (no loop).
|
||||
# If the tree is dirty, commit `style: apply formatter` and push to the PR
|
||||
# head, then set output committed=true so the caller skips portions on SHA_old.
|
||||
# Do not --no-verify. Do not push to main.
|
||||
#
|
||||
# Caller example:
|
||||
# jobs:
|
||||
# autofix:
|
||||
# if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@<sha> # vX.Y.Z
|
||||
# permissions: { contents: write }
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# format-command: npm run format
|
||||
# lint-fix-command: npm run lint -- --fix
|
||||
#
|
||||
# Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
format-command:
|
||||
description: "Write formatter command (e.g. npm run format, ruff format .)"
|
||||
type: string
|
||||
required: true
|
||||
lint-fix-command:
|
||||
description: "Optional write lint-fix command (e.g. ruff check --fix .)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
extra-command:
|
||||
description: "Optional extra write command (e.g. terraform fmt -write)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
node-version:
|
||||
description: "Node.js version when package-lock.json is present"
|
||||
type: string
|
||||
required: false
|
||||
default: "24"
|
||||
terraform-version:
|
||||
description: "Terraform version when extra-command mentions terraform"
|
||||
type: string
|
||||
required: false
|
||||
default: "1.16.0"
|
||||
outputs:
|
||||
committed:
|
||||
description: "true when this job pushed a formatter commit"
|
||||
value: ${{ jobs.autofix.outputs.committed }}
|
||||
secrets:
|
||||
AUTOFMT_APP_ID:
|
||||
description: "GitHub App id for the formatter"
|
||||
required: true
|
||||
AUTOFMT_APP_PRIVATE_KEY:
|
||||
description: "GitHub App private key for the formatter"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
name: autofix
|
||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: ci-autofix-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
outputs:
|
||||
committed: ${{ steps.result.outputs.committed }}
|
||||
steps:
|
||||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.AUTOFMT_APP_ID }}
|
||||
private-key: ${{ secrets.AUTOFMT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Skip App-authored synchronize
|
||||
id: skip-bot
|
||||
env:
|
||||
ACTOR: ${{ github.actor }}
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="${APP_SLUG}[bot]"
|
||||
if [ "${ACTOR}" = "${expected}" ]; then
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "Actor is ${expected}; not reformatting an App push."
|
||||
else
|
||||
echo "skip=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' }}
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: ${{ github.head_ref }}
|
||||
persist-credentials: true
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }}
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }}
|
||||
run: npm ci
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }}
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install ruff
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }}
|
||||
run: pip install 'ruff==0.15.22'
|
||||
|
||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' && contains(inputs.extra-command, 'terraform') }}
|
||||
with:
|
||||
terraform_version: ${{ inputs.terraform-version }}
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Apply formatter
|
||||
if: ${{ steps.skip-bot.outputs.skip != 'true' }}
|
||||
env:
|
||||
FORMAT_COMMAND: ${{ inputs.format-command }}
|
||||
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
|
||||
EXTRA_COMMAND: ${{ inputs.extra-command }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash -euo pipefail -c "${FORMAT_COMMAND}"
|
||||
if [ -n "${LINT_FIX_COMMAND}" ]; then
|
||||
bash -euo pipefail -c "${LINT_FIX_COMMAND}"
|
||||
fi
|
||||
if [ -n "${EXTRA_COMMAND}" ]; then
|
||||
bash -euo pipefail -c "${EXTRA_COMMAND}"
|
||||
fi
|
||||
|
||||
- name: Commit and push if dirty
|
||||
id: result
|
||||
env:
|
||||
SKIP_BOT: ${{ steps.skip-bot.outputs.skip }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
APP_ID: ${{ secrets.AUTOFMT_APP_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${SKIP_BOT}" = "true" ]; then
|
||||
echo "committed=false" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${HEAD_REF}" ] || [ "${HEAD_REF}" = "main" ]; then
|
||||
echo "Refusing to push formatter commits to ${HEAD_REF:-empty}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config user.name "${APP_SLUG}[bot]"
|
||||
git config user.email "${APP_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
|
||||
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "Tree is clean; no formatter commit."
|
||||
echo "committed=false" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "style: apply formatter"
|
||||
git push origin "HEAD:refs/heads/${HEAD_REF}"
|
||||
echo "committed=true" >> "${GITHUB_OUTPUT}"
|
||||
262
.github/workflows/ci-frontend.yaml
vendored
Normal file
262
.github/workflows/ci-frontend.yaml
vendored
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
name: CI — Frontend
|
||||
|
||||
# Parallel CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
|
||||
# with vitest + Playwright). Jobs: guard, static, build, unit (optional shards),
|
||||
# browser-smoke. The caller owns the `ci-complete` aggregator and ruleset check.
|
||||
# Do not put these portion names in an org ruleset.
|
||||
#
|
||||
# Remaining-lane repos that still need the sequential `ci / ci` context should
|
||||
# keep calling ci-typescript-frontend.yaml until they migrate.
|
||||
#
|
||||
# Caller example:
|
||||
# jobs:
|
||||
# frontend:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@<sha> # vX.Y.Z
|
||||
# with:
|
||||
# node-version: "24"
|
||||
# unit-shards: 4
|
||||
# run-e2e: true
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node.js version to use"
|
||||
type: string
|
||||
default: "24"
|
||||
unit-shards:
|
||||
description: "Vitest shard count (1-8). PR UI shows unit (1) .. unit (N)."
|
||||
type: number
|
||||
default: 1
|
||||
run-e2e:
|
||||
description: "Run the test:e2e script (Playwright browser smoke)"
|
||||
type: boolean
|
||||
default: true
|
||||
required-scripts:
|
||||
description: "Comma-separated npm scripts that must exist in package.json"
|
||||
type: string
|
||||
default: "format:check,lint,build,test,test:e2e"
|
||||
working-directory:
|
||||
description: "Directory to run npm/build/test commands from"
|
||||
type: string
|
||||
default: "."
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: guard
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-guard
|
||||
cancel-in-progress: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify unit-shards
|
||||
env:
|
||||
UNIT_SHARDS: ${{ inputs.unit-shards }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${UNIT_SHARDS}" -lt 1 ] || [ "${UNIT_SHARDS}" -gt 8 ]; then
|
||||
echo "unit-shards must be between 1 and 8 (got ${UNIT_SHARDS})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify required npm scripts
|
||||
env:
|
||||
REQUIRED_SCRIPTS: ${{ inputs.required-scripts }}
|
||||
RUN_E2E: ${{ inputs.run-e2e }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require("node:fs");
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const required = (process.env.REQUIRED_SCRIPTS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((script) => process.env.RUN_E2E !== "false" || script !== "test:e2e");
|
||||
const missing = required.filter((script) => !pkg.scripts?.[script]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`Missing required scripts: ${missing.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`All required scripts present: ${required.join(", ")}`);
|
||||
NODE
|
||||
|
||||
- name: Guard changed lines
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
BASE_REF="${PR_BASE_SHA}"
|
||||
elif [ "${EVENT_NAME}" = "merge_group" ]; then
|
||||
BASE_REF="${MERGE_GROUP_BASE_SHA}"
|
||||
else
|
||||
BASE_REF="${PUSH_BEFORE}"
|
||||
fi
|
||||
|
||||
if [ -z "${BASE_REF}" ] || [ "${BASE_REF}" = "0000000000000000000000000000000000000000" ]; then
|
||||
BASE_REF="$(git rev-parse HEAD~1 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -z "${BASE_REF}" ]; then
|
||||
echo "No base ref available; skipping changed-line guard."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ADDED_LINES="$(git diff --unified=0 "${BASE_REF}" "${GITHUB_SHA}" | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
|
||||
|
||||
if printf '%s\n' "${ADDED_LINES}" | grep -E 'Generated with (Claude [C]ode|[C]odex|Chat[G]PT)|--no-[v]erify|HUSKY[=]0'; then
|
||||
echo "Found generated-tool footer or hook bypass wording in added lines."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if printf '%s\n' "${ADDED_LINES}" | grep -E 'A[K]IA[0-9A-Z]{16}|A[S]IA[0-9A-Z]{16}|AWS[_]SECRET[_]ACCESS[_]KEY|aws[_-]?secret[_-]?access[_-]?key|J[W]TAuthenticationHIGHsecuredPassword|P[a]ssword=|S[e]ndGrid.*A[p]iKey'; then
|
||||
echo "Found a likely secret in added lines. Move sensitive values to the environment or secret manager."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Changed-line guard passed."
|
||||
|
||||
- name: Conventions check
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
errors=0
|
||||
fail() { echo "::error::$1"; errors=$((errors + 1)); }
|
||||
[[ -f README.md ]] || fail "Missing README.md"
|
||||
if [[ -f .gitignore ]]; then
|
||||
grep -qE '^\.env$|^\.env\b' .gitignore || fail ".gitignore does not include .env"
|
||||
else
|
||||
fail "Missing .gitignore"
|
||||
fi
|
||||
if [[ $errors -gt 0 ]]; then
|
||||
echo "Conventions check failed with $errors error(s)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Conventions check passed."
|
||||
|
||||
static:
|
||||
name: static
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-static
|
||||
cancel-in-progress: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run format:check
|
||||
- run: npm run lint
|
||||
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-build
|
||||
cancel-in-progress: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
unit:
|
||||
name: unit (${{ matrix.shard }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-unit-${{ matrix.shard }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJSON(format('[{0}]', inputs.unit-shards == 1 && '1' || inputs.unit-shards == 2 && '1,2' || inputs.unit-shards == 3 && '1,2,3' || inputs.unit-shards == 4 && '1,2,3,4' || inputs.unit-shards == 5 && '1,2,3,4,5' || inputs.unit-shards == 6 && '1,2,3,4,5,6' || inputs.unit-shards == 7 && '1,2,3,4,5,6,7' || '1,2,3,4,5,6,7,8')) }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
|
||||
|
||||
- run: npm ci
|
||||
- name: Unit tests
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ inputs.unit-shards }}
|
||||
run: npm test -- --shard="${SHARD}/${SHARDS}"
|
||||
|
||||
browser-smoke:
|
||||
name: browser-smoke
|
||||
if: ${{ inputs.run-e2e }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
concurrency:
|
||||
group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-browser-smoke
|
||||
cancel-in-progress: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }}
|
||||
|
||||
- run: npm ci
|
||||
- name: Browser smoke
|
||||
env:
|
||||
CI: "true"
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:e2e
|
||||
57
.github/workflows/ci-terraform.yaml
vendored
Normal file
57
.github/workflows/ci-terraform.yaml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: CI — Terraform
|
||||
|
||||
# Reusable Terraform fmt/init/validate for HCP app repos. Init uses
|
||||
# `-backend=false` so CI does not need remote state credentials. The caller
|
||||
# owns the `ci-complete` aggregator.
|
||||
#
|
||||
# Caller example:
|
||||
# jobs:
|
||||
# terraform:
|
||||
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<sha> # vX.Y.Z
|
||||
# with:
|
||||
# terraform-version: "1.16.0"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
terraform-version:
|
||||
description: "Terraform version to install"
|
||||
type: string
|
||||
default: "1.16.0"
|
||||
working-directory:
|
||||
description: "Directory containing Terraform sources"
|
||||
type: string
|
||||
default: "terraform"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
name: terraform
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: ci-terraform-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}
|
||||
cancel-in-progress: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: ${{ inputs.terraform-version }}
|
||||
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
|
||||
10
.github/workflows/ci-typescript-frontend.yaml
vendored
10
.github/workflows/ci-typescript-frontend.yaml
vendored
|
|
@ -1,9 +1,11 @@
|
|||
name: CI — TypeScript Frontend
|
||||
|
||||
# Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs
|
||||
# with vitest + Playwright). Emits the single `ci / ci` status context required
|
||||
# by the org branch-protection rulesets — keep the caller job id `ci` so the
|
||||
# context resolves to `ci / ci`.
|
||||
# Sequential reusable CI for remaining-lane TypeScript front-end apps that
|
||||
# still emit `ci / ci`. HCP app repos should call ci-frontend.yaml (parallel
|
||||
# portions) plus a caller-owned `ci-complete` aggregator instead.
|
||||
#
|
||||
# Emits the single `ci / ci` status context required by the unconverted-repo
|
||||
# ruleset — keep the caller job id `ci` so the context resolves to `ci / ci`.
|
||||
#
|
||||
# Runs, in order: a Sea Haven standards gate (required npm scripts present, no
|
||||
# AI-tool footers / hook bypasses / hardcoded secrets in the added lines),
|
||||
|
|
|
|||
44
.mergify.yml
44
.mergify.yml
|
|
@ -1,44 +0,0 @@
|
|||
merge_queue:
|
||||
mode: serial
|
||||
max_parallel_checks: 5
|
||||
queue_controls_comment: false
|
||||
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- base = main
|
||||
- -draft
|
||||
- github-review-decision = APPROVED
|
||||
- check-success = "ci / ci"
|
||||
|
||||
merge_protections:
|
||||
- name: require-review-and-ci
|
||||
if:
|
||||
- base = main
|
||||
- -draft
|
||||
success_conditions:
|
||||
- github-review-decision = APPROVED
|
||||
- check-success = "ci / ci"
|
||||
|
||||
queue_rules:
|
||||
- name: default
|
||||
batch_size: 3
|
||||
batch_max_wait_time: 30 seconds
|
||||
checks_timeout: 10 min
|
||||
queue_conditions:
|
||||
- base = main
|
||||
- -draft
|
||||
- github-review-decision = APPROVED
|
||||
- check-success = "ci / ci"
|
||||
merge_method: squash
|
||||
commit_message_format:
|
||||
title: inherit
|
||||
body: empty
|
||||
branch_protection_injection_mode: queue
|
||||
|
||||
pull_request_rules:
|
||||
- name: queue on queue ready label
|
||||
conditions:
|
||||
- label = "queue ready"
|
||||
actions:
|
||||
queue:
|
||||
name: default
|
||||
141
README.md
141
README.md
|
|
@ -26,7 +26,16 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
|
|||
|
||||
### Merge queue
|
||||
|
||||
Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues when it is awake. The default queue tests up to three PRs together on a draft branch so it does not push onto the original PR. Each PR still squash-merges on its own. Merge protections skip those drafts. Pending queue checks time out after 10 minutes. To kick a stuck PR, apply the `queue ready` label. That does not bypass `ci / ci` or `APPROVED`. Do not use `queued`; Mergify applies that while a PR is in the queue.
|
||||
CI callers keep a `merge_group` trigger so native GitHub merge queues still run portions. Mergify YAML is not used. Do not put portion job names (`frontend / static`, `unit (1)`, …) in a ruleset.
|
||||
|
||||
### Required checks
|
||||
|
||||
Two org rulesets. A repo is on exactly one of them:
|
||||
|
||||
- **main branch protection** requires `ci / ci` for unconverted remaining-lane repos.
|
||||
- **CI complete** requires `ci-complete` for converted HCP callers. It targets no repos until a cutover includes the repo and excludes it from the old ruleset in the same window.
|
||||
|
||||
The formatter GitHub App is not on the main-branch bypass list.
|
||||
|
||||
## What's in here
|
||||
|
||||
|
|
@ -36,7 +45,17 @@ Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues
|
|||
|
||||
**`.github/workflows/ci-typescript-cdk.yaml`** — Reusable CI workflow for TypeScript / CDK repos. Runs `npm ci` + optional `tsc --noEmit`, optional ESLint, optional Jest, and optional `cdk synth`. Also supports Node.js SAM repos via an optional `sam validate` step.
|
||||
|
||||
**`.github/workflows/ci-typescript-frontend.yaml`** — Reusable CI workflow for bundled TypeScript front-end apps (Vite / React / Vue SPAs). Runs a Sea Haven standards gate (required npm scripts present, no AI-tool footers / hook bypasses / hardcoded secrets in added lines), then `format:check`, `lint`, `build`, vitest unit tests, and an optional Playwright browser smoke. Emits the single `ci / ci` status context — keep the caller job id `ci`.
|
||||
**`.github/workflows/ci-typescript-frontend.yaml`** — Sequential reusable CI for remaining-lane bundled TypeScript front-end apps that still emit `ci / ci`. HCP app repos should call `ci-frontend.yaml` plus a caller-owned `ci-complete` aggregator instead.
|
||||
|
||||
**`.github/workflows/ci-frontend.yaml`** — Parallel HCP frontend CI: `guard`, `static`, `build`, `unit` (optional shards), `browser-smoke`. The caller owns `ci-complete`. Do not put those portion names in a ruleset.
|
||||
|
||||
**`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`.
|
||||
|
||||
**`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the caller's write commands, and pushes `style: apply formatter` only when the tree is dirty. Output `committed` lets the caller skip portions on SHA_old. Does not `--no-verify` and does not push to `main`.
|
||||
|
||||
**`.github/workflows/cd-hcp-fargate.yaml`** — HCP Fargate image CD. Checkout at `ref` (empty means `github.sha`), OIDC, SSM cluster/service/family/ecr/container/api-url, docker build+push tagged `$sha` and `$environment`, patch `GIT_SHA`, RegisterTaskDefinition + UpdateService + services-stable, poll health SHA. `environment` is a `with:` input. The reusable job owns `environment:`, concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`.
|
||||
|
||||
**`.github/workflows/cd-hcp-spa.yaml`** — HCP SPA CD. Checkout at `ref`, Node 24, `npm ci` + `npm run build`, origin-path guard, hashed s3 sync then `index.html` last then prune, invalidate, verify. Build env comes from the GitHub Environment (`vars.VITE_*`, `secrets.SENTRY_AUTH_TOKEN`).
|
||||
|
||||
**`.github/workflows/cd-sam.yaml`** — Reusable CD workflow for SAM repos. Runs `sam build` + `sam deploy` with OIDC credentials and a CloudFormation execution role. Triggers via `workflow_call` from per-repo `deploy.yaml` on push to `main`.
|
||||
|
||||
|
|
@ -74,7 +93,7 @@ The supply-chain check operates in **diff mode**: for modified or renamed workfl
|
|||
|
||||
### Workflow templates (`workflow-templates/`)
|
||||
|
||||
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `pr-policy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
|
||||
Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `pr-policy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone; `ci-hcp` is the converted-repo caller with autofix, frontend, terraform, and `ci-complete`). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one.
|
||||
|
||||
A `pr-policy` starter template is available in `workflow-templates/`. Before the template produces passing human PR checks, the three Jira org secrets must be granted to the consumer repo (see §1).
|
||||
|
||||
|
|
@ -166,8 +185,10 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
|
|||
| Secret | Value | Consumed by |
|
||||
|--------|-------|-------------|
|
||||
| `ANTHROPIC_API_KEY` | Anthropic API key | `reviewer-eval.yml` in `open-swe` |
|
||||
| `AUTOFMT_APP_ID` | Formatter GitHub App id | `ci-autofix.yaml` |
|
||||
| `AUTOFMT_APP_PRIVATE_KEY` | Formatter GitHub App private key | `ci-autofix.yaml` |
|
||||
|
||||
The CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3).
|
||||
The remaining-lane CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §4). HCP CD uses `vars.DEPLOY_ROLE_ARN` on the GitHub Environment after OIDC. Adam installs the formatter App (contents: write, metadata: read; not a main-branch ruleset bypass) and grants the two autofmt secrets before the first converted repo runs autofix.
|
||||
|
||||
Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo:
|
||||
|
||||
|
|
@ -242,6 +263,65 @@ jobs:
|
|||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
|
||||
```
|
||||
|
||||
**HCP app repo** (converted callers; required check is `ci-complete`):
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, hotfix/**, release/**]
|
||||
merge_group:
|
||||
push:
|
||||
branches: [hotfix/**, release/**]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@<full-commit-sha> # vX.Y.Z
|
||||
permissions: { contents: write }
|
||||
secrets: inherit
|
||||
with:
|
||||
format-command: npm run format
|
||||
lint-fix-command: npm run lint -- --fix
|
||||
|
||||
frontend:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@<full-commit-sha> # vX.Y.Z
|
||||
with:
|
||||
node-version: "24"
|
||||
unit-shards: 4
|
||||
run-e2e: true
|
||||
|
||||
terraform:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<full-commit-sha> # vX.Y.Z
|
||||
with:
|
||||
terraform-version: "1.16.0"
|
||||
|
||||
ci-complete:
|
||||
name: ci-complete
|
||||
needs: [autofix, frontend, terraform]
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require portions
|
||||
env:
|
||||
FRONTEND: ${{ needs.frontend.result }}
|
||||
TERRAFORM: ${{ needs.terraform.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "${FRONTEND}" = success
|
||||
test "${TERRAFORM}" = success
|
||||
```
|
||||
|
||||
Python HCP callers pass `format-command: ruff format .` and `lint-fix-command: ruff check --fix .`. Optional `extra-command: terraform fmt -write` is available on `ci-autofix.yaml`. Do not run `eslint --fix` unless that repo's `lint` script is already fix-safe. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets.
|
||||
|
||||
### 3. Add PR policy to a repo
|
||||
|
||||
Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1).
|
||||
|
|
@ -282,9 +362,58 @@ The check-run name is `policy / pr`. If your branch-protection ruleset requires
|
|||
|
||||
### 4. Add CD to a repo
|
||||
|
||||
Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
|
||||
**HCP Fargate** (one caller job per GitHub Environment; `environment` is a `with:` input):
|
||||
|
||||
**SAM repo** (e.g., afterhours-shift-manager):
|
||||
```yaml
|
||||
name: Deploy API
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: [terraform/**, docs/**, "*.md"]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment: { type: choice, options: [dev, prod] }
|
||||
ref: { type: string, default: "" }
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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@<full-commit-sha> # vX.Y.Z
|
||||
permissions: { contents: read, id-token: write }
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: dev
|
||||
ref: ${{ inputs.ref }}
|
||||
ssm-prefix: /meal-order-manager/deploy
|
||||
docker-platform: linux/amd64
|
||||
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
|
||||
|
||||
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@<full-commit-sha> # vX.Y.Z
|
||||
permissions: { contents: read, id-token: write }
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: prod
|
||||
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
ssm-prefix: /meal-order-manager/deploy
|
||||
docker-platform: linux/amd64
|
||||
ship-gate: true
|
||||
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
|
||||
```
|
||||
|
||||
SPA callers use `cd-hcp-spa.yaml` the same way. Pass `required-vite-vars` for Environment `VITE_*` keys that must be set before `npm run build`. Add `deploy-staging` only where that Environment exists. `DEPLOY_ROLE_ARN` is a GitHub Environment variable, not a repo secret. SHA-pinned org reusables change `job_workflow_ref` to `Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@<sha>` (and spa). Keep `workflow_ref` on the thin caller at `refs/heads/main` and `refs/tags/v*`. `sub` stays `repo:.../<app>:environment:<env>`. Adding a reusable is a cross-family IAM change.
|
||||
|
||||
Create `.github/workflows/deploy.yaml` in remaining SAM/CDK repos. Requires `AWS_DEPLOY_ROLE_ARN` repo secret.
|
||||
|
||||
**SAM repo** (e.g., remaining SAM stacks):
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
|
|
|||
7
workflow-templates/ci-hcp.properties.json
Normal file
7
workflow-templates/ci-hcp.properties.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Sea Haven — CI (HCP)",
|
||||
"description": "Parallel frontend + Terraform CI with autofix and a ci-complete aggregator for converted HCP app repos. Remaining-lane SPAs should keep the sequential TypeScript frontend template.",
|
||||
"iconName": "octicon-checklist",
|
||||
"categories": ["TypeScript", "JavaScript", "Continuous integration"],
|
||||
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "terraform/.*\\.tf$"]
|
||||
}
|
||||
53
workflow-templates/ci-hcp.yml
Normal file
53
workflow-templates/ci-hcp.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, hotfix/**, release/**]
|
||||
merge_group:
|
||||
push:
|
||||
branches: [hotfix/**, release/**]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@REPLACE-ME # vX.Y.Z
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
with:
|
||||
format-command: npm run format
|
||||
lint-fix-command: "npm run lint -- --fix"
|
||||
|
||||
frontend:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@REPLACE-ME # vX.Y.Z
|
||||
with:
|
||||
node-version: "24"
|
||||
unit-shards: 4
|
||||
run-e2e: true
|
||||
|
||||
terraform:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@REPLACE-ME # vX.Y.Z
|
||||
with:
|
||||
terraform-version: "1.16.0"
|
||||
|
||||
ci-complete:
|
||||
name: ci-complete
|
||||
needs: [autofix, frontend, terraform]
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require portions
|
||||
env:
|
||||
FRONTEND: ${{ needs.frontend.result }}
|
||||
TERRAFORM: ${{ needs.terraform.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "${FRONTEND}" = success
|
||||
test "${TERRAFORM}" = success
|
||||
7
workflow-templates/ci-terraform.properties.json
Normal file
7
workflow-templates/ci-terraform.properties.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Sea Haven — CI (Terraform)",
|
||||
"description": "Runs terraform fmt -check, init -backend=false, and validate via the org reusable ci-terraform workflow. Prefer the HCP CI template when the repo also has a frontend.",
|
||||
"iconName": "octicon-checklist",
|
||||
"categories": ["Continuous integration"],
|
||||
"filePatterns": ["terraform/.*\\.tf$"]
|
||||
}
|
||||
16
workflow-templates/ci-terraform.yml
Normal file
16
workflow-templates/ci-terraform.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: Terraform CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, hotfix/**, release/**]
|
||||
merge_group:
|
||||
push:
|
||||
branches: [hotfix/**, release/**]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@REPLACE-ME # vX.Y.Z
|
||||
with:
|
||||
terraform-version: "1.16.0"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Sea Haven — CI (TypeScript / frontend)",
|
||||
"description": "Runs the Sea Haven standards gate, format:check, lint, build, unit tests, and a Playwright browser smoke for bundled Vite/React/Vue apps via the org reusable workflow.",
|
||||
"description": "Sequential remaining-lane CI that emits ci / ci. Converted HCP SPAs should use the HCP CI template (ci-frontend plus ci-complete) instead.",
|
||||
"iconName": "octicon-checklist",
|
||||
"categories": ["TypeScript", "JavaScript", "Continuous integration"],
|
||||
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"]
|
||||
|
|
|
|||
7
workflow-templates/hcp-fargate-deploy.properties.json
Normal file
7
workflow-templates/hcp-fargate-deploy.properties.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Sea Haven — Deploy (HCP Fargate)",
|
||||
"description": "Deploys a Fargate image via the org reusable cd-hcp-fargate workflow. One caller job per GitHub Environment. Push to main deploys dev; a published Release deploys prod behind ship-gate.",
|
||||
"iconName": "octicon-rocket",
|
||||
"categories": ["Deployment", "Docker", "Continuous integration"],
|
||||
"filePatterns": ["Dockerfile$", "terraform/.*\\.tf$"]
|
||||
}
|
||||
54
workflow-templates/hcp-fargate-deploy.yml
Normal file
54
workflow-templates/hcp-fargate-deploy.yml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: Deploy API
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: [terraform/**, docs/**, "*.md"]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target Environment"
|
||||
required: true
|
||||
type: choice
|
||||
options: [dev, prod]
|
||||
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-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@REPLACE-ME # vX.Y.Z
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: dev
|
||||
ref: ${{ inputs.ref }}
|
||||
ssm-prefix: /REPLACE-ME-repo/deploy
|
||||
docker-platform: linux/amd64
|
||||
# extra-task-env: '{"SENTRY_DSN_PARAM":"/REPLACE-ME-repo/sentry-dsn"}'
|
||||
|
||||
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@REPLACE-ME # vX.Y.Z
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: prod
|
||||
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
ssm-prefix: /REPLACE-ME-repo/deploy
|
||||
docker-platform: linux/amd64
|
||||
ship-gate: true
|
||||
# extra-task-env: '{"SENTRY_DSN_PARAM":"/REPLACE-ME-repo/sentry-dsn"}'
|
||||
7
workflow-templates/hcp-spa-deploy.properties.json
Normal file
7
workflow-templates/hcp-spa-deploy.properties.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Sea Haven — Deploy (HCP SPA)",
|
||||
"description": "Deploys a Vite SPA to S3/CloudFront via the org reusable cd-hcp-spa workflow. One caller job per GitHub Environment. Add a deploy-staging job only when that Environment exists.",
|
||||
"iconName": "octicon-rocket",
|
||||
"categories": ["Deployment", "TypeScript", "JavaScript"],
|
||||
"filePatterns": ["vite\\.config\\.[jt]s$", "package\\.json$"]
|
||||
}
|
||||
51
workflow-templates/hcp-spa-deploy.yml
Normal file
51
workflow-templates/hcp-spa-deploy.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Deploy Web
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: [terraform/**, docs/**, "*.md"]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target Environment"
|
||||
required: true
|
||||
type: choice
|
||||
options: [dev, prod]
|
||||
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-dev:
|
||||
name: Deploy SPA to dev
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@REPLACE-ME # vX.Y.Z
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: dev
|
||||
ref: ${{ inputs.ref }}
|
||||
ssm-prefix: /REPLACE-ME-repo/deploy
|
||||
|
||||
deploy-prod:
|
||||
name: Deploy SPA to prod
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@REPLACE-ME # vX.Y.Z
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: prod
|
||||
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
ssm-prefix: /REPLACE-ME-repo/deploy
|
||||
ship-gate: true
|
||||
# required-vite-vars: "VITE_SHIFTS_API_BASE,VITE_SENTRY_DSN"
|
||||
Loading…
Add table
Reference in a new issue