Skip to content

Commit 0dcbc6e

Browse files
ci: app sync workflows with per-environment (#252)
ci: redesign app sync workflows with per-environment NUON_CONFIG
1 parent 9db50a9 commit 0dcbc6e

3 files changed

Lines changed: 311 additions & 65 deletions

File tree

.github/workflows/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Sync workflows
2+
3+
`sync-app.yaml` and `sync-all-apps.yaml` sync app configs to a Nuon API using a
4+
per-environment [GitHub Environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment)
5+
secret named `NUON_CONFIG`.
6+
7+
- `sync-app.yaml` runs on PRs (against the `stage` environment) and on pushes to
8+
`main` (against the `prod` environment).
9+
- `sync-all-apps.yaml` is dispatched manually (or on schedule) and lets you pick
10+
the environment, ref, and an optional subset of apps.
11+
12+
Each job selects a GitHub Environment and reads `secrets.NUON_CONFIG` from it,
13+
writing the value to `$HOME/.nuon` before running `nuon apps sync`.
14+
15+
## Adding a new environment
16+
17+
The `NUON_CONFIG` secret is the full `~/.nuon` YAML config, and its `api_token`
18+
**must be an org-admin token minted from the environment's admin API** — a normal
19+
`nuon auth login` user token is not sufficient for syncing apps org-wide.
20+
21+
1. Reach the environment's admin API. It is internal, so port-forward it (example
22+
for stage; swap `stage` for the target environment):
23+
24+
```sh
25+
kubectl --context <env-cluster> -n ctl-api \
26+
port-forward svc/ctl-api-<env>-admin 8082:80
27+
```
28+
29+
2. Ensure the org admin service account exists, then mint a long-lived token
30+
(admin calls are authorized by the `X-Nuon-Admin-Email` header):
31+
32+
```sh
33+
ADMIN_API_URL="http://localhost:8082"
34+
ORG_ID="org_xxxxxxxxxxxxxxxxxxxxxxxx"
35+
ADMIN_EMAIL="you@nuon.co"
36+
37+
curl -sS -X POST "${ADMIN_API_URL}/v1/orgs/${ORG_ID}/admin-service-account" \
38+
-H "Content-Type: application/json" \
39+
-H "X-Nuon-Admin-Email: ${ADMIN_EMAIL}" \
40+
-d '{}'
41+
42+
curl -sS -X POST "${ADMIN_API_URL}/v1/general/admin-static-token" \
43+
-H "Content-Type: application/json" \
44+
-H "X-Nuon-Admin-Email: ${ADMIN_EMAIL}" \
45+
-d "{\"email_or_subject\": \"${ORG_ID}-admin-service-account\", \"duration\": \"8760h\"}"
46+
```
47+
48+
The second call returns `{"api_token": "..."}`.
49+
50+
3. Assemble the `~/.nuon` config for the environment:
51+
52+
```yaml
53+
api_url: https://api.<env>.nuon.co
54+
org_id: org_xxxxxxxxxxxxxxxxxxxxxxxx
55+
api_token: <api_token from step 2>
56+
```
57+
58+
4. Create the GitHub Environment (repo **Settings → Environments → New
59+
environment**), naming it to match the value used in the workflows (e.g.
60+
`stage`, `prod`).
61+
62+
5. Add the `NUON_CONFIG` secret to that environment:
63+
64+
```sh
65+
gh secret set NUON_CONFIG --env <environment-name> < ~/.nuon
66+
```
67+
68+
Verify with:
69+
70+
```sh
71+
gh secret list --env <environment-name>
72+
```
73+
74+
6. To make it selectable in `sync-all-apps.yaml`, add the environment name to the
75+
`environment` input `options` list. To use it automatically in `sync-app.yaml`,
76+
update the `environment:` expression on the `sync` job.
77+
78+
Because the secret carries `api_url`, `org_id`, and the admin `api_token`, each
79+
environment is fully scoped by its own `NUON_CONFIG` — no other workflow changes
80+
are needed to point an environment at a different API.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
name: Sync All Nuon Apps
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
environment:
7+
description: "Environment to sync against (config comes from its NUON_CONFIG secret)"
8+
required: false
9+
type: choice
10+
options:
11+
- stage
12+
- prod
13+
default: prod
14+
ref:
15+
description: "Branch, tag, or SHA of example-app-configs to sync (default: this ref)"
16+
required: false
17+
type: string
18+
default: ""
19+
apps:
20+
description: "Optional comma-separated subset of apps (default: all)"
21+
required: false
22+
type: string
23+
default: ""
24+
25+
schedule:
26+
- cron: "0 6 * * *"
27+
28+
workflow_call:
29+
inputs:
30+
environment:
31+
type: string
32+
required: false
33+
default: prod
34+
ref:
35+
type: string
36+
required: false
37+
default: ""
38+
apps:
39+
type: string
40+
required: false
41+
default: ""
42+
secrets:
43+
NUON_CONFIG:
44+
required: true
45+
46+
permissions:
47+
contents: read
48+
49+
concurrency:
50+
group: sync-all-apps
51+
cancel-in-progress: false
52+
53+
jobs:
54+
discover:
55+
runs-on: ubuntu-latest
56+
outputs:
57+
matrix: ${{ steps.set-matrix.outputs.matrix }}
58+
has_apps: ${{ steps.set-matrix.outputs.has_apps }}
59+
steps:
60+
- uses: actions/checkout@v4
61+
with:
62+
ref: ${{ inputs.ref || github.ref }}
63+
64+
- name: Discover apps
65+
id: set-matrix
66+
env:
67+
APPS_FILTER: ${{ inputs.apps }}
68+
run: |
69+
set -euo pipefail
70+
71+
# An "app" dir is any top-level directory containing a metadata.toml.
72+
mapfile -t ALL < <(find . -mindepth 2 -maxdepth 2 -name 'metadata.toml' -printf '%h\n' \
73+
| sed 's|^\./||' \
74+
| sort -u)
75+
76+
if [ -n "${APPS_FILTER}" ]; then
77+
IFS=',' read -ra REQUESTED <<< "${APPS_FILTER}"
78+
SELECTED=()
79+
for want in "${REQUESTED[@]}"; do
80+
want_trimmed="$(echo "$want" | xargs)"
81+
[ -z "$want_trimmed" ] && continue
82+
found=false
83+
for app in "${ALL[@]}"; do
84+
if [ "$app" = "$want_trimmed" ]; then
85+
SELECTED+=("$app")
86+
found=true
87+
break
88+
fi
89+
done
90+
if [ "$found" = false ]; then
91+
echo "::warning::requested app '$want_trimmed' not found in repo; skipping"
92+
fi
93+
done
94+
else
95+
SELECTED=("${ALL[@]}")
96+
fi
97+
98+
if [ "${#SELECTED[@]}" -eq 0 ]; then
99+
echo "No apps to sync"
100+
echo "has_apps=false" >> "$GITHUB_OUTPUT"
101+
echo "matrix={\"app\":[]}" >> "$GITHUB_OUTPUT"
102+
exit 0
103+
fi
104+
105+
JSON_ARRAY=$(printf '%s\n' "${SELECTED[@]}" | jq -R -s -c 'split("\n") | map(select(length > 0))')
106+
echo "Apps to sync: $JSON_ARRAY"
107+
echo "has_apps=true" >> "$GITHUB_OUTPUT"
108+
echo "matrix={\"app\":$JSON_ARRAY}" >> "$GITHUB_OUTPUT"
109+
110+
sync:
111+
needs: discover
112+
if: needs.discover.outputs.has_apps == 'true'
113+
runs-on: ubuntu-latest
114+
environment: ${{ inputs.environment || 'prod' }}
115+
strategy:
116+
matrix: ${{ fromJson(needs.discover.outputs.matrix) }}
117+
fail-fast: false
118+
max-parallel: 4
119+
steps:
120+
- uses: actions/checkout@v4
121+
with:
122+
ref: ${{ inputs.ref || github.ref }}
123+
124+
- name: Install Nuon CLI
125+
run: |
126+
curl -fsSL https://nuon-artifacts.s3.us-west-2.amazonaws.com/cli/install.sh -o "$RUNNER_TEMP/nuon-install.sh"
127+
bash "$RUNNER_TEMP/nuon-install.sh" --no-input
128+
129+
- name: Write Nuon config
130+
env:
131+
NUON_CONFIG: ${{ secrets.NUON_CONFIG }}
132+
run: |
133+
printf '%s\n' "$NUON_CONFIG" > "$HOME/.nuon"
134+
chmod 600 "$HOME/.nuon"
135+
136+
- name: Sync ${{ matrix.app }}
137+
env:
138+
APP: ${{ matrix.app }}
139+
run: |
140+
nuon apps sync "$APP" --create
141+
142+
summary:
143+
needs: [discover, sync]
144+
if: always() && needs.discover.outputs.has_apps == 'true'
145+
runs-on: ubuntu-latest
146+
steps:
147+
- name: Write summary
148+
env:
149+
MATRIX: ${{ needs.discover.outputs.matrix }}
150+
SYNC_RESULT: ${{ needs.sync.result }}
151+
ENVIRONMENT: ${{ inputs.environment || 'prod' }}
152+
REF: ${{ inputs.ref || github.ref }}
153+
run: |
154+
{
155+
echo "## Sync All Nuon Apps"
156+
echo ""
157+
echo "- **Environment:** \`$ENVIRONMENT\`"
158+
echo "- **Ref:** \`$REF\`"
159+
echo "- **Sync job result:** \`$SYNC_RESULT\`"
160+
echo ""
161+
echo "### Apps"
162+
echo "$MATRIX" | jq -r '.app[] | "- `" + . + "`"'
163+
} >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)