Compare commits

..

50 commits
v1.0.3 ... main

Author SHA1 Message Date
Adam Moussa
ee5b843ca1
feat(ci): add HCP Lambda zip deploy reusable (PLAT-79) (#156)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
* feat(ci): add HCP Lambda zip deploy reusable (PLAT-79)

* fix(ci): pick the ancestor release in the Lambda ship-gate (PLAT-79)
2026-09-28 19:29:38 +00:00
Adam Moussa
892580d7d7
fix(iam): match org-baseline policy-check OIDC subject (PLAT-234) (#155)
Some checks are pending
ci / ci / ci (push) Waiting to run
Pull request tokens use repo:ORG/seahaven-org-baseline:pull_request.
Merge queue tokens use the gh-readonly-queue ref. The previous
refs/pull/* subject never matched either.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-28 18:38:19 +00:00
Adam Moussa
c9134742ce
chore(iam): remove soaked githubdeploy roles from the template (PLAT-232) (#154)
Some checks are pending
ci / ci / ci (push) Waiting to run
Drops the management-account deploy roles for front-integrations,
afi-backup-monitor, exec-aide, seahaven-door-unlock-api, and
apm-wo-analysis. CloudTrail showed no successful mutation for 14 days.
Deploying this stack deletes those roles. This change does not deploy it.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-28 16:55:02 +00:00
Adam Moussa
8e6b8e8665
feat(iam): add org-baseline Access Analyzer CI role (PLAT-234) (#153)
Some checks are pending
ci / ci / ci (push) Waiting to run
* feat(iam): add org-baseline Access Analyzer CI role (PLAT-234)

Adds githubdeploy-seahaven-org-baseline-policy-check with only
ValidatePolicy and CheckNoNewAccess, trusted for main and pull_request.
The deploy role stays limited to main and CDK assume.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* docs(iam): point the policy-check role at its CI job (PLAT-234)

The Access Analyzer checks live in seahaven-org-baseline pull request 160.
This role is only the principal that job assumes.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(iam): match the default pull request OIDC subject (PLAT-234)

Trust refs/pull/* so seahaven-org-baseline pull request tokens can assume
the policy-check role. The immutable subject claim is not enabled.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-28 16:23:57 +00:00
Adam Moussa
0a1010e632
feat(ci): add a static-site HCP deploy caller (PLAT-225) (#152)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Unfingerprinted Eleventy builds need a one-day asset cache, not the SPA immutable sync.
2026-09-24 23:24:57 +00:00
Adam Moussa
6fc4ca31e1
chore(renovate): add the empty org preset (PLAT-224) (#151)
Some checks are pending
ci / ci / ci (push) Waiting to run
local>Sea-Haven-Industries/.github resolves to default.json. Policy stays in renovate-config.
2026-09-24 18:38:49 +00:00
Adam Moussa
bf14925fcf
feat(ci): derive Fargate health URL from CloudFront (#150)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
Callers that cannot add an SSM parameter to a shared permissions boundary can set health-from-distribution. The default still reads SSM api-url.
2026-09-24 16:49:49 +00:00
Adam Moussa
acaf2bfc0d
feat(ci): let HCP deploys share an SSM prefix (#149)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
Optional task-env replace, source-map upload, health attempts, and a concurrency suffix keep existing callers on the same defaults.
2026-09-24 16:15:53 +00:00
Adam Moussa
2e2b3a282f
fix(ci): link autofix commits to the bot account (#148)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
Use the bot account id in the noreply email so GitHub attaches the
app logo. The App id prefix pushes a commit that stays unlinked.
2026-09-23 20:15:19 -04:00
Adam Moussa
61f15d78b5
feat(ci): add formatter presets to autofix (#147)
Callers can run prettier, eslint, ruff, and terraform without passing
npm run lint -- --fix, which chains non-fixers in several apps.
2026-09-23 23:47:30 +00:00
Adam Moussa
fd41132410
ci: drop pytest lanes from ci-python-app (#146)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Collect-only and subproject-tests were unused on most callers. Pytest stays a caller-owned job. The reusable now emits lint plus the ci / ci aggregator.
2026-09-22 15:52:03 -04:00
Adam Moussa
216604ad67
feat(ci): add HCP reusable workflows and drop Mergify (#145)
* 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.

* chore(ci): remove deprecated PR policy reusable

policy / pr is no longer a required check. Drop the callable, its unit tests, and the setup docs so callers stop pinning a retired gate.
2026-09-22 19:22:23 +00:00
renovate[bot]
22c47f924f
chore(deps): update github actions (#144)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 15:46:36 +00:00
renovate[bot]
08d8ca31a9
chore(deps): update sea-haven-industries/.github action to v1.0.11 (#143)
Some checks failed
ci / ci / ci (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 16:46:00 +00:00
Adam Moussa
514552df92
feat(iam): allow afterhours WeeklyPost to send to paychex-checkcomponents (PLAT-135) (#142)
Some checks failed
ci / ci / ci (push) Has been cancelled
2026-09-09 23:59:57 +00:00
renovate[bot]
9781774f04
chore(deps): update github actions (#141)
Some checks failed
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 18:41:06 +00:00
renovate[bot]
9b7b464d5c
chore(deps): update sea-haven-industries/.github action to v1.0.10 (#140)
Some checks failed
ci / ci / ci (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 15:48:50 +00:00
Adam Moussa
4a6cbfd362
ci(workflows): pin remaining GitHub Actions to SHA (#139)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Mutable tags and the dependency-review v5 branch executed inside every consumer, including OIDC deploy jobs. SHA pins with version comments match the policy scanner and let Renovate advance them.
2026-08-26 18:25:53 -04:00
Adam Moussa
1a45bf286f
fix(ci): unstick mergify queue timeout and draft protections (#138)
Some checks are pending
ci / ci / ci (push) Waiting to run
2026-08-25 13:51:45 -04:00
Adam Moussa
2fe812e3f9
fix(ci): batch mergify queue to avoid in-place review dismissal (#137) 2026-08-25 13:31:25 -04:00
Adam Moussa
727627f1da
chore(deps): remove dependabot version updates (#136)
Some checks are pending
ci / ci / ci (push) Waiting to run
Renovate is the version-update bot on this Interactive repo. GitHub Dependabot alerts stay.
2026-08-25 11:50:49 -04:00
Adam Moussa
6a35d89541
feat(ci): queue mergify PRs with a queue ready label (#135)
Some checks are pending
ci / ci / ci (push) Waiting to run
Applying the label kicks a stuck PR without an @mergifyio comment. Auto-queue and review/CI gates stay.
2026-08-24 19:16:43 -04:00
renovate[bot]
e5b0cf714d
chore(deps): update github actions (#134)
Some checks failed
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
2026-08-24 21:18:32 +00:00
Adam Moussa
cc75356ed1
fix(ci): drop two-step mergify merge conditions (#133) 2026-08-24 16:47:44 -04:00
Adam Moussa
91ff9ed08e
fix(ci): gate mergify on github-review-decision instead of approval count (#132)
Some checks are pending
ci / ci / ci (push) Waiting to run
2026-08-24 15:42:59 -04:00
Adam Moussa
db5e1b4b19
chore(ci): remove pr-policy starter template (#131)
Some checks are pending
ci / ci / ci (push) Waiting to run
2026-08-24 15:11:41 -04:00
Adam Moussa
dc56defea2
fix(ci): allow dependabot and renovate in mergify queue (#130)
Some checks are pending
ci / ci / ci (push) Waiting to run
2026-08-24 14:13:41 -04:00
Adam Moussa
deff75a3bb
fix: update merge queue configuration (#129)
Signed-off-by: Adam Moussa <null>
2026-08-24 14:09:28 -04:00
mergify[bot]
08e8520c1f
Merge pull request #128 from Sea-Haven-Industries/chore/switch-to-mergify
chore(ci): switch auto-merge from seahaven-bot to Mergify (PLAT-108)
2026-08-24 17:50:58 +00:00
9fb1bb5549
fix(ci): use github-review-decision instead of illegal CODEOWNERS condition 2026-08-24 13:26:42 -04:00
a04036962c
fix(ci): require review and ci before mergify auto-queue 2026-08-24 13:17:14 -04:00
dce935ce31
chore(ci): switch auto-merge from seahaven-bot to Mergify 2026-08-24 13:06:47 -04:00
Adam Moussa
f2f2d4066c
ci: enable squash auto-merge on ready PRs (PLAT-108) (#126)
Some checks failed
ci / ci / ci (push) Has been cancelled
* ci: enable squash auto-merge on ready PRs

* fix: add auto-merge.yaml as a release exclusion

* fix(ci): serialize auto-merge enable and ignore already-enabled
2026-08-21 23:34:16 +00:00
Adam Moussa
8ec1f627fe
ci: add merge_group and drop policy caller (PLAT-108) (#125)
Some checks are pending
ci / ci / ci (push) Waiting to run
* ci: add merge_group trigger for required ci / ci

* ci: drop this repo's PR policy caller
2026-08-21 17:41:23 -04:00
Adam Moussa
af0f002e14
ci: expand labeler globs and skip dependabot pr policy (PLAT-107) (#124)
Some checks failed
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
* ci: expand labeler globs and skip dependabot pr policy

.NET product paths never matched app, so backend PRs stayed unlabeled. Dependabot PRs still ran commit-subject and pin checks on generated titles. Skip those PRs in the reusable policy job.

* fix(labeler): match nested elastic beanstalk config paths

Root-only .ebextensions and .platform globs miss api/.ebextensions in monorepos. Mirror the Dockerfile nested form.
2026-08-21 12:42:28 -04:00
Adam Moussa
59c7b1f9a3
chore(iam): remove mgmt meal-order weekly-menu OIDC role (#123)
Some checks are pending
ci / ci / ci (push) Waiting to run
Weekly-menu publish now assumes the prod HCP role; drop the orphaned
mgmt github-meal-order-manager-weekly-menu role from this stack.
2026-08-20 13:21:59 -04:00
dependabot[bot]
d37ca73ffa
chore(deps): bump callable-pr-policy.yaml (#122)
Some checks failed
ci / ci / ci (push) Has been cancelled
Bumps the minor-and-patch group with 1 update: [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github).

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 19:11:53 +00:00
Adam Moussa
e5691d8a7f
fix(policy): accept AP Jira keys in PR titles (#121)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
2026-08-10 20:55:27 +00:00
dependabot[bot]
123366c3f1
chore(deps): bump callable-pr-policy.yaml (#120)
Some checks are pending
ci / ci / ci (push) Waiting to run
Bumps the minor-and-patch group with 1 update: [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github).

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.5 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](9c1ecf9428...7ac3528750)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 17:55:10 +00:00
Adam Moussa
9a2efffce3
fix(iam): grant weekly-menu role SSM deploy params (#119)
Some checks are pending
ci / ci / ci (push) Waiting to run
2026-08-10 11:39:40 -04:00
Adam Moussa
9f2adabbea
chore(iam): remove procurement-ingest OIDC deploy role (PLAT-88) (#118)
Some checks failed
ci / ci / ci (push) Has been cancelled
* chore(iam): remove procurement-ingest OIDC deploy role

* chore(iam): drop procurement-ingest OIDC role output
2026-08-07 12:42:07 -04:00
Adam Moussa
7ac3528750
fix(policy): allow sync merges and longer PR titles (#117)
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
Refs: PLAT-62
2026-08-04 14:41:48 -04:00
Adam Moussa
12e70a2279
ci: add released PR policy self-caller (#116)
Some checks are pending
ci / ci / ci (push) Waiting to run
Refs: PLAT-62
2026-08-04 11:16:48 -04:00
Adam Moussa
9c1ecf9428
ci: add deterministic PR policy and align org templates (PLAT-62) (#115)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
* docs: align organization templates with Cursor conventions

Refs: PLAT-62

* ci: add deterministic PR policy gate

Refs: PLAT-62

* fix(ci): grandfather unchanged workflow policy debt

Refs: PLAT-62

* fix(ci): address PR policy security review

Refs: PLAT-62

* fix(ci): scan copied workflow files

Refs: PLAT-62

* fix(ci): close remaining workflow policy bypasses

Refs: PLAT-62

* fix(policy): reject uses block scalar action refs

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(policy): preserve line-specific violation fingerprints

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-08-03 20:30:32 -04:00
Adam Moussa
b94062bd86
fix(iam): grant weekly-menu role execute-api invoke (#114)
Some checks are pending
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Waiting to run
Release on reusable change / release (push) Blocked by required conditions
2026-08-03 15:32:35 -04:00
Adam Moussa
81cf168170
fix(deploy): recover SAM stacks after update rollback 2026-08-03 14:38:39 -04:00
Adam Moussa
9a7171a855
Merge pull request #112 from Sea-Haven-Industries/docs/readme-pinning-and-catalog
Some checks failed
ci / ci / ci (push) Has been cancelled
docs: align README pinning policy with SHA-pin convention and complete the catalog
2026-07-29 13:02:55 -04:00
ca5dae6aff
docs: align README pinning policy with SHA-pin convention and complete the catalog 2026-07-29 13:00:53 -04:00
Adam Moussa
18e207a799
Merge pull request #111 from Sea-Haven-Industries/feat/weekly-menu-scoped-role
Some checks failed
ci / ci / ci (push) Waiting to run
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
feat(iam): add scoped OIDC role for the meal-order-manager weekly-menu job
2026-07-28 19:24:29 -04:00
c2c1b80b60
feat(iam): add scoped OIDC role for the meal-order-manager weekly-menu job 2026-07-28 19:16:05 -04:00
56 changed files with 2685 additions and 497 deletions

View file

@ -1,5 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Internal IT support - name: Jira — DEV / PLAT / SEC
url: https://seahaven.atlassian.net/jira/software/projects/INFRA url: https://seahaven.atlassian.net/jira
about: For operational issues, file an INFRA Jira ticket instead. about: File all org work in Jira (DEV, PLAT, or SEC). INFRA is a closed archive. GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe.

View file

@ -15,7 +15,6 @@ assignees: amoussa1229
## AWS / integration impact ## AWS / integration impact
- New or changed AWS resources (Lambda, DynamoDB, S3, API Gateway): - New or changed AWS resources (Lambda, DynamoDB, S3, API Gateway):
- Slack app(s) involved: - Slack app(s) involved:
- Confluence Architecture Map update needed: yes / no
## Alternatives considered ## Alternatives considered
<!-- Other approaches and why they were rejected. --> <!-- Other approaches and why they were rejected. -->

View file

@ -21,6 +21,4 @@ assignees: amoussa1229
<!-- Exact steps to revert: prior stack version, DeletionPolicy considerations, data restore. --> <!-- Exact steps to revert: prior stack version, DeletionPolicy considerations, data restore. -->
## Documentation ## Documentation
- [ ] Confluence Architecture Map (id 1540098) update queued
- [ ] README updated in same PR - [ ] README updated in same PR
- [ ] Project memory entry queued

View file

@ -1,8 +1,14 @@
<!-- <!--
PR conventions — see engineering-handbook/pull-requests.md PR conventions
- Title: imperative mood, under 70 chars, describe the change not the ticket (e.g. "Add receipt parser Lambda", not "PROJ-123" or "Bug fix"). - Title format: type(scope): description (DEV-123)
- Scope: one logical change per PR. If the title needs an "and", split it. - type ∈ feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release
- Jira: put the issue key in the branch name or this PR title (e.g. [PROJ-123]) to link the PR into the Jira issue's development panel. Omit if the work has no ticket. - Maximum 120 characters, including the Jira suffix.
- Active Jira projects: DEV (product), PLAT (platform), SEC (security). INFRA is a closed archive.
- Put the Jira key at the end of the title in parentheses. A missing key is a warning, not a failure.
- Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ + kebab-case description.
Branch names do not contain Jira keys.
- Scope: one logical change per PR. If the title needs "and", split it.
- Body: state verifiable facts about the change and validation. Do not cite the handbook or add AI-attribution footers.
--> -->
## Summary ## Summary
@ -15,13 +21,4 @@ PR conventions — see engineering-handbook/pull-requests.md
<!-- What tests were added, updated, or run. If no automated tests, explain the manual testing. --> <!-- What tests were added, updated, or run. If no automated tests, explain the manual testing. -->
## Notes ## Notes
<!-- Anything reviewers should know: migration steps, deploy order, follow-ups, breaking changes. Delete this section if empty. --> <!-- Anything reviewers should know: migration steps, deploy order, follow-ups, breaking changes. Use None. if empty. -->
## Sea Haven checklist
- [ ] CDK diff / SAM changeset reviewed (if infra change)
- [ ] Secrets added to Parameter Store / Secrets Manager (not hardcoded)
- [ ] DynamoDB PITR verified on new tables
- [ ] Slack notification tested in staging
- [ ] Confluence Architecture Map updated
- [ ] Memory update queued (if new repo/stack)
- [ ] Cross-review requested (if IAM or Lambda handler signature change)

View file

@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

View file

@ -16,8 +16,8 @@ jobs:
dependency-review: dependency-review:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/dependency-review-action@v5 - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with: with:
fail-on-severity: high fail-on-severity: high
allow-ghsas: ${{ inputs.allow-ghsas }} allow-ghsas: ${{ inputs.allow-ghsas }}

View file

@ -53,6 +53,12 @@ jobs:
- '**/template.yaml' - '**/template.yaml'
- 'samconfig.toml' - 'samconfig.toml'
- 'infra/**' - 'infra/**'
- 'Dockerfile'
- '**/Dockerfile'
- '.ebextensions/**'
- '**/.ebextensions/**'
- '.platform/**'
- '**/.platform/**'
app: app:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
@ -64,6 +70,9 @@ jobs:
- 'web/**' - 'web/**'
- 'mobile/**' - 'mobile/**'
- 'shared/**' - 'shared/**'
- '**/*.cs'
- '**/*.cshtml'
- '**/*.razor'
content: content:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
@ -98,12 +107,12 @@ jobs:
tests: tests:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
# directory conventions (covers Java src/test, Ruby test/spec, etc.)
- '**/tests/**' - '**/tests/**'
- '**/test/**' - '**/test/**'
- '**/spec/**' - '**/spec/**'
- '**/__tests__/**' - '**/__tests__/**'
# JS / TS - 'e2e/**'
- '**/e2e/**'
- '**/*.test.js' - '**/*.test.js'
- '**/*.test.jsx' - '**/*.test.jsx'
- '**/*.test.ts' - '**/*.test.ts'
@ -112,21 +121,16 @@ jobs:
- '**/*.spec.jsx' - '**/*.spec.jsx'
- '**/*.spec.ts' - '**/*.spec.ts'
- '**/*.spec.tsx' - '**/*.spec.tsx'
# Python
- '**/*_test.py' - '**/*_test.py'
- '**/test_*.py' - '**/test_*.py'
- '**/conftest.py' - '**/conftest.py'
# .NET
- '**/*Tests.cs' - '**/*Tests.cs'
- '**/*Test.cs' - '**/*Test.cs'
- '**/*.Tests/**' - '**/*.Tests/**'
# Java / JVM
- '**/*Test.java' - '**/*Test.java'
- '**/*Tests.java' - '**/*Tests.java'
- '**/*IT.java' - '**/*IT.java'
# Go
- '**/*_test.go' - '**/*_test.go'
# Ruby
- '**/*_spec.rb' - '**/*_spec.rb'
- '**/*_test.rb' - '**/*_test.rb'
EOF EOF

View file

@ -70,12 +70,12 @@ jobs:
group: cd-cdk-${{ inputs.region }}-${{ inputs.stacks }}-${{ inputs.stack-name }} group: cd-cdk-${{ inputs.region }}-${{ inputs.stacks }}-${{ inputs.stack-name }}
cancel-in-progress: false cancel-in-progress: false
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
if: ${{ inputs.enable-qemu }} if: ${{ inputs.enable-qemu }}
- uses: actions/setup-dotnet@v6 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
if: ${{ inputs.dotnet-version != '' }} if: ${{ inputs.dotnet-version != '' }}
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}
@ -95,11 +95,11 @@ jobs:
--self-contained false \ --self-contained false \
--output "$(dirname "$DOTNET_PUBLISH_PROJECT")/bin/Release/net8.0/linux-arm64/publish" --output "$(dirname "$DOTNET_PUBLISH_PROJECT")/bin/Release/net8.0/linux-arm64/publish"
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
- uses: actions/setup-python@v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: ${{ inputs.python-version != '' }} if: ${{ inputs.python-version != '' }}
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
@ -121,7 +121,7 @@ jobs:
pip install -r "$req" pip install -r "$req"
done < <(find . -name requirements.txt -not -path '*/node_modules/*' -print0) done < <(find . -name requirements.txt -not -path '*/node_modules/*' -print0)
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}

View file

@ -4,7 +4,7 @@ name: CD — .NET Elastic Beanstalk
# #
# jobs: # jobs:
# deploy: # deploy:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # main # uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # v1.0.4
# with: # with:
# project: "Api.Example/Api.Example.csproj" # project: "Api.Example/Api.Example.csproj"
# eb-application: "example-api" # eb-application: "example-api"
@ -82,9 +82,9 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@v6 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}
@ -119,7 +119,7 @@ jobs:
cd .. cd ..
echo "Bundle size: $(du -h bundle.zip | cut -f1)" echo "Bundle size: $(du -h bundle.zip | cut -f1)"
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}

441
.github/workflows/cd-hcp-fargate.yaml vendored Normal file
View file

@ -0,0 +1,441 @@
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
#
# apply-task-environment replaces the container env from
# ${prefix}/task-environment. sentry-project uploads image files before
# RegisterTaskDefinition. concurrency-suffix splits two deployables that
# share one SSM prefix. Empty defaults keep the previous behavior.
#
# 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: "{}"
apply-task-environment:
description: "Replace container env from SSM ${prefix}/task-environment. GIT_SHA wins."
type: boolean
required: false
default: false
sentry-org:
description: "Sentry org for BFF source map upload when sentry-project is set"
type: string
required: false
default: "seahaven"
sentry-project:
description: "Sentry project for BFF source map upload. Empty skips upload."
type: string
required: false
default: ""
sentry-container-files:
description: "Comma-separated image paths to upload. Required when sentry-project is set."
type: string
required: false
default: ""
health-attempts:
description: "Number of /api/health polls, 10 seconds apart, before failing"
type: number
required: false
default: 6
health-from-distribution:
description: "Build the health URL from SSM distribution-id and CloudFront GetDistribution. Leave false to read SSM api-url."
type: boolean
required: false
default: false
concurrency-suffix:
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
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: ${{ inputs.concurrency-suffix != '' && format('deploy-{0}-{1}-{2}', inputs.ssm-prefix, inputs.concurrency-suffix, inputs.environment) || format('deploy-{0}-{1}', 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 }}
HEALTH_FROM_DISTRIBUTION: ${{ inputs.health-from-distribution }}
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")
if [ "${HEALTH_FROM_DISTRIBUTION}" = "true" ]; then
DIST_ID=$(get_param "${prefix}/distribution-id")
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
API_URL="https://${DOMAIN}"
else
API_URL=$(get_param "${prefix}/api-url")
fi
{
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 \
.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: ${{ inputs.sentry-project != '' }}
with:
node-version: "24"
- name: Upload BFF source maps
if: ${{ inputs.sentry-project != '' }}
env:
ECR: ${{ steps.deploy.outputs.ecr }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_URL: https://de.sentry.io
SENTRY_ORG: ${{ inputs.sentry-org }}
SENTRY_PROJECT: ${{ inputs.sentry-project }}
SENTRY_CONTAINER_FILES: ${{ inputs.sentry-container-files }}
run: |
set -euo pipefail
if [ -z "${SENTRY_AUTH_TOKEN}" ]; then
echo "SENTRY_AUTH_TOKEN is required to upload BFF source maps" >&2
exit 1
fi
if [ -z "${SENTRY_CONTAINER_FILES}" ]; then
echo "sentry-container-files is required when sentry-project is set" >&2
exit 1
fi
docker pull "${ECR}:${GIT_SHA}"
mkdir -p build/sentry
cid="$(docker create "${ECR}:${GIT_SHA}")"
cleanup() { docker rm "${cid}" >/dev/null 2>&1 || true; }
trap cleanup EXIT
IFS=',' read -r -a files <<< "${SENTRY_CONTAINER_FILES}"
for path in "${files[@]}"; do
path="${path#"${path%%[![:space:]]*}"}"
path="${path%"${path##*[![:space:]]}"}"
if [ -z "${path}" ]; then
echo "sentry-container-files contains an empty path" >&2
exit 1
fi
base="$(basename "${path}")"
docker cp "${cid}:${path}" "build/sentry/${base}"
done
if [ -f build/sentry/server.js ]; then
grep -q "${GIT_SHA}" build/sentry/server.js
grep -q debugId build/sentry/server.js
fi
npx --yes @sentry/cli@2 sourcemaps upload \
--org "${SENTRY_ORG}" \
--project "${SENTRY_PROJECT}" \
--release "${GIT_SHA}" \
build/sentry
- 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 }}
APPLY_TASK_ENVIRONMENT: ${{ inputs.apply-task-environment }}
SSM_PREFIX: ${{ inputs.ssm-prefix }}
run: |
set -euo pipefail
if [ "${APPLY_TASK_ENVIRONMENT}" = "true" ]; then
prefix="${SSM_PREFIX%/}"
TASK_ENV_JSON="$(aws ssm get-parameter \
--name "${prefix}/task-environment" \
--with-decryption \
--query Parameter.Value \
--output text)"
export TASK_ENV_JSON
fi
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")
apply = os.environ.get("APPLY_TASK_ENVIRONMENT") == "true"
found = False
for container in td["containerDefinitions"]:
if container["name"] != name:
continue
found = True
container["image"] = image
if apply:
env_map = json.loads(os.environ["TASK_ENV_JSON"])
if not isinstance(env_map, dict) or not env_map:
sys.exit("task-environment must be a non-empty JSON object")
env = {str(key): str(value) for key, value in env_map.items()}
env.pop("GIT_SHA", None)
container["stopTimeout"] = 60
else:
env = {item["name"]: item["value"] for item in container.get("environment", [])}
for key, value in extra_env.items():
env[str(key)] = str(value)
env["GIT_SHA"] = sha
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 }}
HEALTH_ATTEMPTS: ${{ inputs.health-attempts }}
run: |
set -euo pipefail
path="${HEALTH_PATH}"
case "${path}" in
/*) ;;
*) path="/${path}" ;;
esac
url="${API_URL%/}${path}"
if ! [[ "${HEALTH_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]]; then
echo "health-attempts must be a positive integer" >&2
exit 1
fi
for _ in $(seq 1 "${HEALTH_ATTEMPTS}"); 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

274
.github/workflows/cd-hcp-lambda.yaml vendored Normal file
View file

@ -0,0 +1,274 @@
name: CD — HCP Lambda
# Reusable Lambda zip 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-lambda.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: /payments-dashboard/deploy
# function-keys: process_csv,slack_app_home
# ship-gate: true
#
# The caller repo must provide scripts/package_lambdas.mjs, which writes
# build/packages/<key>.zip and embeds the commit in src/buildInfo.js.
# Terraform owns the functions and ignores code attributes. SSM under
# ssm-prefix supplies artifacts-bucket and <key>-function-name.
#
# Nothing here creates an HCP run.
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. /payments-dashboard/deploy)"
type: string
required: true
function-keys:
description: "Comma-separated package keys. Each maps to SSM <prefix>/<key>-function-name."
type: string
required: true
node-version:
description: "Node.js version for setup-node and the packager"
type: string
required: false
default: "24"
ship-gate:
description: "Require the ref to be on main or a legal hotfix/release tag"
type: boolean
required: false
default: false
permissions:
contents: read
id-token: write
jobs:
deploy:
name: Deploy Lambda 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
# Newest matching release that is an ancestor of TAG. The highest
# release overall is not that ancestor when a hotfix is cut from an
# older line (v2.0.0 exists, v1.2.1 is cut from v1.2.0).
CANDIDATES="$(
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, reverse=True)
print("\n".join(tags))
'
)"
PREV=""
if [ -n "${CANDIDATES}" ]; then
while IFS= read -r candidate; do
if [ -z "${candidate}" ]; then
continue
fi
candidate_status="$(gh api "repos/${REPO}/compare/${candidate}...${TAG}" --jq .status)"
if [ "${candidate_status}" = "ahead" ]; then
PREV="${candidate}"
break
fi
done <<< "${CANDIDATES}"
fi
if [ -z "${PREV}" ]; then
echo "ship-gate: no prior ${PATTERN} release is an ancestor of ${TAG}" >&2
exit 1
fi
echo "ship-gate: ${TAG} is ahead of ${PREV}"
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: ${{ inputs.node-version }}
cache: npm
- name: Build function zips
env:
GIT_SHA: ${{ steps.commit.outputs.sha }}
FUNCTION_KEYS: ${{ inputs.function-keys }}
run: |
set -euo pipefail
if [ -z "${FUNCTION_KEYS}" ]; then
echo "function-keys is required" >&2
exit 1
fi
keys=()
IFS=',' read -r -a raw_keys <<< "${FUNCTION_KEYS}"
for raw in "${raw_keys[@]}"; do
key="${raw#"${raw%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
if [ -z "${key}" ]; then
echo "function-keys contains an empty key" >&2
exit 1
fi
if [[ ! "${key}" =~ ^[A-Za-z0-9_]+$ ]]; then
echo "invalid function key: ${key}" >&2
exit 1
fi
keys+=("${key}")
done
if [ "${#keys[@]}" -eq 0 ]; then
echo "function-keys is empty" >&2
exit 1
fi
clean="$(IFS=,; echo "${keys[*]}")"
echo "keys=${clean}" >> "${GITHUB_ENV}"
cmd=(node scripts/package_lambdas.mjs --git-sha "${GIT_SHA}" --out-dir build/packages)
for key in "${keys[@]}"; do
cmd+=(--only "${key}")
done
"${cmd[@]}"
FUNCTION_KEYS_CLEAN="${clean}" python3 - <<'PY'
import os, zipfile
from pathlib import Path
sha = os.environ["GIT_SHA"]
keys = [part for part in os.environ["FUNCTION_KEYS_CLEAN"].split(",") if part]
for name in keys:
path = Path("build/packages") / f"{name}.zip"
if not path.is_file():
raise SystemExit(f"missing {path}")
with zipfile.ZipFile(path) as zf:
info = zf.read("src/buildInfo.js").decode()
if sha not in info:
raise SystemExit(f"{path} missing GIT_SHA {sha}")
print("zips ok")
PY
- 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: Upload zips and update function code
env:
SSM_PREFIX: ${{ inputs.ssm-prefix }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
FUNCTION_KEYS_CLEAN: ${{ env.keys }}
run: |
set -euo pipefail
prefix="${SSM_PREFIX%/}"
bucket="$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)"
IFS=',' read -r -a keys <<< "${FUNCTION_KEYS_CLEAN}"
for key in "${keys[@]}"; do
fn="$(aws ssm get-parameter --name "${prefix}/${key}-function-name" --query Parameter.Value --output text)"
s3_key="functions/${key}/${GIT_SHA}.zip"
aws s3 cp "build/packages/${key}.zip" "s3://${bucket}/${s3_key}"
aws lambda update-function-code \
--function-name "${fn}" \
--s3-bucket "${bucket}" \
--s3-key "${s3_key}" \
--query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \
--output table
aws lambda wait function-updated-v2 --function-name "${fn}"
done

409
.github/workflows/cd-hcp-spa.yaml vendored Normal file
View file

@ -0,0 +1,409 @@
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
#
# concurrency-suffix splits two deployables that share one SSM prefix.
# verify-companion-api adds cache, asset, and /api/health checks after the
# index.html hash matches. Empty defaults keep the previous behavior.
#
# 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: ""
verify-companion-api:
description: "After the index hash matches, check cache headers, hashed assets, and /api/health"
type: boolean
required: false
default: false
concurrency-suffix:
description: "Optional concurrency group suffix when two deployables share an SSM prefix"
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: ${{ inputs.concurrency-suffix != '' && format('deploy-{0}-{1}-{2}', inputs.ssm-prefix, inputs.concurrency-suffix, inputs.environment) || format('deploy-{0}-{1}', 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
- name: Verify companion API
if: ${{ inputs.verify-companion-api }}
env:
SITE_URL: ${{ steps.deploy.outputs.site_url }}
HEALTH_BUDGET: "20"
HEALTH_INTERVAL: "15"
run: |
set -euo pipefail
SITE_URL="${SITE_URL%/}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
curl -fsS --max-time 30 "${SITE_URL}/signin" -o "${tmp}/signin.html"
curl -fsS --max-time 30 "${SITE_URL}/help" -o "${tmp}/route.html"
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
echo "FAIL: HTML Cache-Control is missing no-store." >&2
exit 1
fi
python3 -c '
import re, sys
html = open(sys.argv[1], encoding="utf-8").read()
seen = []
for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
if path not in seen:
seen.append(path)
print(path)
' "${tmp}/index.html" > "${tmp}/asset-paths.txt"
if [ ! -s "${tmp}/asset-paths.txt" ]; then
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
exit 1
fi
: > "${tmp}/assets.txt"
immutable_ok="no"
while IFS= read -r asset_path; do
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \
-o "${tmp}/asset-body" -D "${tmp}/asset.headers"
cat "${tmp}/asset-body" >> "${tmp}/assets.txt"
if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
exit 1
fi
immutable_ok="yes"
fi
done < "${tmp}/asset-paths.txt"
if [ "${immutable_ok}" != "yes" ]; then
echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2
exit 1
fi
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
if grep -Eiq 'https?://(localhost|127\.0\.0\.1):[0-9]+' "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL localhost." >&2
exit 1
fi
health_code="000"
health_sha=""
health_attempt=0
while [ "${health_attempt}" -lt "${HEALTH_BUDGET}" ]; do
health_attempt=$((health_attempt + 1))
health_code="$(curl -sS --max-time 30 -o "${tmp}/health.json" -w '%{http_code}' "${SITE_URL}/api/health" || echo "000")"
echo "health poll ${health_attempt}/${HEALTH_BUDGET}: GET /api/health http=${health_code}"
if [ "${health_code}" = "200" ]; then
health_sha="$(python3 -c 'import json,sys
try:
print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "")
except Exception:
print("")
' "${tmp}/health.json")"
if [ -n "${health_sha}" ] && [ "${health_sha}" != "bootstrap" ]; then
break
fi
echo "health poll ${health_attempt}/${HEALTH_BUDGET}: sha=${health_sha:-missing} (waiting for Deploy API)"
fi
if [ "${health_attempt}" -lt "${HEALTH_BUDGET}" ]; then
sleep "${HEALTH_INTERVAL}"
fi
done
if [ "${health_code}" != "200" ]; then
echo "FAIL: GET /api/health returned HTTP ${health_code} after ${HEALTH_BUDGET} polls." >&2
exit 1
fi
if [ -z "${health_sha}" ] || [ "${health_sha}" = "bootstrap" ]; then
echo "FAIL: GET /api/health is still the bootstrap stub after ${HEALTH_BUDGET} polls." >&2
exit 1
fi
python3 -c 'import json,sys; body=json.load(open(sys.argv[1], encoding="utf-8")); raise SystemExit(0 if body.get("stage") and body.get("sha") else 1)' "${tmp}/health.json"
echo "PASS: companion API smoke checks passed."

312
.github/workflows/cd-hcp-static.yaml vendored Normal file
View file

@ -0,0 +1,312 @@
name: CD — HCP static site
# Reusable CloudFront/S3 CD for unfingerprinted static sites. 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:`.
#
# Assets are not content-hashed, so they get a one-day cache. HTML, XML, and
# text get no-cache. Do not point a hashed SPA at this workflow.
#
# Caller example:
# jobs:
# deploy-prod:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-static.yaml@<sha> # vX.Y.Z
# permissions: { contents: read, id-token: write }
# secrets: inherit
# with:
# environment: prod
# ref: ${{ inputs.ref }}
# ssm-prefix: /seahaven-site/deploy
# required-paths: _site/index.html,_site/contact/index.html,_site/404.html
# min-file-count: 40
# 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. /seahaven-site/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
output-dir:
description: "Build output directory"
type: string
required: false
default: "_site"
required-paths:
description: "Comma-separated repo-relative files that must exist after the build"
type: string
required: false
default: ""
min-file-count:
description: "Minimum file count under output-dir. Zero skips the count check."
type: number
required: false
default: 1
permissions:
contents: read
id-token: write
jobs:
deploy:
name: Deploy static site 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 site
env:
OUTPUT_DIR: ${{ inputs.output-dir }}
REQUIRED_PATHS: ${{ inputs.required-paths }}
MIN_FILE_COUNT: ${{ inputs.min-file-count }}
run: |
set -euo pipefail
npm ci --ignore-scripts
npm run build
python3 - <<'PY'
import os, sys
output_dir = os.environ["OUTPUT_DIR"]
if not os.path.isdir(output_dir):
print(f"build did not produce {output_dir}", file=sys.stderr)
sys.exit(1)
missing = []
for raw in os.environ.get("REQUIRED_PATHS", "").split(","):
path = raw.strip()
if path and not os.path.isfile(path):
missing.append(path)
if missing:
print("missing required build files: " + ", ".join(missing), file=sys.stderr)
sys.exit(1)
count = 0
for _root, _dirs, files in os.walk(output_dir):
count += len(files)
minimum = int(os.environ["MIN_FILE_COUNT"])
if minimum > 0 and count < minimum:
print(f"build produced only {count} files (expected >= {minimum})", file=sys.stderr)
sys.exit(1)
index = os.path.join(output_dir, "index.html")
if not os.path.isfile(index):
print(f"missing {index}", file=sys.stderr)
sys.exit(1)
print(f"Build OK: {count} files.")
PY
index_sha="$(python3 -c 'import hashlib,os,pathlib; print(hashlib.sha256(pathlib.Path(os.environ["OUTPUT_DIR"], "index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "${OUTPUT_DIR}/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 build output to the bucket root
env:
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
OUTPUT_DIR: ${{ inputs.output-dir }}
run: |
set -euo pipefail
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \
--exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
--cache-control "public, max-age=86400"
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress \
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
--cache-control "no-cache"
aws s3 sync "${OUTPUT_DIR}/" "s3://${SITE_BUCKET}/" --no-progress --delete
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

View file

@ -69,20 +69,20 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }} cache-dependency-path: ${{ inputs.cache-dependency-path }}
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with: with:
ruby-version: ${{ inputs.ruby-version }} ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true bundler-cache: true

View file

@ -49,15 +49,15 @@ jobs:
group: cd-sam-${{ inputs.region }}-${{ inputs.stack-name }} group: cd-sam-${{ inputs.region }}-${{ inputs.stack-name }}
cancel-in-progress: false cancel-in-progress: false
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
- uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3 - uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with: with:
role-to-assume: ${{ secrets.deploy-role-arn }} role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}
@ -69,7 +69,7 @@ jobs:
--stack-name "${{ inputs.stack-name }}" \ --stack-name "${{ inputs.stack-name }}" \
--query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "NOT_FOUND") --query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "NOT_FOUND")
case "$STATUS" in case "$STATUS" in
*ROLLBACK_COMPLETE|*FAILED) ROLLBACK_COMPLETE|*FAILED)
echo "::error::Stack ${{ inputs.stack-name }} is in $STATUS — manual intervention required." echo "::error::Stack ${{ inputs.stack-name }} is in $STATUS — manual intervention required."
exit 1 exit 1
;; ;;

295
.github/workflows/ci-autofix.yaml vendored Normal file
View file

@ -0,0 +1,295 @@
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.
#
# Presets run first, then any format-command / lint-fix-command / extra-command.
# prettier npm ci + npm run format (requires package-lock.json)
# eslint npm ci + npx eslint . --fix (opt-in; do not call npm run lint)
# ruff ruff format . + ruff check --fix . (ruff 0.15.22)
# terraform terraform fmt -recursive in terraform-working-directory
#
# 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:
# presets: prettier,terraform
#
# Python callers pass presets: ruff,terraform. Add eslint only when that
# repo's CI lint step is ESLint itself and Prettier owns formatting.
# Do not pass `npm run lint -- --fix` (some apps chain Redocly into lint).
#
# Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY.
on:
workflow_call:
inputs:
presets:
description: "Comma-separated presets: prettier, eslint, ruff, terraform"
type: string
required: false
default: ""
format-command:
description: "Optional write command run after presets (e.g. npm run format)"
type: string
required: false
default: ""
lint-fix-command:
description: "Optional write lint-fix command run after presets"
type: string
required: false
default: ""
extra-command:
description: "Optional extra write command run after presets"
type: string
required: false
default: ""
node-version:
description: "Node.js version for the prettier or eslint preset, or an npm command"
type: string
required: false
default: "24"
terraform-version:
description: "Terraform version for the terraform preset or an extra-command that runs terraform"
type: string
required: false
default: "1.16.0"
terraform-working-directory:
description: "Directory for the terraform preset (terraform fmt -recursive)"
type: string
required: false
default: "terraform"
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
- name: Resolve presets
id: presets
env:
SKIP_BOT: ${{ steps.skip-bot.outputs.skip }}
PRESETS: ${{ inputs.presets }}
FORMAT_COMMAND: ${{ inputs.format-command }}
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
EXTRA_COMMAND: ${{ inputs.extra-command }}
run: |
set -euo pipefail
write_outputs() {
{
echo "prettier=$1"
echo "eslint=$2"
echo "ruff=$3"
echo "terraform=$4"
} >> "${GITHUB_OUTPUT}"
}
if [ "${SKIP_BOT}" = "true" ]; then
write_outputs false false false false
exit 0
fi
want_prettier=false
want_eslint=false
want_ruff=false
want_terraform=false
if [ -n "${PRESETS}" ]; then
IFS=',' read -ra parts <<< "${PRESETS}"
for raw in "${parts[@]}"; do
token=$(printf '%s' "${raw}" | tr -d '[:space:]')
case "${token}" in
"") ;;
prettier) want_prettier=true ;;
eslint) want_eslint=true ;;
ruff) want_ruff=true ;;
terraform) want_terraform=true ;;
*)
echo "Unknown preset: ${token}" >&2
exit 1
;;
esac
done
fi
if { [ "${want_prettier}" = "true" ] || [ "${want_eslint}" = "true" ]; } && [ ! -f package-lock.json ]; then
echo "prettier and eslint presets require package-lock.json" >&2
exit 1
fi
if [ "${want_prettier}" = "false" ] \
&& [ "${want_eslint}" = "false" ] \
&& [ "${want_ruff}" = "false" ] \
&& [ "${want_terraform}" = "false" ] \
&& [ -z "${FORMAT_COMMAND}" ] \
&& [ -z "${LINT_FIX_COMMAND}" ] \
&& [ -z "${EXTRA_COMMAND}" ]; then
echo "Set presets or a format, lint-fix, or extra command." >&2
exit 1
fi
write_outputs "${want_prettier}" "${want_eslint}" "${want_ruff}" "${want_terraform}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' && (steps.presets.outputs.prettier == 'true' || steps.presets.outputs.eslint == 'true' || contains(inputs.format-command, 'npm') || contains(inputs.lint-fix-command, 'npm')) }}
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install npm dependencies
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' && (steps.presets.outputs.prettier == 'true' || steps.presets.outputs.eslint == 'true' || contains(inputs.format-command, 'npm') || contains(inputs.lint-fix-command, 'npm')) }}
run: npm ci
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.ruff == 'true' || contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff') || contains(inputs.extra-command, 'ruff')) }}
with:
python-version: "3.12"
- name: Install ruff
if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.ruff == 'true' || contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff') || contains(inputs.extra-command, 'ruff')) }}
run: pip install 'ruff==0.15.22'
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.terraform == '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:
PRETTIER: ${{ steps.presets.outputs.prettier }}
ESLINT: ${{ steps.presets.outputs.eslint }}
RUFF: ${{ steps.presets.outputs.ruff }}
TERRAFORM: ${{ steps.presets.outputs.terraform }}
TERRAFORM_DIR: ${{ inputs.terraform-working-directory }}
FORMAT_COMMAND: ${{ inputs.format-command }}
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
EXTRA_COMMAND: ${{ inputs.extra-command }}
run: |
set -euo pipefail
if [ "${PRETTIER}" = "true" ]; then
npm run format
fi
if [ "${ESLINT}" = "true" ]; then
npx eslint . --fix
fi
if [ "${RUFF}" = "true" ]; then
ruff format .
ruff check --fix .
fi
if [ "${TERRAFORM}" = "true" ]; then
terraform -chdir="${TERRAFORM_DIR}" fmt -recursive
fi
if [ -n "${FORMAT_COMMAND}" ]; then
bash -euo pipefail -c "${FORMAT_COMMAND}"
fi
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_TOKEN: ${{ steps.app-token.outputs.token }}
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
# The noreply local-part must be the bot account id, not the App id.
# An App-id prefix still pushes, but GitHub does not link the commit
# to the bot, so the app logo is not used.
bot_id=$(curl -fsSL \
-H "Authorization: Bearer ${APP_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/users/${APP_SLUG}%5Bbot%5D" | jq -er '.id')
git config user.name "${APP_SLUG}[bot]"
git config user.email "${bot_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}"

View file

@ -34,9 +34,9 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@v6 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}

262
.github/workflows/ci-frontend.yaml vendored Normal file
View 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

View file

@ -35,7 +35,7 @@ name: CI — Mobile iOS
# Caller example: # Caller example:
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # main # uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # v1.0.4
# with: # with:
# working-directory: mobile # working-directory: mobile
# cache-dependency-path: mobile/package-lock.json # cache-dependency-path: mobile/package-lock.json
@ -137,9 +137,9 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
@ -206,15 +206,15 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }} cache-dependency-path: ${{ inputs.cache-dependency-path }}
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with: with:
ruby-version: ${{ inputs.ruby-version }} ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true bundler-cache: true

View file

@ -1,19 +1,14 @@
name: CI — Python (app) name: CI — Python (app)
# Reusable CI for plain Python apps / locally-run tooling that do NOT deploy via # Reusable CI for plain Python apps / locally-run tooling that do NOT deploy via
# SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those). Beyond # SAM or CDK (use ci-python-sam.yaml / ci-typescript-cdk.yaml for those).
# lint + format it adds two things such repos commonly need: # Runs ruff check + format, plus an optional conventions audit.
# * a collect-only import check for a root suite whose live run needs secrets
# (verifies every test module imports cleanly without running them), and
# * an isolated full pytest run for a self-contained subproject dir whose tests
# package collides with the root tests/ package (e.g. a `tests/` under a
# subdir) and so must run in its own working directory.
# #
# Naming is load-bearing (see this repo's ci.yaml): the org ruleset matches the # Naming is load-bearing (see this repo's ci.yaml): the org ruleset matches the
# required `ci / ci` check against the JOB check-run name. A caller job keyed `ci` # required `ci / ci` check against the JOB check-run name. A caller job keyed `ci`
# invoking this workflow reports each job here as `ci / <job>`, so the aggregator # invoking this workflow reports each job here as `ci / <job>`, so the aggregator
# job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on every # job below is keyed `ci` to emit exactly `ci / ci`. The aggregator gates on lint,
# other job, so the single required check fails if any sub-job fails. # so the single required check fails if lint fails.
on: on:
workflow_call: workflow_call:
@ -26,18 +21,6 @@ on:
description: "Space-separated directories for ruff (default: repo root)" description: "Space-separated directories for ruff (default: repo root)"
type: string type: string
default: "." default: "."
requirements:
description: "Requirements file used for the pip cache key + install"
type: string
default: "requirements.txt"
collect-only:
description: "Run 'pytest --collect-only' at the repo root (imports resolve without secrets)"
type: boolean
default: true
subproject-dir:
description: "Optional self-contained subproject dir whose pytest suite runs in full"
type: string
default: ""
run-conventions-check: run-conventions-check:
description: "Run the lightweight conventions audit (README + .gitignore covers .env)" description: "Run the lightweight conventions audit (README + .gitignore covers .env)"
type: boolean type: boolean
@ -52,17 +35,15 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
# The trailing segment of every group in this file is the job id written # The trailing segment of every group in this file is the job id written
# out literally, NOT `${{ github.job }}`. In a called workflow that # out literally, NOT `${{ github.job }}`. In a called workflow that
# expression evaluates to the CALLER's job id, so all four jobs here would # expression evaluates to the CALLER's job id, so sibling jobs would
# resolve to one group and, with cancel-in-progress on, cancel each other. # resolve to one group and, with cancel-in-progress on, cancel each other.
# Observed live in pr-reviewer: `lint` was cancelled one second in by a
# sibling and the aggregator failed on the cancelled dependency.
concurrency: concurrency:
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-lint group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-lint
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
@ -101,68 +82,19 @@ jobs:
fi fi
echo "Conventions check passed." echo "Conventions check passed."
test-collect:
if: ${{ inputs.collect-only }}
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-test-collect
cancel-in-progress: true
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
cache: pip
cache-dependency-path: ${{ inputs.requirements }}
- name: Install dependencies
run: |
pip install -r "${{ inputs.requirements }}"
pip install pytest python-dotenv
- name: Pytest collect-only
run: pytest --collect-only -q
subproject-tests:
if: ${{ inputs.subproject-dir != '' }}
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-subproject-tests
cancel-in-progress: true
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
cache: pip
cache-dependency-path: ${{ inputs.requirements }}
- name: Install dependencies
run: |
pip install -r "${{ inputs.requirements }}"
pip install pytest
- name: Run subproject suite
working-directory: ${{ inputs.subproject-dir }}
run: python -m pytest -q
ci: ci:
# Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`. # Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`.
needs: [lint, test-collect, subproject-tests] needs: [lint]
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency: concurrency:
group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-ci group: ci-python-app-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}-ci
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- name: Require all jobs to have succeeded - name: Require lint to have succeeded
run: | run: |
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then if [ "${{ needs.lint.result }}" != "success" ]; then
echo "A required CI job failed or was cancelled." echo "lint failed or was cancelled."
exit 1 exit 1
fi fi
echo "All CI jobs passed." echo "All CI jobs passed."

View file

@ -55,9 +55,9 @@ jobs:
group: ci-python-sam-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }} group: ci-python-sam-${{ github.workflow }}-${{ github.ref }}-${{ inputs.source-dirs }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
@ -89,13 +89,13 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
if: ${{ inputs.run-cdk-synth }} if: ${{ inputs.run-cdk-synth }}
uses: actions/setup-node@v7 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
- name: Set up QEMU - name: Set up QEMU
if: ${{ inputs.run-cdk-synth && inputs.enable-qemu }} if: ${{ inputs.run-cdk-synth && inputs.enable-qemu }}
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: CDK synth - name: CDK synth
if: ${{ inputs.run-cdk-synth }} if: ${{ inputs.run-cdk-synth }}
@ -153,7 +153,7 @@ jobs:
- name: Setup SAM CLI - name: Setup SAM CLI
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}
uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3 uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0
- name: SAM validate - name: SAM validate
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}

View file

@ -15,7 +15,7 @@ name: CI — Static Site
# Caller example (build mode): # Caller example (build mode):
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@<full-commit-sha> # main # uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@<full-commit-sha> # v1.0.4
# with: # with:
# build-command: "npx @11ty/eleventy" # build-command: "npx @11ty/eleventy"
# check-dir: "_site" # check-dir: "_site"
@ -70,9 +70,9 @@ jobs:
CHECK_DIR: ${{ inputs.check-dir }} CHECK_DIR: ${{ inputs.check-dir }}
BUILD_COMMAND: ${{ inputs.build-command }} BUILD_COMMAND: ${{ inputs.build-command }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: ${{ inputs.run-htmlhint || inputs.build-command != '' }} if: ${{ inputs.run-htmlhint || inputs.build-command != '' }}
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}

57
.github/workflows/ci-terraform.yaml vendored Normal file
View 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

View file

@ -67,12 +67,12 @@ jobs:
group: ci-typescript-cdk-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }} group: ci-typescript-cdk-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
if: ${{ inputs.enable-qemu }} if: ${{ inputs.enable-qemu }}
- uses: actions/setup-dotnet@v6 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
if: ${{ inputs.dotnet-version != '' }} if: ${{ inputs.dotnet-version != '' }}
with: with:
dotnet-version: ${{ inputs.dotnet-version }} dotnet-version: ${{ inputs.dotnet-version }}
@ -81,7 +81,7 @@ jobs:
if: ${{ inputs.dotnet-publish-project != '' }} if: ${{ inputs.dotnet-publish-project != '' }}
run: dotnet publish ${{ inputs.dotnet-publish-project }} --configuration Release --runtime linux-arm64 --self-contained run: dotnet publish ${{ inputs.dotnet-publish-project }} --configuration Release --runtime linux-arm64 --self-contained
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
@ -159,7 +159,7 @@ jobs:
- name: Setup SAM CLI - name: Setup SAM CLI
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}
uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3 uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3.0.0
- name: SAM validate - name: SAM validate
if: ${{ inputs.run-sam-validate }} if: ${{ inputs.run-sam-validate }}

View file

@ -1,9 +1,11 @@
name: CI — TypeScript Frontend name: CI — TypeScript Frontend
# Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs # Sequential reusable CI for remaining-lane TypeScript front-end apps that
# with vitest + Playwright). Emits the single `ci / ci` status context required # still emit `ci / ci`. HCP app repos should call ci-frontend.yaml (parallel
# by the org branch-protection rulesets — keep the caller job id `ci` so the # portions) plus a caller-owned `ci-complete` aggregator instead.
# context resolves to `ci / ci`. #
# 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 # Runs, in order: a Sea Haven standards gate (required npm scripts present, no
# AI-tool footers / hook bypasses / hardcoded secrets in the added lines), # AI-tool footers / hook bypasses / hardcoded secrets in the added lines),
@ -13,7 +15,7 @@ name: CI — TypeScript Frontend
# Caller example: # Caller example:
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@<full-commit-sha> # main # uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@<full-commit-sha> # v1.0.4
# with: # with:
# node-version: "24" # node-version: "24"
@ -87,11 +89,11 @@ jobs:
run: run:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm

View file

@ -41,6 +41,7 @@ on:
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
merge_group:
permissions: permissions:
contents: read contents: read
@ -50,7 +51,7 @@ jobs:
name: ci / ci name: ci / ci
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install actionlint - name: Install actionlint
env: env:

View file

@ -31,6 +31,7 @@ on:
- "!.github/workflows/ci.yaml" - "!.github/workflows/ci.yaml"
- "!.github/workflows/labeler.yaml" - "!.github/workflows/labeler.yaml"
- "!.github/workflows/release-on-merge.yaml" - "!.github/workflows/release-on-merge.yaml"
- "!.github/workflows/auto-merge.yaml"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@ -57,7 +58,7 @@ jobs:
outputs: outputs:
version: ${{ steps.next.outputs.version }} version: ${{ steps.next.outputs.version }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Tags are the input to the version calculation, so they must be # Tags are the input to the version calculation, so they must be
# fetched; a shallow checkout without them would restart at 1.0.0. # fetched; a shallow checkout without them would restart at 1.0.0.

View file

@ -32,7 +32,7 @@ name: Release — Tag and GitHub Release
# Caller example: # Caller example:
# jobs: # jobs:
# release: # release:
# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # main # uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # v1.0.4
# with: # with:
# version: ${{ inputs.version }} # version: ${{ inputs.version }}
# #
@ -118,7 +118,7 @@ jobs:
released: ${{ steps.publish.outputs.released || 'false' }} released: ${{ steps.publish.outputs.released || 'false' }}
url: ${{ steps.publish.outputs.url }} url: ${{ steps.publish.outputs.url }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Full history + tags: the existing-tag guard reads local refs. # Full history + tags: the existing-tag guard reads local refs.
fetch-depth: 0 fetch-depth: 0

220
README.md
View file

@ -2,26 +2,79 @@
Organization-level GitHub configuration for Sea Haven Industries. Organization-level GitHub configuration for Sea Haven Industries.
## Git and PR conventions
### Branch naming
`feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description. Branch names do not contain Jira keys.
### Commit format
`type(scope): description` — lowercase, imperative, no trailing period, header ≤ 72 chars. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`, `release`. Breaking change: `feat!:` + `BREAKING CHANGE:` footer.
### PR title
`type(scope): description (DEV-123)` — maximum 120 characters, including the Jira suffix. Put the Jira key at the end in parentheses. A missing key is a warning, not a failure. Active projects: **DEV** (product), **PLAT** (platform), **SEC** (security). INFRA is a closed archive.
### PR body
Exactly four headings in order: `## Summary`, `## Validation`, `## Tests`, `## Notes`. Use `None.` under Notes if empty.
### Deploy path
The two sanctioned deploy paths are merge to `main` triggering the pipeline and `workflow_dispatch` on that same pipeline. No manual workstation deploys to production.
### Merge 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 ## What's in here
### Renovate preset
`default.json` is the file loaded by `local>Sea-Haven-Industries/.github`. It is intentionally empty of policy. Org Renovate rules live in `Sea-Haven-Industries/renovate-config` as `org-inherited-config.json`.
### Reusable Workflows ### Reusable Workflows
**`.github/workflows/ci-python-sam.yaml`** — Reusable CI workflow for Python / SAM repos. Runs `ruff check` + `ruff format --check`, optional `pytest`, and optional `sam validate --lint`. Also usable for Python CDK repos by disabling SAM validate. **`.github/workflows/ci-python-sam.yaml`** — Reusable CI workflow for Python / SAM repos. Runs `ruff check` + `ruff format --check`, optional `pytest`, and optional `sam validate --lint`. Also usable for Python CDK repos by disabling SAM validate.
**`.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-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 requested presets and any optional write commands, and pushes `style: apply formatter` only when the tree is dirty. Presets are `prettier` (`npm run format`), `eslint` (`npx eslint . --fix`, opt-in), `ruff` (`ruff format .` and `ruff check --fix .`), and `terraform` (`terraform fmt -recursive` in `terraform-working-directory`, default `terraform`). 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-hcp-static.yaml`** — HCP static-site CD for unfingerprinted builds such as Eleventy. Checkout at `ref`, Node 24, `npm ci --ignore-scripts` + `npm run build`, one-day cache on assets, `no-cache` on HTML/XML/text, prune, invalidate, verify the served index hash. Reads `/<prefix>/bucket` and `/<prefix>/distribution-id`. Do not use this for a hashed SPA.
**`.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`. **`.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`.
**`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds. **`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds.
**`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format, optional pytest; no SAM validate). **`.github/workflows/ci-python-app.yaml`** — Reusable CI for non-SAM Python apps (ruff check + format and conventions; no pytest, no SAM validate). Pytest stays a caller-owned job.
**`.github/workflows/ci-dotnet.yaml`** — Reusable CI for .NET solutions (`dotnet build`, optional `dotnet test`; SDK version and solution path as inputs). **`.github/workflows/ci-dotnet.yaml`** — Reusable CI for .NET solutions (`dotnet build`, optional `dotnet test`; SDK version and solution path as inputs).
**`.github/workflows/ci-static.yaml`** — Reusable CI for static sites (e.g. Eleventy builds for seahaven-site). **`.github/workflows/ci-static.yaml`** — Reusable CI for static sites (e.g. Eleventy builds for seahaven-site).
**`.github/workflows/ci-mobile-ios.yaml`** — Reusable CI for React Native iOS apps: dependency install, typecheck, optional lint and unit tests, and an unsigned compile (nothing uploaded). Emits the aggregated `ci / ci` status context.
**`.github/workflows/cd-mobile-ios.yaml`** — Reusable CD for iOS apps via Fastlane to TestFlight (Node + Ruby setup inputs). **`.github/workflows/cd-mobile-ios.yaml`** — Reusable CD for iOS apps via Fastlane to TestFlight (Node + Ruby setup inputs).
**`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping. **`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping.
@ -30,19 +83,31 @@ Organization-level GitHub configuration for Sea Haven Industries.
**`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph. **`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph.
**`.github/workflows/release.yaml`** — Reusable release workflow: creates an annotated git tag at a commit and publishes a GitHub Release pointing at it. The version is an input (not read from a manifest).
**`.github/workflows/release-on-merge.yaml`** — Repo automation (not callable): cuts a tag and GitHub Release for **this** repo whenever a merge to `main` changes a reusable workflow, so Dependabot has a release to advance consumer SHA pins to (see the pinning policy below).
**`.github/workflows/labeler.yaml`** — This repo's own thin caller of `callable-labeler.yaml`, so the labeler runs on `.github`'s own PRs.
**`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted. **`.github/workflows/ci.yaml`** — Self-CI for this repo: actionlint (checksum-verified install) over all workflow files, emitting the required `ci / ci` status context. Its shellcheck integration is enabled, so `run:` bodies are shell-linted too; the two deploy steps that rely on intentional word-splitting (`sam deploy … $PARAMS`, `cdk deploy $STACKS`) carry a per-line, commented `# shellcheck disable=SC2086` rather than being quoted or globally exempted.
### Workflow templates (`workflow-templates/`) ### Workflow templates (`workflow-templates/`)
Starter workflows offered on the org's **Actions → New workflow** page: `ci-python`, `ci-node`, `cdk-deploy`, `sam-deploy`, `dotnet-eb-deploy`, `dependency-review`, `labeler`, `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. 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`, `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.
### Action pinning policy ### Ref pinning policy
Third-party action refs across the org follow a tiered policy: All workflow refs across the org are pinned to full commit SHAs:
- **High-trust / high-blast-radius third-party actions are SHA-pinned** with a trailing version comment (e.g. `actions/labeler` in `callable-labeler.yaml`), and binary installs are checksum-verified (actionlint in `ci.yaml`). Dependabot keeps the SHA current via its trailing-comment mechanism. - **Org reusable workflows** are referenced at a **full commit SHA** of this repo with a trailing comment naming the ref or release the pin tracks:
- **Common first-party actions** (`actions/checkout`, `actions/dependency-review-action`, `actions/github-script`) are pinned to a **major tag** (`@v7`, `@v5`, …) and kept current by Dependabot version updates gated by CI.
- **Org reusable workflows** are referenced at **`@main`** (`uses: Sea-Haven-Industries/.github/.github/workflows/…@main`). This is deliberate: caller and callable share one trust domain, and pinning callers to a SHA would freeze every consumer against central fixes. Templates in `workflow-templates/` follow the same `@main` convention. ```yaml
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha> # v1.0.3
```
Branch refs are mutable: a bad commit on this repo would flow instantly into every consumer's CI and deploy path, while a SHA pin turns the same change into a reviewable Dependabot PR. Two prerequisites keep pins advancing instead of freezing: every consumer repo's `dependabot.yml` must include the `github-actions` ecosystem (weekly), and Dependabot must be granted access to this repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"); without the grant, update jobs fail with `git_dependencies_not_reachable` and pins freeze silently. `release-on-merge.yaml` tags this repo on every reusable-workflow change so Dependabot has releases to diff against. When adding a caller by hand, pin to the latest release commit (`gh api /repos/Sea-Haven-Industries/.github/commits/vX.Y.Z --jq .sha`), annotate it with `# vX.Y.Z`, and let Dependabot advance it from there.
- **Third-party and first-party actions** (`actions/checkout`, `actions/setup-python`, `actions/labeler`, …) — a subset are already SHA-pinned (e.g. `actions/labeler`, `aws-actions/*`, `docker/setup-qemu-action`, `ruby/setup-ruby`); the remainder (`actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-dotnet`, `actions/dependency-review-action`) currently use floating major-version tags. Full SHA pinning for this group is deferred (PLAT backlog); Dependabot will keep SHA and comment current once pins are set.
- **Binary installs are checksum-verified** (actionlint in `ci.yaml`).
### AWS deploy roles & IAM (`oidc-deploy-roles.yaml`) ### AWS deploy roles & IAM (`oidc-deploy-roles.yaml`)
@ -96,7 +161,7 @@ A function's effective permissions are the **intersection** of its own role poli
2. Redeploy the SAM stacks so their roles pick it up (while the exec role still permits it). 2. Redeploy the SAM stacks so their roles pick it up (while the exec role still permits it).
3. *Then* tighten the exec role. 3. *Then* tighten the exec role.
Wrong order breaks every SAM deploy. (History: INFRA-103 established the boundary, INFRA-97 scoped the role.) CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role. Wrong order breaks every SAM deploy. CDK repos are unaffected — they deploy via `cdk-hnb659fds-*` roles, not this execution role.
This ordering rule is about changing the **boundary** or the conditions that gate it. It does not apply to changes that only add permissions to the exec role. This ordering rule is about changing the **boundary** or the conditions that gate it. It does not apply to changes that only add permissions to the exec role.
@ -107,7 +172,7 @@ This ordering rule is about changing the **boundary** or the conditions that gat
- **Removing `PermissionsBoundary` from an existing role fails by design.** CloudFormation issues `DeleteRolePermissionsBoundary` for that edit, gets `AccessDenied`, and the stack update rolls back. Removing the boundary from a SAM function is a security regression, so failing loudly is intended. - **Removing `PermissionsBoundary` from an existing role fails by design.** CloudFormation issues `DeleteRolePermissionsBoundary` for that edit, gets `AccessDenied`, and the stack update rolls back. Removing the boundary from a SAM function is a security regression, so failing loudly is intended.
- **Rollback of an update that *adds* a boundary to an existing role would also fail**, landing the stack in `UPDATE_ROLLBACK_FAILED`. This is currently unreachable — all 26 IAM roles across the five SAM stacks already carry the boundary (verified 2026-07-27), so no update can add one. It becomes reachable again only if a role is created without the boundary and given one later. - **Rollback of an update that *adds* a boundary to an existing role would also fail**, landing the stack in `UPDATE_ROLLBACK_FAILED`. This is currently unreachable — all 26 IAM roles across the five SAM stacks already carry the boundary (verified 2026-07-27), so no update can add one. It becomes reachable again only if a role is created without the boundary and given one later.
Recovery in either case is an administrator action, not a pipeline retry: clear the wedged stack with `aws cloudformation continue-update-rollback --stack-name <stack> --resources-to-skip <RoleLogicalId>`, or replace the role by renaming its logical id. Note `cd-sam`'s pre-flight hard-fails on `*ROLLBACK_COMPLETE`, so that repo's deploys stay blocked until it is cleared. Recovery from `UPDATE_ROLLBACK_FAILED` is an administrator action, not a pipeline retry: clear the wedged stack with `aws cloudformation continue-update-rollback --stack-name <stack> --resources-to-skip <RoleLogicalId>`, or replace the role by renaming its logical id. A completed update rollback lands in `UPDATE_ROLLBACK_COMPLETE`, which is stable and can accept a corrective update; `cd-sam` blocks only first-create `ROLLBACK_COMPLETE` and failed or in-progress states.
## Setup ## Setup
@ -118,8 +183,10 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each
| Secret | Value | Consumed by | | Secret | Value | Consumed by |
|--------|-------|-------------| |--------|-------|-------------|
| `ANTHROPIC_API_KEY` | Anthropic API key | `reviewer-eval.yml` in `open-swe` | | `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 §3). 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.
### 2. Add CI to a repo ### 2. Add CI to a repo
@ -135,7 +202,7 @@ on:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
``` ```
**TypeScript CDK repo** (e.g., seahaven-door-unlock-api, seahaven-slack-bot): **TypeScript CDK repo** (e.g., seahaven-door-unlock-api, seahaven-slack-bot):
@ -148,7 +215,7 @@ on:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
``` ```
**Node.js SAM repo** (e.g., payments-dashboard): **Node.js SAM repo** (e.g., payments-dashboard):
@ -161,7 +228,7 @@ on:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with: with:
run-typecheck: false run-typecheck: false
run-cdk-synth: false run-cdk-synth: false
@ -178,19 +245,126 @@ on:
jobs: jobs:
python: python:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with: with:
source-dirs: "src" source-dirs: "src"
run-sam-validate: false run-sam-validate: false
typescript: typescript:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main 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:
presets: prettier,terraform
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 `presets: ruff,terraform`. The `eslint` preset is opt-in and runs `npx eslint . --fix`. Do not pass `npm run lint -- --fix`: several apps chain Redocly into `lint`. Enable `eslint` only when that repo's CI lint step is ESLint itself and Prettier owns formatting. Optional `format-command`, `lint-fix-command`, and `extra-command` still run after the presets. 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 CD to a repo ### 3. 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 ```yaml
name: Deploy name: Deploy
@ -200,7 +374,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with: with:
stack-name: afterhours-shift-manager stack-name: afterhours-shift-manager
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
@ -220,7 +394,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
secrets: secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
``` ```
@ -235,7 +409,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with: with:
python-version: "3.12" python-version: "3.12"
cdk-dir: cdk cdk-dir: cdk
@ -253,7 +427,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with: with:
enable-qemu: true enable-qemu: true
secrets: secrets:
@ -270,7 +444,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@main uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with: with:
project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj
eb-application: shoc-backend eb-application: shoc-backend

View file

@ -4,7 +4,8 @@ Sea-Haven-Industries repositories are private and intended for internal Sea Have
## Where to go ## Where to go
- **Bugs, feature requests, infrastructure work** — file a ticket in Jira (**INFRA** project) or open an issue on the relevant repository. - **Bugs and feature requests** — file a ticket in Jira (**DEV**, **PLAT**, or **SEC** depending on scope). GitHub Issues are active only on shoc-backend, shoc-frontend-new, and open-swe (contractor/fork intake).
- **Infrastructure and platform work** — use the **PLAT** project. Security issues go in **SEC**.
- **Operational or urgent issues** — contact Adam Moussa (adam@seahavenind.com). - **Operational or urgent issues** — contact Adam Moussa (adam@seahavenind.com).
- **Engineering conventions and standards** — see the [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook). - **Engineering conventions and standards** — see the [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook).
- **Security vulnerabilities** — follow [SECURITY.md](SECURITY.md) (do not open a public issue). - **Security vulnerabilities** — follow [SECURITY.md](SECURITY.md) (do not open a public issue).

3
default.json Normal file
View file

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View file

@ -7,6 +7,10 @@ Parameters:
GitHubOrg: GitHubOrg:
Type: String Type: String
Default: Sea-Haven-Industries Default: Sea-Haven-Industries
# No glob metacharacters: this value is interpolated into StringLike trust
# conditions, where a '*' override would silently open every role's trust
# to any GitHub org with a same-named repo.
AllowedPattern: "^[A-Za-z0-9-]+$"
CreateOIDCProvider: CreateOIDCProvider:
Type: String Type: String
Default: "false" Default: "false"
@ -52,6 +56,7 @@ Resources:
# - DynamoDB CRUD (afterhours-shifts table) # - DynamoDB CRUD (afterhours-shifts table)
# - secretsmanager:GetSecretValue (afterhours-shift-manager/*) # - secretsmanager:GetSecretValue (afterhours-shift-manager/*)
# - ses:SendEmail (SES identity) # - ses:SendEmail (SES identity)
# - sqs:SendMessage (paychex-checkcomponents in seahaven-prod, WeeklyPost)
# - CloudWatch Logs (all functions) # - CloudWatch Logs (all functions)
# #
# payments-dashboard # payments-dashboard
@ -205,6 +210,25 @@ Resources:
Resource: Resource:
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:*" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:*"
# ── SQS cross-account send (afterhours WeeklyPost -> paychex) ─────
# afterhours-shift-manager WeeklyPostFunction enqueues the weekly
# after-hours pay payload onto paychex-integrations' checkcomponents
# queue in seahaven-prod (PLAT-135). Send only. This is a ceiling,
# not a grant: the function's inline policy already allows this ARN
# and the prod queue policy admits only WeeklyPostFunctionRole-*, so
# the boundary was the one missing piece. The PrincipalArn condition
# keeps the ceiling closed for every other role on this boundary even
# if the queue policy is later loosened.
- Sid: SQSPaychexCheckcomponentsSend
Effect: Allow
Action:
- sqs:SendMessage
Resource:
- arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents
Condition:
ArnLike:
aws:PrincipalArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/afterhours-shift-manager-WeeklyPostFunctionRole-*"
# ── Lambda invocation (payments, meal-order inter-function calls) ── # ── Lambda invocation (payments, meal-order inter-function calls) ──
- Sid: LambdaInvoke - Sid: LambdaInvoke
Effect: Allow Effect: Allow
@ -334,30 +358,17 @@ Resources:
StringEquals: StringEquals:
"iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary"
# Boundary management — SET the boundary only. DELETE is NOT # Boundary management is SET-only. Granting delete would let this
# granted: for a delete, the iam:PermissionsBoundary condition key # role create a boundary-gated role, strip the boundary, then pass an
# reflects the boundary CURRENTLY attached to the target role, so # unconstrained role to Lambda. SAM creation and teardown need only
# a StringEquals condition on the boundary ARN MATCHES exactly the # PutRolePermissionsBoundary and DeleteRole.
# roles the gate protects. Granting delete under that condition
# lets this role create a boundary-gated role with an inline *:*
# policy, strip the boundary, and pass the now-unbounded role to
# Lambda — defeating the primary escalation control. Verified live
# against the mgmt copy 2026-07-27 (simulate-principal-policy:
# iam:DeleteRolePermissionsBoundary = allowed).
# #
# OPERATIONAL CONSEQUENCE — read before debugging a stuck stack. # Removing a boundary therefore fails by design. A failed rollback
# SAM does not need the delete for the common paths: it SETS the # reaches UPDATE_ROLLBACK_FAILED and needs admin recovery by skipping
# boundary on roles it creates, and stack teardown calls DeleteRole. # or replacing the role. A successful rollback reaches the stable
# But there IS one path that now fails by design: updating an # UPDATE_ROLLBACK_COMPLETE state and can accept a corrective update.
# existing AWS::IAM::Role to REMOVE its PermissionsBoundary property # Removing a SAM function boundary is a security regression, so
# makes CloudFormation call DeleteRolePermissionsBoundary, which is # failing loudly is intentional.
# denied. The stack update fails and rolls back, and because cd-sam's
# pre-flight hard-fails on *ROLLBACK_COMPLETE, that repo's deploys
# stay blocked until it is cleared. Recovery is an out-of-band admin
# action (remove the boundary directly, or replace the role by
# renaming its logical id) — not a pipeline retry. Removing the
# boundary from a SAM function is a security regression anyway, so
# failing loudly here is the intent.
- Sid: IAMPutPermissionsBoundary - Sid: IAMPutPermissionsBoundary
Effect: Allow Effect: Allow
Action: Action:
@ -475,6 +486,19 @@ Resources:
StringEquals: StringEquals:
"iam:PassedToService": "lambda.amazonaws.com" "iam:PassedToService": "lambda.amazonaws.com"
# API Gateway assumes SAM authorizer invocation roles. Keep this
# separate from Lambda PassRole so each target service and role
# pattern remains independently constrained.
- Sid: IAMPassAuthorizerRole
Effect: Allow
Action:
- iam:PassRole
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cfn-managed/*AuthorizerInvokeRole-*"
Condition:
StringEquals:
"iam:PassedToService": "apigateway.amazonaws.com"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped # Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped
# #
@ -1027,146 +1051,6 @@ Resources:
Resource: Resource:
- !GetAtt SamCfnExecutionRole.Arn - !GetAtt SamCfnExecutionRole.Arn
FrontIntegrationsDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-front-integrations
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/front-integrations:ref:refs/heads/main
Policies:
- PolicyName: sam-deploy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- cloudformation:CreateChangeSet
- cloudformation:DeleteChangeSet
- cloudformation:DescribeChangeSet
- cloudformation:DescribeStackEvents
- cloudformation:DescribeStacks
- cloudformation:ExecuteChangeSet
- cloudformation:GetTemplate
- cloudformation:ListStackResources
- cloudformation:UpdateStack
- cloudformation:CreateStack
- cloudformation:TagResource
Resource:
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/front-integrations/*
- Effect: Allow
Action:
- cloudformation:GetTemplateSummary
Resource: "*"
- Effect: Allow
Action:
- cloudformation:DescribeStacks
- cloudformation:CreateChangeSet
- cloudformation:DescribeChangeSet
- cloudformation:ExecuteChangeSet
- cloudformation:CreateStack
Resource:
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*
- Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:ListBucket
- s3:GetBucketLocation
- s3:CreateBucket
- s3:PutBucketPolicy
- s3:GetBucketPolicy
- s3:PutLifecycleConfiguration
- s3:PutBucketVersioning
- s3:DeleteObject
Resource:
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*
- Effect: Allow
Action:
- iam:PassRole
Resource:
- !GetAtt SamCfnExecutionRole.Arn
AfiBackupMonitorDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-afi-backup-monitor
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/afi-backup-monitor:ref:refs/heads/main
Policies:
- PolicyName: sam-deploy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- cloudformation:CreateChangeSet
- cloudformation:DeleteChangeSet
- cloudformation:DescribeChangeSet
- cloudformation:DescribeStackEvents
- cloudformation:DescribeStacks
- cloudformation:ExecuteChangeSet
- cloudformation:GetTemplate
- cloudformation:ListStackResources
- cloudformation:UpdateStack
- cloudformation:CreateStack
- cloudformation:TagResource
Resource:
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/afi-backup-monitor/*
- Effect: Allow
Action:
- cloudformation:GetTemplateSummary
Resource: "*"
- Effect: Allow
Action:
- cloudformation:DescribeStacks
- cloudformation:CreateChangeSet
- cloudformation:DescribeChangeSet
- cloudformation:ExecuteChangeSet
- cloudformation:CreateStack
Resource:
- !Sub arn:aws:cloudformation:us-east-1:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*
- Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:ListBucket
- s3:GetBucketLocation
- s3:CreateBucket
- s3:PutBucketPolicy
- s3:GetBucketPolicy
- s3:PutLifecycleConfiguration
- s3:PutBucketVersioning
- s3:DeleteObject
Resource:
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*
- arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*
- Effect: Allow
Action:
- iam:PassRole
Resource:
- !GetAtt SamCfnExecutionRole.Arn
PaymentsDashboardDeployRole: PaymentsDashboardDeployRole:
Type: AWS::IAM::Role Type: AWS::IAM::Role
Properties: Properties:
@ -1238,117 +1122,12 @@ Resources:
- !GetAtt SamCfnExecutionRole.Arn - !GetAtt SamCfnExecutionRole.Arn
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CDK deploy roles (4 repos) # CDK deploy role for seahaven-org-baseline.
# Soaked githubdeploy roles for front-integrations, afi-backup-monitor,
# exec-aide, seahaven-door-unlock-api, and apm-wo-analysis are removed
# here (PLAT-232). Deploying this stack deletes those roles.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ExecAideDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-exec-aide
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/exec-aide:ref:refs/heads/main
Policies:
- PolicyName: cdk-deploy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- sts:AssumeRole
Resource:
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
SeahavenDoorUnlockApiDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-seahaven-door-unlock-api
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-door-unlock-api:ref:refs/heads/main
Policies:
- PolicyName: cdk-deploy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- sts:AssumeRole
Resource:
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
ProcurementIngestDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-procurement-ingest
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/procurement-ingest:ref:refs/heads/main
Policies:
- PolicyName: cdk-deploy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- sts:AssumeRole
Resource:
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
ApmWoAnalysisDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-apm-wo-analysis
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/apm-wo-analysis:ref:refs/heads/main
Policies:
- PolicyName: cdk-deploy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- sts:AssumeRole
Resource:
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
SeahavenAccountBaselineDeployRole: SeahavenAccountBaselineDeployRole:
Type: AWS::IAM::Role Type: AWS::IAM::Role
Properties: Properties:
@ -1376,6 +1155,61 @@ Resources:
Resource: Resource:
- !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-* - !Sub arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*
# MealOrderManagerWeeklyMenuRole removed 2026-08-20 (PLAT-70):
# weekly-menu OIDC role now lives in seahaven-prod as
# /tf-managed/githubdeploy-meal-order-manager-weekly-menu (HCP TF).
# GitHub secret AWS_WEEKLY_MENU_ROLE_ARN already points at the prod role.
# Mgmt role github-meal-order-manager-weekly-menu deleted with this stack update.
# PLAT-234 principal only. The checks are seahaven-org-baseline pull request
# 160: .github/workflows/ci.yaml job iam-policy-check and
# scripts/check_iam_policies.py. That job assumes this role. It asserts
# StringEquals on the bootstrap trust templates, no lambda write on the
# plan template, then ValidatePolicy and CheckNoNewAccess when this role
# can be assumed.
SeahavenOrgBaselinePolicyCheckRole:
Type: AWS::IAM::Role
Properties:
RoleName: githubdeploy-seahaven-org-baseline-policy-check
Description: Access Analyzer policy checks for seahaven-org-baseline CI. No deploy permissions.
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
# pull_request jobs with no environment use sub
# repo:ORG/seahaven-org-baseline:pull_request. refs/pull/N/merge is
# the ref claim, not sub. A second statement is required: StringEquals
# and StringLike in one condition are AND.
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:pull_request
- Effect: Allow
Principal:
Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/seahaven-org-baseline:ref:refs/heads/gh-readonly-queue/main/*
Policies:
- PolicyName: access-analyzer-policy-check
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AccessAnalyzerPolicyCheck
Effect: Allow
Action:
- access-analyzer:ValidatePolicy
- access-analyzer:CheckNoNewAccess
Resource: "*"
Outputs: Outputs:
LambdaExecutionBoundaryArn: LambdaExecutionBoundaryArn:
Value: !Ref LambdaExecutionBoundary Value: !Ref LambdaExecutionBoundary
@ -1390,23 +1224,20 @@ Outputs:
Name: github-cfn-execution-role-arn Name: github-cfn-execution-role-arn
AfterhoursShiftManagerDeployRoleArn: AfterhoursShiftManagerDeployRoleArn:
Value: !GetAtt AfterhoursShiftManagerDeployRole.Arn Value: !GetAtt AfterhoursShiftManagerDeployRole.Arn
FrontIntegrationsDeployRoleArn:
Value: !GetAtt FrontIntegrationsDeployRole.Arn
AfiBackupMonitorDeployRoleArn:
Value: !GetAtt AfiBackupMonitorDeployRole.Arn
PaymentsDashboardDeployRoleArn: PaymentsDashboardDeployRoleArn:
Value: !GetAtt PaymentsDashboardDeployRole.Arn Value: !GetAtt PaymentsDashboardDeployRole.Arn
# SeahavenSlackBotDeployRoleArn removed 2026-07-27: the role was deleted # SeahavenSlackBotDeployRoleArn removed 2026-07-27: the role was deleted
# out-of-band on 2026-07-23, so this !GetAtt failed as a live IAM read and # out-of-band on 2026-07-23, so this !GetAtt failed as a live IAM read and
# broke every stack update. Nothing imported it (the Output had no # broke every stack update. Nothing imported it (the Output had no
# ExportName, and no stack imports any export from this stack). # ExportName, and no stack imports any export from this stack).
ExecAideDeployRoleArn: # ProcurementIngestDeployRoleArn removed 2026-08-07 (PLAT-88): HCP sole
Value: !GetAtt ExecAideDeployRole.Arn # mutate path; prod githubdeploy role deleted; mgmt twin already gone.
SeahavenDoorUnlockApiDeployRoleArn: # FrontIntegrations, AfiBackupMonitor, ExecAide, SeahavenDoorUnlockApi,
Value: !GetAtt SeahavenDoorUnlockApiDeployRole.Arn # and ApmWoAnalysis deploy role outputs removed 2026-09-28 (PLAT-232).
ProcurementIngestDeployRoleArn: # CloudTrail showed no successful mutation for 14 days. The roles are
Value: !GetAtt ProcurementIngestDeployRole.Arn # deleted only when this stack is deployed. That deploy is not this change.
ApmWoAnalysisDeployRoleArn:
Value: !GetAtt ApmWoAnalysisDeployRole.Arn
SeahavenAccountBaselineDeployRoleArn: SeahavenAccountBaselineDeployRoleArn:
Value: !GetAtt SeahavenAccountBaselineDeployRole.Arn Value: !GetAtt SeahavenAccountBaselineDeployRole.Arn
SeahavenOrgBaselinePolicyCheckRoleArn:
Value: !GetAtt SeahavenOrgBaselinePolicyCheckRole.Arn
# MealOrderManagerWeeklyMenuRoleArn removed 2026-08-20 (PLAT-70): role deleted with resource.

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the # Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
# reusable workflow's default — passed explicitly to pin against drift. # reusable workflow's default — passed explicitly to pin against drift.

View file

@ -2,6 +2,7 @@ name: CI (.NET)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
@ -11,4 +12,4 @@ jobs:
# Every input is optional. Common overrides: `solution` (defaults to *.sln # Every input is optional. Common overrides: `solution` (defaults to *.sln
# in the working directory), `working-directory`, and `dotnet-version` # in the working directory), `working-directory`, and `dotnet-version`
# (defaults to 8.0.x). This reusable has no `node-version` input. # (defaults to 8.0.x). This reusable has no `node-version` input.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

View 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$"]
}

View file

@ -0,0 +1,52 @@
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:
presets: prettier,terraform
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

View file

@ -2,12 +2,13 @@ name: CI (Mobile / iOS)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the # Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
# check context resolves to the required `ci / ci`. # check context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also # Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also
# the reusable workflow's default — passed explicitly to pin against drift. # the reusable workflow's default — passed explicitly to pin against drift.

View file

@ -2,10 +2,11 @@ name: CI (Node / TypeScript)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the # Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also the
# reusable workflow's default — passed explicitly to pin against drift. # reusable workflow's default — passed explicitly to pin against drift.

View file

@ -1,6 +1,6 @@
{ {
"name": "Sea Haven — CI (Python / app)", "name": "Sea Haven — CI (Python / app)",
"description": "Runs ruff check, ruff format --check, a pytest collect-only import check, and an optional subproject suite via the org reusable workflow. For Python repos that do not deploy via SAM or CDK.", "description": "Runs ruff check, ruff format --check, and a conventions audit via the org reusable workflow. For Python repos that do not deploy via SAM or CDK.",
"iconName": "octicon-checklist", "iconName": "octicon-checklist",
"categories": ["Python", "Continuous integration"], "categories": ["Python", "Continuous integration"],
"filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"] "filePatterns": ["requirements.*\\.txt$", "pyproject\\.toml$"]

View file

@ -2,13 +2,12 @@ name: CI (Python / app)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the # Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
# check context resolves to the required `ci / ci`. # check context resolves to the required `ci / ci`.
# #
# Every input is optional. Common overrides: `source-dirs` (ruff targets), # Every input is optional. Common override: `source-dirs` (ruff targets).
# `requirements` (non-default requirements file), `subproject-dir` (a uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
# self-contained suite that must run in its own working directory).
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@555d07c3a240689a81668026787eba089df4c975 # main

View file

@ -2,10 +2,11 @@ name: CI (Python / SAM)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
run-tests: true run-tests: true
# ci-python-sam.yaml declares a `node-version` input (default "24") that # ci-python-sam.yaml declares a `node-version` input (default "24") that

View file

@ -2,12 +2,13 @@ name: CI (Static Site)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check # Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
# context resolves to the required `ci / ci`. # context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which # Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
# generates lockfileVersion 3. Being explicit avoids lockfile drift. # generates lockfileVersion 3. Being explicit avoids lockfile drift.

View 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$"]
}

View 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"

View file

@ -1,6 +1,6 @@
{ {
"name": "Sea Haven — CI (TypeScript / frontend)", "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", "iconName": "octicon-checklist",
"categories": ["TypeScript", "JavaScript", "Continuous integration"], "categories": ["TypeScript", "JavaScript", "Continuous integration"],
"filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"] "filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"]

View file

@ -2,12 +2,13 @@ name: CI (TypeScript / frontend)
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
jobs: jobs:
ci: ci:
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check # Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
# context resolves to the required `ci / ci`. # context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which # Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
# generates lockfileVersion 3. Being explicit avoids lockfile drift. # generates lockfileVersion 3. Being explicit avoids lockfile drift.

View file

@ -8,4 +8,4 @@ permissions:
jobs: jobs:
dependency-review: dependency-review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Required: the project to publish, relative to the repo root. # Required: the project to publish, relative to the repo root.
project: REPLACE-ME-project-csproj project: REPLACE-ME-project-csproj

View 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$"]
}

View 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"}'

View 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$"]
}

View 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"

View file

@ -13,4 +13,4 @@ permissions:
jobs: jobs:
label: label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Pinned explicitly (handbook): local dev is Node 24 / npm 11, which # Pinned explicitly (handbook): local dev is Node 24 / npm 11, which
# generates lockfileVersion 3. Being explicit avoids lockfile drift. # generates lockfileVersion 3. Being explicit avoids lockfile drift.

View file

@ -13,7 +13,7 @@ permissions:
jobs: jobs:
release: release:
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
version: ${{ inputs.version }} version: ${{ inputs.version }}
# Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default # Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
deploy: deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with: with:
# Required: the CloudFormation stack name (kebab-case, matches repo name). # Required: the CloudFormation stack name (kebab-case, matches repo name).
# NOTE: this is a literal placeholder on purpose — starter-workflow variables # NOTE: this is a literal placeholder on purpose — starter-workflow variables