Skip to content

Commit 718f8be

Browse files
committed
feat(workflows): add terraform/ecs/argocd/helm-lint reusables; decouple cosign identity; fix sbom secrets-if + helm-publish quoting; sync docs to reality
1 parent 9e3dbea commit 718f8be

14 files changed

Lines changed: 1350 additions & 1123 deletions

.github/workflows/argocd-sync.yml

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
name: 'ArgoCD Sync & Wait'
2+
3+
# Waits for an ArgoCD Application to reach Synced + Healthy (optionally triggering
4+
# an explicit sync first). The wait can be scoped to specific resources to avoid
5+
# hanging on unrelated `Progressing` objects. The runner must be able to reach the
6+
# ArgoCD API (typically an in-cluster self-hosted runner), hence the homelab-runners
7+
# default — override `runs-on` for other clusters.
8+
9+
on:
10+
workflow_call:
11+
inputs:
12+
app-name:
13+
description: 'ArgoCD Application name'
14+
required: true
15+
type: string
16+
argocd-server:
17+
description: 'ArgoCD server address (e.g. argocd-server.argocd.svc.cluster.local:80)'
18+
required: true
19+
type: string
20+
resources:
21+
description: 'Newline-separated "group:Kind:name" specs to scope the wait (optional)'
22+
required: false
23+
type: string
24+
default: ''
25+
sync:
26+
description: 'Run an explicit `argocd app sync` before waiting'
27+
required: false
28+
type: boolean
29+
default: false
30+
sync-timeout:
31+
description: 'Seconds to wait for Synced'
32+
required: false
33+
type: number
34+
default: 300
35+
health-timeout:
36+
description: 'Seconds to wait for Healthy'
37+
required: false
38+
type: number
39+
default: 300
40+
plaintext:
41+
description: 'Dial the ArgoCD API over plaintext h2c (in-cluster :80)'
42+
required: false
43+
type: boolean
44+
default: true
45+
runs-on:
46+
description: 'Runner label to execute the job on (must reach the ArgoCD API)'
47+
required: false
48+
type: string
49+
default: 'homelab-runners'
50+
secrets:
51+
argocd-token:
52+
description: 'ArgoCD auth token'
53+
required: true
54+
outputs:
55+
result:
56+
description: 'Sync/wait result (success|failure)'
57+
value: ${{ jobs.sync.outputs.result }}
58+
59+
permissions:
60+
contents: read
61+
62+
jobs:
63+
sync:
64+
name: ArgoCD Sync & Wait
65+
runs-on: ${{ inputs.runs-on }}
66+
timeout-minutes: 15
67+
env:
68+
ARGOCD_SERVER: ${{ inputs.argocd-server }}
69+
ARGOCD_AUTH_TOKEN: ${{ secrets.argocd-token }}
70+
ARGOCD_OPTS: ${{ inputs.plaintext && '--plaintext' || '' }}
71+
APP: ${{ inputs.app-name }}
72+
outputs:
73+
result: ${{ steps.done.outputs.result }}
74+
steps:
75+
- name: Install ArgoCD CLI
76+
run: |
77+
set -euo pipefail
78+
sudo curl -fsSL -o /usr/local/bin/argocd \
79+
https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
80+
sudo chmod +x /usr/local/bin/argocd
81+
argocd version --client --short
82+
83+
- name: Sync and wait
84+
env:
85+
RESOURCES: ${{ inputs.resources }}
86+
DO_SYNC: ${{ inputs.sync }}
87+
SYNC_TIMEOUT: ${{ inputs.sync-timeout }}
88+
HEALTH_TIMEOUT: ${{ inputs.health-timeout }}
89+
run: |
90+
set -euo pipefail
91+
res=()
92+
while IFS= read -r r; do
93+
[ -n "$r" ] && res+=(--resource "$r")
94+
done <<< "$RESOURCES"
95+
96+
if [ "$DO_SYNC" = "true" ]; then
97+
echo "argocd app sync $APP"
98+
argocd app sync "$APP" --timeout "$SYNC_TIMEOUT" "${res[@]}"
99+
fi
100+
101+
echo "Stage 1/2: wait --sync (max ${SYNC_TIMEOUT}s)"
102+
argocd app wait "$APP" --sync --timeout "$SYNC_TIMEOUT" "${res[@]}"
103+
104+
echo "Stage 2/2: wait --health (max ${HEALTH_TIMEOUT}s)"
105+
argocd app wait "$APP" --health --timeout "$HEALTH_TIMEOUT" "${res[@]}"
106+
107+
- name: Diagnostics on failure
108+
if: failure()
109+
run: |
110+
argocd app get "$APP" --output wide || true
111+
argocd app resources "$APP" || true
112+
113+
- name: Record result
114+
id: done
115+
if: always()
116+
run: echo "result=${{ job.status }}" >> "$GITHUB_OUTPUT"
117+
118+
- name: Summary
119+
if: always()
120+
run: |
121+
{
122+
echo "## 🐙 ArgoCD Sync & Wait"
123+
echo ""
124+
echo "**App:** \`${{ inputs.app-name }}\`"
125+
echo "**Server:** \`${{ inputs.argocd-server }}\`"
126+
echo "**Result:** \`${{ job.status }}\`"
127+
} >> "$GITHUB_STEP_SUMMARY"

.github/workflows/docker-build-push.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ on:
7272
required: false
7373
type: string
7474
default: 'homelab-runners'
75+
cosign-identity-regexp:
76+
description: 'Cosign certificate identity regexp for verification. Defaults to this (git-flow) reusable workflow repo, since the OIDC identity of a reusable workflow is its own path, not the caller. Override when forking git-flow under another org.'
77+
required: false
78+
type: string
79+
default: 'https://github.com/samuelho-dev/git-flow/.github/workflows/.*'
7580
secrets:
7681
registry-username:
7782
description: 'Registry username'
@@ -244,7 +249,7 @@ jobs:
244249
# For reusable workflows, the signature identity is the workflow file path
245250
# not the calling repository, so we use a pattern matching either
246251
cosign verify \
247-
--certificate-identity-regexp="https://github.com/samuelho-dev/git-flow/.github/workflows/.*" \
252+
--certificate-identity-regexp="${{ inputs.cosign-identity-regexp }}" \
248253
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
249254
${{ inputs.registry }}/${{ github.repository_owner }}/${{ inputs.image }}@${{ steps.build.outputs.digest }}
250255

.github/workflows/ecs-deploy.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: 'ECS Express Deploy'
2+
3+
# Deploys a container image to an Amazon ECS Express Mode service via GitHub OIDC.
4+
# Image promotion is by tag/digest — the same image built once is deployed across
5+
# environments. For classic ECS services (task-definition based) add a `mode`
6+
# input later; today only Express Mode is needed.
7+
8+
on:
9+
workflow_call:
10+
inputs:
11+
service-name:
12+
description: 'ECS Express service name'
13+
required: true
14+
type: string
15+
image:
16+
description: 'Full image reference to deploy (registry/repo:tag)'
17+
required: true
18+
type: string
19+
execution-role-arn:
20+
description: 'ECS task execution role ARN'
21+
required: true
22+
type: string
23+
infrastructure-role-arn:
24+
description: 'ECS infrastructure role ARN for Express Mode'
25+
required: true
26+
type: string
27+
aws-region:
28+
description: 'AWS region'
29+
required: false
30+
type: string
31+
default: 'us-west-2'
32+
environment:
33+
description: 'GitHub deployment environment for protection rules (optional)'
34+
required: false
35+
type: string
36+
default: ''
37+
runs-on:
38+
description: 'Runner label to execute the job on'
39+
required: false
40+
type: string
41+
default: 'ubuntu-latest'
42+
secrets:
43+
aws-role-arn:
44+
description: 'IAM role ARN to assume via GitHub OIDC'
45+
required: true
46+
outputs:
47+
image:
48+
description: 'The image reference that was deployed'
49+
value: ${{ inputs.image }}
50+
51+
permissions:
52+
contents: read
53+
id-token: write
54+
55+
jobs:
56+
deploy:
57+
name: ECS Express Deploy
58+
runs-on: ${{ inputs.runs-on }}
59+
environment: ${{ inputs.environment }}
60+
timeout-minutes: 20
61+
steps:
62+
- name: Configure AWS credentials
63+
uses: aws-actions/configure-aws-credentials@v4 # Renovate pins to SHA
64+
with:
65+
role-to-assume: ${{ secrets.aws-role-arn }}
66+
aws-region: ${{ inputs.aws-region }}
67+
68+
- name: Deploy to ECS Express Mode
69+
uses: aws-actions/amazon-ecs-deploy-express-service@v1 # Renovate pins to SHA
70+
with:
71+
service-name: ${{ inputs.service-name }}
72+
image: ${{ inputs.image }}
73+
execution-role-arn: ${{ inputs.execution-role-arn }}
74+
infrastructure-role-arn: ${{ inputs.infrastructure-role-arn }}
75+
76+
- name: Summary
77+
if: always()
78+
run: |
79+
{
80+
echo "## 🚀 ECS Express Deploy"
81+
echo ""
82+
echo "**Service:** \`${{ inputs.service-name }}\`"
83+
echo "**Image:** \`${{ inputs.image }}\`"
84+
echo "**Region:** \`${{ inputs.aws-region }}\`"
85+
echo "**Environment:** \`${{ inputs.environment || 'none' }}\`"
86+
} >> "$GITHUB_STEP_SUMMARY"

.github/workflows/ecs-smoke.yml

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
name: 'Service Smoke Check'
2+
3+
# Polls a JSON /health endpoint until it returns an accepted status (and optional
4+
# version match). `soak-passes` controls how many CONSECUTIVE good probes are
5+
# required: 1 = first-success smoke after a deploy; N = an N-probe soak window for
6+
# a canary gate. Fails the job if the target never satisfies the criteria.
7+
8+
on:
9+
workflow_call:
10+
inputs:
11+
url:
12+
description: 'Base URL of the service (e.g. https://api.creativetoolkits.com)'
13+
required: true
14+
type: string
15+
path:
16+
description: 'Health endpoint path'
17+
required: false
18+
type: string
19+
default: '/health'
20+
expected-version:
21+
description: 'Require payload.version to equal this value (optional)'
22+
required: false
23+
type: string
24+
default: ''
25+
accept-status:
26+
description: 'Comma-separated payload.status values treated as healthy'
27+
required: false
28+
type: string
29+
default: 'ok,degraded'
30+
expected-http:
31+
description: 'Required HTTP status code'
32+
required: false
33+
type: number
34+
default: 200
35+
retries:
36+
description: 'Maximum number of probe attempts'
37+
required: false
38+
type: number
39+
default: 20
40+
interval-seconds:
41+
description: 'Seconds between probes'
42+
required: false
43+
type: number
44+
default: 15
45+
soak-passes:
46+
description: 'Consecutive successful probes required (1 = smoke, N = soak)'
47+
required: false
48+
type: number
49+
default: 1
50+
runs-on:
51+
description: 'Runner label to execute the job on'
52+
required: false
53+
type: string
54+
default: 'ubuntu-latest'
55+
outputs:
56+
ok:
57+
description: 'true when the smoke check passed'
58+
value: ${{ jobs.smoke.outputs.ok }}
59+
version:
60+
description: 'Last observed payload.version'
61+
value: ${{ jobs.smoke.outputs.version }}
62+
63+
permissions:
64+
contents: read
65+
66+
jobs:
67+
smoke:
68+
name: Service Smoke Check
69+
runs-on: ${{ inputs.runs-on }}
70+
timeout-minutes: 20
71+
outputs:
72+
ok: ${{ steps.poll.outputs.ok }}
73+
version: ${{ steps.poll.outputs.version }}
74+
steps:
75+
- name: Poll health endpoint
76+
id: poll
77+
env:
78+
BASE_URL: ${{ inputs.url }}
79+
HEALTH_PATH: ${{ inputs.path }}
80+
EXPECTED_VERSION: ${{ inputs.expected-version }}
81+
ACCEPT_STATUS: ${{ inputs.accept-status }}
82+
EXPECTED_HTTP: ${{ inputs.expected-http }}
83+
RETRIES: ${{ inputs.retries }}
84+
INTERVAL: ${{ inputs.interval-seconds }}
85+
SOAK_PASSES: ${{ inputs.soak-passes }}
86+
run: |
87+
python3 - <<'PY'
88+
import json, os, sys, time, urllib.error, urllib.parse, urllib.request
89+
90+
base = os.environ["BASE_URL"].rstrip("/")
91+
path = os.environ["HEALTH_PATH"]
92+
url = base + (path if path.startswith("/") else "/" + path)
93+
expected_version = os.environ.get("EXPECTED_VERSION", "")
94+
accept = [s.strip() for s in os.environ.get("ACCEPT_STATUS", "ok,degraded").split(",") if s.strip()]
95+
expected_http = int(os.environ.get("EXPECTED_HTTP", "200"))
96+
retries = int(os.environ.get("RETRIES", "20"))
97+
interval = int(os.environ.get("INTERVAL", "15"))
98+
soak = int(os.environ.get("SOAK_PASSES", "1"))
99+
100+
def probe():
101+
sep = "&" if urllib.parse.urlsplit(url).query else "?"
102+
probe_url = f"{url}{sep}ci_probe={time.time_ns()}"
103+
req = urllib.request.Request(probe_url, headers={"Cache-Control": "no-cache"})
104+
try:
105+
with urllib.request.urlopen(req, timeout=10) as resp:
106+
code, body = resp.status, resp.read().decode("utf-8")
107+
except (OSError, urllib.error.URLError) as err:
108+
return False, f"request failed: {err}", ""
109+
if code != expected_http:
110+
return False, f"HTTP {code}: {body[:300]}", ""
111+
try:
112+
payload = json.loads(body)
113+
except json.JSONDecodeError as err:
114+
return False, f"invalid JSON: {err}", ""
115+
status = payload.get("status")
116+
version = payload.get("version") or ""
117+
if accept and status not in accept:
118+
return False, f"status={status!r} not in {accept}", version
119+
if expected_version and version != expected_version:
120+
return False, f"version={version!r} != {expected_version!r}", version
121+
return True, f"status={status}, version={version}", version
122+
123+
def write_output(key, value):
124+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
125+
handle.write(f"{key}={value}\n")
126+
127+
passes = 0
128+
last_version = ""
129+
for attempt in range(1, retries + 1):
130+
ok, message, version = probe()
131+
last_version = version or last_version
132+
print(f"attempt {attempt}/{retries}: {'OK' if ok else 'FAIL'} - {message}")
133+
if ok:
134+
passes += 1
135+
if passes >= soak:
136+
write_output("ok", "true")
137+
write_output("version", last_version)
138+
print(f"smoke passed ({passes}/{soak} consecutive)")
139+
sys.exit(0)
140+
else:
141+
passes = 0
142+
if attempt < retries:
143+
time.sleep(interval)
144+
145+
write_output("ok", "false")
146+
write_output("version", last_version)
147+
print("::error::smoke check did not pass within the retry budget")
148+
sys.exit(1)
149+
PY
150+
151+
- name: Summary
152+
if: always()
153+
run: |
154+
{
155+
echo "## 🩺 Service Smoke Check"
156+
echo ""
157+
echo "**URL:** \`${{ inputs.url }}${{ inputs.path }}\`"
158+
echo "**Soak passes:** \`${{ inputs.soak-passes }}\`"
159+
echo "**OK:** \`${{ steps.poll.outputs.ok }}\`"
160+
echo "**Version:** \`${{ steps.poll.outputs.version }}\`"
161+
} >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)