cpflow 5.0.0 → 5.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/commands/update-changelog.md +88 -23
- data/.github/actions/cpflow-resolve-review-config/action.yml +137 -0
- data/.github/actions/cpflow-setup-environment/action.yml +118 -0
- data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +26 -21
- data/.github/workflows/cpflow-delete-review-app.yml +21 -18
- data/.github/workflows/cpflow-deploy-review-app.yml +23 -19
- data/.github/workflows/cpflow-deploy-staging.yml +15 -11
- data/.github/workflows/cpflow-help-command.yml +0 -6
- data/.github/workflows/cpflow-promote-staging-to-production.yml +30 -5
- data/.github/workflows/cpflow-review-app-help.yml +1 -10
- data/CHANGELOG.md +32 -1
- data/Gemfile.lock +1 -1
- data/docs/ai-github-flow-prompt.md +1 -1
- data/docs/ci-automation.md +215 -29
- data/docs/commands.md +1 -1
- data/lib/command/ai_github_flow_prompt.rb +1 -1
- data/lib/command/run.rb +11 -1
- data/lib/command/setup_app.rb +1 -1
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/Dockerfile +9 -3
- data/lib/generator_templates/entrypoint.sh +42 -2
- data/lib/generator_templates/templates/app.yml +3 -3
- data/lib/generator_templates/templates/postgres.yml +19 -16
- data/lib/generator_templates/templates/rails.yml +3 -1
- data/lib/generator_templates_sqlite/templates/app.yml +3 -3
- data/lib/generator_templates_sqlite/templates/rails.yml +3 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +95 -79
- data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +4 -9
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +2 -9
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +3 -9
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +3 -8
- data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +0 -9
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +10 -8
- data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +4 -10
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +3 -1
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +23 -8
- metadata +2 -1
data/docs/ci-automation.md
CHANGED
|
@@ -150,29 +150,148 @@ apps:
|
|
|
150
150
|
|
|
151
151
|
Important points:
|
|
152
152
|
|
|
153
|
-
- `REVIEW_APP_PREFIX` in GitHub Actions must match the review config key prefix, for example `my-app-review`.
|
|
154
153
|
- `match_if_app_name_starts_with: true` is what allows a single config entry to back `my-app-review-123`, `my-app-review-456`, and cleanup commands like `cpflow cleanup-stale-apps -a my-app-review`.
|
|
154
|
+
- Review-app deploy, delete, and cleanup workflows infer the review app prefix from the single app entry with `match_if_app_name_starts_with: true`.
|
|
155
|
+
- Review-app workflows infer the staging Control Plane org from that review app entry's `cpln_org`.
|
|
155
156
|
- `upstream: my-app-staging` is what lets the production promotion workflow copy the exact staging artifact.
|
|
156
157
|
- If your main web workload is not named `rails`, set the optional `PRIMARY_WORKLOAD` repository variable described below.
|
|
157
158
|
|
|
158
159
|
## Required GitHub Repository Settings
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
For a normal generated review-app setup, configure one repository secret:
|
|
161
162
|
|
|
162
163
|
- `CPLN_TOKEN_STAGING`: token for the staging Control Plane org
|
|
163
|
-
- `CPLN_TOKEN_PRODUCTION`: token for the production Control Plane org
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
No GitHub repository variables are required for review apps when `.controlplane/controlplane.yml`
|
|
166
|
+
has exactly one review app entry with `match_if_app_name_starts_with: true` and
|
|
167
|
+
that entry has a `cpln_org`. The inferred values come from that config file:
|
|
168
|
+
the review-app prefix is the app key with `match_if_app_name_starts_with: true`,
|
|
169
|
+
and the staging org is that app's `cpln_org` value. Set these variables only
|
|
170
|
+
when you need to test a fork or clone against a different Control Plane org,
|
|
171
|
+
choose a different review-app prefix, expose a different public workload, or
|
|
172
|
+
disambiguate generated review-app config:
|
|
173
|
+
|
|
174
|
+
- `CPLN_ORG_STAGING`: override the staging/review org inferred from `cpln_org`, for example `company-staging`
|
|
175
|
+
- `REVIEW_APP_PREFIX`: override the inferred review-app prefix; required only when multiple review app prefixes exist in `controlplane.yml`
|
|
176
|
+
- `PRIMARY_WORKLOAD`: override the public workload used to discover the public endpoint and do production health checks; defaults to `rails`
|
|
177
|
+
|
|
178
|
+
If `controlplane.yml` defines more than one app with
|
|
179
|
+
`match_if_app_name_starts_with: true`, inference intentionally fails. Set
|
|
180
|
+
`CPLN_ORG_STAGING` and `REVIEW_APP_PREFIX` to tell the workflow which review-app
|
|
181
|
+
family to manage.
|
|
182
|
+
|
|
183
|
+
For staging deploys, also configure:
|
|
166
184
|
|
|
167
185
|
- `CPLN_ORG_STAGING`: staging org name, for example `company-staging`
|
|
168
|
-
- `CPLN_ORG_PRODUCTION`: production org name, for example `company-production`
|
|
169
186
|
- `STAGING_APP_NAME`: staging GVC name, for example `my-app-staging`
|
|
170
|
-
- `PRODUCTION_APP_NAME`: production GVC name, for example `my-app-production`
|
|
171
|
-
- `REVIEW_APP_PREFIX`: review-app prefix, for example `my-app-review`
|
|
172
187
|
- `STAGING_APP_BRANCH`: optional branch that auto-deploys staging. If you use a custom branch, either pass it to `cpflow generate-github-actions --staging-branch BRANCH` during generation or edit `cpflow-deploy-staging.yml` so its `on.push.branches` list includes the same branch.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
|
|
189
|
+
For production promotion, also configure:
|
|
190
|
+
|
|
191
|
+
- a GitHub Environment named `production`
|
|
192
|
+
- required reviewers on that environment, limited to the people or team allowed to promote production
|
|
193
|
+
- "Prevent self-review" on that environment, so the person who starts the promotion cannot approve it
|
|
194
|
+
- optionally disable administrator bypass and restrict deployment branches/tags to your protected release branch
|
|
195
|
+
- `CPLN_TOKEN_PRODUCTION` as an environment secret on `production`, not as a repository or organization secret
|
|
196
|
+
- `CPLN_ORG_PRODUCTION` as a production environment variable, for example `company-production`
|
|
197
|
+
- `PRODUCTION_APP_NAME` as a production environment variable, for example `my-app-production`
|
|
198
|
+
|
|
199
|
+
Do not put `CPLN_TOKEN_PRODUCTION` in repository or organization secrets for
|
|
200
|
+
sensitive production systems. The generated promotion reusable workflow declares
|
|
201
|
+
`environment: production`; the generated caller passes that environment name
|
|
202
|
+
through `production_environment`. GitHub waits for the `production`
|
|
203
|
+
environment's protection rules before injecting `CPLN_TOKEN_PRODUCTION` into
|
|
204
|
+
the upstream production job.
|
|
205
|
+
|
|
206
|
+
GitHub's reusable-workflow syntax still requires the upstream workflow to
|
|
207
|
+
declare `CPLN_TOKEN_PRODUCTION` as an optional `workflow_call` secret so static
|
|
208
|
+
validation accepts `secrets.CPLN_TOKEN_PRODUCTION`, but the generated caller
|
|
209
|
+
must not pass it. GitHub uses the secret from the reusable workflow job's
|
|
210
|
+
`production` environment when that environment is configured.
|
|
211
|
+
|
|
212
|
+
Generated caller workflows pass only the named secrets each reusable workflow
|
|
213
|
+
needs. They do not use `secrets: inherit`; the production token is supplied by
|
|
214
|
+
the protected `production` Environment after approval, not forwarded from a
|
|
215
|
+
repository secret.
|
|
216
|
+
|
|
217
|
+
## First-Time Control Plane Bootstrap
|
|
218
|
+
|
|
219
|
+
GitHub settings only give the workflows permission to act. They do not create
|
|
220
|
+
the persistent staging or production GVCs for you on the first merge.
|
|
221
|
+
|
|
222
|
+
Before the first staging deploy, bootstrap the staging app once:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
cpflow setup-app -a my-app-staging --org my-org-staging --skip-post-creation-hook
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`setup-app` reads the `setup_app_templates` list from
|
|
229
|
+
`.controlplane/controlplane.yml`. It creates the persistent staging GVC,
|
|
230
|
+
workloads, app identity, app secret dictionary, app secret policy, and policy
|
|
231
|
+
binding that grants the app identity `reveal` permission on that dictionary.
|
|
232
|
+
Use `--skip-post-creation-hook` for first-time bootstrap so a database hook does
|
|
233
|
+
not try to run before the first image exists.
|
|
234
|
+
|
|
235
|
+
After the persistent app exists, use `apply-template` for later template
|
|
236
|
+
updates. Adjust the template list to match your repo, such as adding `worker`,
|
|
237
|
+
`sidekiq`, `renderer`, `redis`, or other templates present under
|
|
238
|
+
`.controlplane/templates`:
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
cpflow apply-template app postgres rails -a my-app-staging --org my-org-staging --yes --add-app-identity
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
If you use `apply-template` to create or repair an existing app, also confirm
|
|
245
|
+
that the app identity has `reveal` permission on the app secret policy. Without
|
|
246
|
+
that binding, workloads that reference `cpln://secret/<app-secrets>.*` stay
|
|
247
|
+
paused until the policy is fixed.
|
|
248
|
+
|
|
249
|
+
Before the first production promotion, run the same kind of bootstrap for the
|
|
250
|
+
production app in the production org:
|
|
251
|
+
|
|
252
|
+
```sh
|
|
253
|
+
cpflow setup-app -a my-app-production --org my-org-production --skip-post-creation-hook
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Use production-only runtime secrets and values for the production app. The
|
|
257
|
+
protected GitHub Environment controls who can run the promotion workflow, but
|
|
258
|
+
the production app resources still need to exist before the first promotion.
|
|
259
|
+
|
|
260
|
+
Review apps are different: the generated `+review-app-deploy` workflow creates
|
|
261
|
+
temporary PR apps as needed, including the identity and secret policy binding.
|
|
262
|
+
You still need the shared review-app runtime secret values described by your
|
|
263
|
+
templates, and the staging token must have access to create and update
|
|
264
|
+
review-app GVCs, workloads, images, identities, policies, and secrets in the
|
|
265
|
+
staging org.
|
|
266
|
+
|
|
267
|
+
### Production Promotion Safety
|
|
268
|
+
|
|
269
|
+
`CPLN_TOKEN_PRODUCTION` can change live production workloads, images, releases,
|
|
270
|
+
and rollback state. Treat it differently from review-app and staging credentials.
|
|
271
|
+
The standard path is:
|
|
272
|
+
|
|
273
|
+
1. Create the `production` GitHub Environment before setting the production token.
|
|
274
|
+
2. Add a small required-reviewer list or team with production authority.
|
|
275
|
+
3. Enable prevent self-review.
|
|
276
|
+
4. Disable administrator bypass if your org policy requires two-person control.
|
|
277
|
+
5. Restrict deployable branches or tags to the protected release branch.
|
|
278
|
+
6. Store `CPLN_TOKEN_PRODUCTION` only as a `production` environment secret.
|
|
279
|
+
7. Store `CPLN_ORG_PRODUCTION` and `PRODUCTION_APP_NAME` as `production`
|
|
280
|
+
environment variables, or as repository variables only when those names are
|
|
281
|
+
intentionally non-sensitive.
|
|
282
|
+
|
|
283
|
+
GitHub only exposes environment secrets to jobs that reference the environment
|
|
284
|
+
after configured protection rules pass. GitHub also does not allow a caller job
|
|
285
|
+
that directly invokes a reusable workflow to set `environment`; for that reason,
|
|
286
|
+
the reusable promotion workflow itself declares `environment: production`. See
|
|
287
|
+
GitHub's docs for [managing environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments),
|
|
288
|
+
[deployment protection rules](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments),
|
|
289
|
+
and [reusable workflow limitations](https://docs.github.com/en/actions/reference/reusable-workflows-reference#supported-keywords-for-jobs-that-call-a-reusable-workflow).
|
|
290
|
+
|
|
291
|
+
Application runtime secrets such as `SECRET_KEY_BASE`, API keys, or private
|
|
292
|
+
license keys belong in Control Plane secret dictionaries referenced by
|
|
293
|
+
`controlplane.yml`. They are not GitHub repository variables unless your
|
|
294
|
+
Docker build itself needs them.
|
|
176
295
|
|
|
177
296
|
Recommended org layout:
|
|
178
297
|
|
|
@@ -183,6 +302,17 @@ Optional repository secret for private dependency builds:
|
|
|
183
302
|
|
|
184
303
|
- `DOCKER_BUILD_SSH_KEY`: private SSH key used when the Dockerfile needs `RUN --mount=type=ssh` to fetch private GitHub dependencies during image build
|
|
185
304
|
|
|
305
|
+
Optional repository variables for private dependency builds:
|
|
306
|
+
|
|
307
|
+
- `DOCKER_BUILD_EXTRA_ARGS`: optional newline-delimited single `docker build` tokens passed through to `cpflow build-image`, for example `--build-arg=FOO=bar` or `--secret=id=npmrc,src=.npmrc`
|
|
308
|
+
- `DOCKER_BUILD_SSH_KNOWN_HOSTS`: optional multi-line `known_hosts` content used with `DOCKER_BUILD_SSH_KEY` when the build needs SSH access to hosts other than GitHub.com
|
|
309
|
+
|
|
310
|
+
Advanced optional repository variables:
|
|
311
|
+
|
|
312
|
+
- `REVIEW_APP_DEPLOYING_ICON_URL`: custom image URL for the animated icon in review-app PR comments. Ignore this for the standard setup; it is cosmetic only.
|
|
313
|
+
- `CPLN_CLI_VERSION`: pin only when Control Plane CLI compatibility requires it.
|
|
314
|
+
- `CPFLOW_VERSION`: pin a published RubyGems version only when intentionally overriding the default build-from-ref behavior.
|
|
315
|
+
|
|
186
316
|
## Docker Builds with Private Dependencies
|
|
187
317
|
|
|
188
318
|
Some apps need extra Docker build configuration before the generated workflows are turnkey. Common examples are:
|
|
@@ -212,6 +342,9 @@ The action will start an SSH agent, add the key, write `known_hosts`, and pass `
|
|
|
212
342
|
`cpflow-review-app-help.yml`
|
|
213
343
|
|
|
214
344
|
- Posts a quick reference when a pull request opens, including on fork-based PRs.
|
|
345
|
+
- This is an onboarding comment only; it does not checkout PR code or receive
|
|
346
|
+
Control Plane secrets. Remove this wrapper if a repo does not want automatic
|
|
347
|
+
review-app command help on every new PR.
|
|
215
348
|
|
|
216
349
|
`cpflow-help-command.yml`
|
|
217
350
|
|
|
@@ -242,6 +375,7 @@ The action will start an SSH agent, add the key, write `known_hosts`, and pass `
|
|
|
242
375
|
`cpflow-promote-staging-to-production.yml`
|
|
243
376
|
|
|
244
377
|
- Manually promotes the staging artifact to production with a confirmation input.
|
|
378
|
+
- Runs the production job in the `production` GitHub Environment, so configured reviewers approve the job before production environment secrets are available.
|
|
245
379
|
- Verifies that production has the env var names staging expects.
|
|
246
380
|
- Runs a health check against `PRIMARY_WORKLOAD`.
|
|
247
381
|
- Attempts a rollback of every configured application workload if the new production image does not come up healthy.
|
|
@@ -252,6 +386,25 @@ The action will start an SSH agent, add the key, write `known_hosts`, and pass `
|
|
|
252
386
|
- Runs nightly and on demand.
|
|
253
387
|
- Deletes stale review apps using `cpflow cleanup-stale-apps`.
|
|
254
388
|
|
|
389
|
+
Generated review app names use `<review-app-prefix>-<PR number>`, for example
|
|
390
|
+
`my-app-review-123`. If an existing repository is migrating from older local
|
|
391
|
+
workflow glue that created names like `<review-app-prefix>-pr-123`, delete those
|
|
392
|
+
old review apps manually after merging the generated flow; the cleanup workflow
|
|
393
|
+
only targets the current prefix convention.
|
|
394
|
+
|
|
395
|
+
To inventory old-prefix review apps before cleanup, run:
|
|
396
|
+
|
|
397
|
+
```sh
|
|
398
|
+
cpln gvc query --org <staging-org> -o yaml --prop name~<review-app-prefix>-pr-
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The PR-open help workflow posts the short command reference whenever the
|
|
402
|
+
generated wrapper exists. That is intentional for configured demo repos. Forks
|
|
403
|
+
or clones that copy the workflow before configuring Control Plane can remove
|
|
404
|
+
`.github/workflows/cpflow-review-app-help.yml` or uncomment and adapt the
|
|
405
|
+
wrapper-level `if:` guard shown in that file, for example
|
|
406
|
+
`vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != ''`.
|
|
407
|
+
|
|
255
408
|
## Upstream Reusable Workflows
|
|
256
409
|
|
|
257
410
|
The generated workflows are intentionally small wrappers. The deployment logic,
|
|
@@ -271,18 +424,26 @@ GitHub ref:
|
|
|
271
424
|
|
|
272
425
|
```yaml
|
|
273
426
|
uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@<ref>
|
|
274
|
-
with:
|
|
275
|
-
control_plane_flow_ref: <ref>
|
|
276
427
|
```
|
|
277
428
|
|
|
278
|
-
|
|
429
|
+
That single `uses:` ref is the downstream lock. GitHub exposes the reusable
|
|
430
|
+
workflow's own repository, ref, and SHA to the called job, so the upstream
|
|
431
|
+
workflow checks out the matching `control-plane-flow` source automatically from
|
|
432
|
+
that context. Downstream wrappers should not pass `control_plane_flow_ref`; if
|
|
433
|
+
you see that input in generated wrappers, regenerate with a newer `cpflow`.
|
|
434
|
+
|
|
435
|
+
There are two locks, and they protect different things:
|
|
279
436
|
|
|
280
|
-
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
437
|
+
- The GitHub ref locks the reusable workflow and composite action code that
|
|
438
|
+
GitHub runs.
|
|
439
|
+
- The RubyGems version locks the `cpflow` CLI/runtime code only when you install
|
|
440
|
+
or run that gem. It does not make GitHub load reusable workflow YAML from the
|
|
441
|
+
gem.
|
|
284
442
|
|
|
285
|
-
|
|
443
|
+
That means a downstream app cannot rely on the gem alone for GitHub Actions
|
|
444
|
+
behavior. The safe stable path is still gem-driven for generation, but
|
|
445
|
+
developers must commit generated wrappers that reference the matching upstream
|
|
446
|
+
release tag:
|
|
286
447
|
|
|
287
448
|
1. Publish a `cpflow` gem.
|
|
288
449
|
2. Install or bundle that released gem in the downstream project.
|
|
@@ -297,9 +458,26 @@ feature-branch refs.
|
|
|
297
458
|
`CPFLOW_VERSION` is a runtime override. If a downstream repository sets the
|
|
298
459
|
`CPFLOW_VERSION` variable, the setup action runs `gem install cpflow -v
|
|
299
460
|
<version>`. If it is unset, the setup action builds `cpflow` from the checked-out
|
|
300
|
-
`control-plane-flow`
|
|
301
|
-
pinning the wrappers to the
|
|
302
|
-
`CPFLOW_VERSION` to that same released gem
|
|
461
|
+
`control-plane-flow` source selected by the reusable workflow's own SHA. For
|
|
462
|
+
normal releases, leave `CPFLOW_VERSION` unset while pinning the wrappers to the
|
|
463
|
+
matching `v<version>` tag, or set `CPFLOW_VERSION` to that same released gem
|
|
464
|
+
version without the leading `v`.
|
|
465
|
+
When setting `CPFLOW_VERSION`, use RubyGems version syntax, for example
|
|
466
|
+
`5.0.0` or `5.0.0.rc.1`; do not use `v5.0.0` or dash-separated prereleases
|
|
467
|
+
because the value is passed directly to `gem install cpflow -v`.
|
|
468
|
+
|
|
469
|
+
The setup action fails early when `CPFLOW_VERSION` and the reusable workflow tag
|
|
470
|
+
are out of sync. `CPFLOW_VERSION=5.0.0` is accepted only when the wrapper uses a
|
|
471
|
+
release tag such as `@v5.0.0` (or GitHub resolves it to `refs/tags/v5.0.0`).
|
|
472
|
+
Release tags may use dot- or dash-separated prerelease suffixes, such as
|
|
473
|
+
`v5.0.0.rc.1` or `v5.0.0-rc.1`; the gem version should still use dots. The
|
|
474
|
+
action also checks the remote `control-plane-flow` tag and the checked-out
|
|
475
|
+
action commit, so a moving branch named like `v5.0.0` cannot be used with
|
|
476
|
+
`CPFLOW_VERSION=5.0.0`. That tag check uses outbound HTTPS to GitHub; restricted
|
|
477
|
+
runners that cannot reach GitHub should leave `CPFLOW_VERSION` unset and build
|
|
478
|
+
`cpflow` from the checked-out ref instead. When testing an unreleased upstream
|
|
479
|
+
commit SHA, leave `CPFLOW_VERSION` unset so the workflow builds `cpflow` from the
|
|
480
|
+
same source that supplies the reusable workflow and composite actions.
|
|
303
481
|
|
|
304
482
|
## Testing Unreleased Upstream Changes Downstream
|
|
305
483
|
|
|
@@ -319,7 +497,10 @@ releasing it. Use an immutable commit SHA from the upstream PR branch:
|
|
|
319
497
|
local experiments that should not be committed.
|
|
320
498
|
|
|
321
499
|
3. Keep `CPFLOW_VERSION` unset so the workflow builds `cpflow` from the same
|
|
322
|
-
upstream SHA that supplies the reusable workflow and composite actions.
|
|
500
|
+
upstream SHA that supplies the reusable workflow and composite actions. If
|
|
501
|
+
`CPFLOW_VERSION` is set while the wrapper is pinned to a SHA, the setup
|
|
502
|
+
action fails before deployment because the gem and action code cannot be
|
|
503
|
+
proven to match.
|
|
323
504
|
4. Run:
|
|
324
505
|
|
|
325
506
|
```sh
|
|
@@ -343,8 +524,10 @@ releasing it. Use an immutable commit SHA from the upstream PR branch:
|
|
|
343
524
|
6. Verify the deploy logs show the expected upstream commit SHA, the setup step
|
|
344
525
|
prints the expected `cpflow` source/version, and the review app URL returns
|
|
345
526
|
HTTP 200.
|
|
346
|
-
7. After the upstream PR merges and a gem is released, regenerate
|
|
347
|
-
|
|
527
|
+
7. After the upstream PR merges and a gem is released, regenerate the downstream
|
|
528
|
+
wrappers from that released gem and commit the release tag. Use
|
|
529
|
+
`bin/pin-cpflow-github-ref vX.Y.Z` only for a ref-only update when the
|
|
530
|
+
generated templates are already current.
|
|
348
531
|
|
|
349
532
|
This tests the real reusable workflow, shared composite actions, and source-built
|
|
350
533
|
`cpflow` gem from one immutable upstream commit. It avoids merging upstream blind
|
|
@@ -360,9 +543,12 @@ bin/test-cpflow-github-flow
|
|
|
360
543
|
|
|
361
544
|
The helper runs `cpflow github-flow-readiness`, parses generated workflow YAML,
|
|
362
545
|
checks composite action metadata for literal GitHub expressions in descriptions,
|
|
363
|
-
checks that all generated wrappers use one upstream ref consistently,
|
|
364
|
-
|
|
365
|
-
`
|
|
546
|
+
checks that all generated wrappers use one upstream ref consistently, rejects
|
|
547
|
+
broad `secrets: inherit` usage in generated cpflow wrappers, rejects obsolete
|
|
548
|
+
`control_plane_flow_ref` wrapper inputs, and runs
|
|
549
|
+
`actionlint` against `.github/workflows/cpflow-*.yml`. Its `actionlint` command
|
|
550
|
+
keeps the existing shellcheck ignore and also ignores stale local `actionlint`
|
|
551
|
+
false positives for GitHub's newer reusable-workflow `job.workflow_*` fields.
|
|
366
552
|
|
|
367
553
|
## Applying This to React on Rails Demo Apps
|
|
368
554
|
|
|
@@ -397,7 +583,7 @@ In practice, porting the flow into a demo app usually follows five phases.
|
|
|
397
583
|
|
|
398
584
|
**Wire up GitHub secrets, variables, and private builds:**
|
|
399
585
|
|
|
400
|
-
11. Make sure the repo variables and secrets line up with the configured app names.
|
|
586
|
+
11. Make sure the repo variables and secrets line up with the configured app names. For production promotion, store `CPLN_TOKEN_PRODUCTION` only on a protected `production` GitHub Environment with required reviewers.
|
|
401
587
|
12. If the Dockerfile pulls private dependencies over SSH, configure `DOCKER_BUILD_SSH_KEY`, add `DOCKER_BUILD_SSH_KNOWN_HOSTS` when the host is not GitHub.com, and validate that the image can build with `RUN --mount=type=ssh`.
|
|
402
588
|
|
|
403
589
|
**Validate and push:**
|
|
@@ -417,7 +603,7 @@ current prompt with that repo's default app prefix already filled in.
|
|
|
417
603
|
Short version:
|
|
418
604
|
|
|
419
605
|
```text
|
|
420
|
-
Set up Control Plane GitHub Flow for this repo. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone, with published package versions and a production Dockerfile that can really build the app. Stop and report blockers for unpublished packages, inaccessible private dependencies, legacy toolchains, or missing production build paths instead of generating workflows blindly. Then run `cpflow generate` if `.controlplane/` is missing, run `cpflow generate-github-actions`, adapt the generated scaffold to the real workloads, document the required GitHub secrets and variables, validate the real build path locally, push the branch, and check the GitHub Actions results.
|
|
606
|
+
Set up Control Plane GitHub Flow for this repo. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone, with published package versions and a production Dockerfile that can really build the app. Stop and report blockers for unpublished packages, inaccessible private dependencies, legacy toolchains, or missing production build paths instead of generating workflows blindly. Then run `cpflow generate` if `.controlplane/` is missing, run `cpflow generate-github-actions`, adapt the generated scaffold to the real workloads, document the required GitHub secrets and variables, validate the real build path locally, push the branch, and check the GitHub Actions results. Keep production promotion safe by documenting `CPLN_TOKEN_PRODUCTION` as a protected `production` GitHub Environment secret, not a repository or organization secret.
|
|
421
607
|
```
|
|
422
608
|
|
|
423
609
|
Expand that prompt with app-specific requirements before editing files:
|
data/docs/commands.md
CHANGED
|
@@ -503,7 +503,7 @@ cpflow run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db:
|
|
|
503
503
|
|
|
504
504
|
- Creates an app and all its workloads
|
|
505
505
|
- Specify the templates for the app and workloads through `setup_app_templates` in the `.controlplane/controlplane.yml` file
|
|
506
|
-
-
|
|
506
|
+
- Use this for temporary apps like review apps and for first-time bootstrap of persistent staging or production apps; after a persistent app exists, use 'cpflow apply-template' for template updates
|
|
507
507
|
- Configures app to have org-level secrets with default name `"{APP_PREFIX}-secrets"`
|
|
508
508
|
using org-level policy with default name `"{APP_PREFIX}-secrets-policy"` (names can be customized, see docs)
|
|
509
509
|
- Creates identity for secrets if it does not exist
|
|
@@ -32,7 +32,7 @@ module Command
|
|
|
32
32
|
<<~PROMPT
|
|
33
33
|
Set up Control Plane GitHub Flow for this repo. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout.
|
|
34
34
|
|
|
35
|
-
If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `+review-app-deploy`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured.
|
|
35
|
+
If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `+review-app-deploy`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured. Do not hand-edit duplicated upstream refs into the generated wrappers: the only downstream Control Plane Flow pin should be the reusable workflow `uses: ...@vX.Y.Z` value generated from the installed `cpflow` gem version, and upstream workflows load their matching shared actions automatically. Keep the standard path simple: review apps require only `CPLN_TOKEN_STAGING` when the generated review app config can be inferred. Document the one-time Control Plane bootstrap command for persistent staging and production apps with `cpflow setup-app --skip-post-creation-hook`; for existing apps or later template updates, document `cpflow apply-template` and the need for the app identity to have `reveal` on the app secret policy. Do not imply the staging deploy or promotion workflows create those persistent GVCs. For production promotion, document a protected `production` GitHub Environment with required reviewers, prevent self-review, and `CPLN_TOKEN_PRODUCTION` stored as an environment secret, not as a repository or organization secret.
|
|
36
36
|
|
|
37
37
|
Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold uses persistent `db` and `storage` volumes plus a release script that runs `rails db:prepare`; otherwise keep the default Postgres workload. If the public workload is not named `rails`, set `PRIMARY_WORKLOAD` or adjust the generated workflows. Inspect the Dockerfile and package sources for private GitHub dependencies or `RUN --mount=type=ssh`; if present, wire `DOCKER_BUILD_SSH_KEY`, optionally set `DOCKER_BUILD_SSH_KNOWN_HOSTS` for non-GitHub SSH hosts, and keep `DOCKER_BUILD_EXTRA_ARGS` to newline-delimited single tokens such as `--build-arg=FOO=bar`.
|
|
38
38
|
|
data/lib/command/run.rb
CHANGED
|
@@ -197,7 +197,7 @@ module Command
|
|
|
197
197
|
spec = nil
|
|
198
198
|
|
|
199
199
|
step("Checking if runner workload '#{runner_workload}' needs to be updated") do # rubocop:disable Metrics/BlockLength
|
|
200
|
-
|
|
200
|
+
original_spec, original_container_spec = base_workload_specs(original_workload)
|
|
201
201
|
spec, container_spec = base_workload_specs(runner_workload)
|
|
202
202
|
|
|
203
203
|
# Keep ENV synced between original and runner workloads
|
|
@@ -208,6 +208,16 @@ module Command
|
|
|
208
208
|
should_update = true
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# Keep the app identity in sync so runner jobs can resolve GVC-level secrets.
|
|
212
|
+
if spec["identityLink"] != original_spec["identityLink"]
|
|
213
|
+
if original_spec["identityLink"]
|
|
214
|
+
spec["identityLink"] = original_spec["identityLink"]
|
|
215
|
+
else
|
|
216
|
+
spec.delete("identityLink")
|
|
217
|
+
end
|
|
218
|
+
should_update = true
|
|
219
|
+
end
|
|
220
|
+
|
|
211
221
|
if container_spec["image"] != default_image
|
|
212
222
|
container_spec["image"] = default_image
|
|
213
223
|
should_update = true
|
data/lib/command/setup_app.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Command
|
|
|
13
13
|
LONG_DESCRIPTION = <<~DESC
|
|
14
14
|
- Creates an app and all its workloads
|
|
15
15
|
- Specify the templates for the app and workloads through `setup_app_templates` in the `.controlplane/controlplane.yml` file
|
|
16
|
-
-
|
|
16
|
+
- Use this for temporary apps like review apps and for first-time bootstrap of persistent staging or production apps; after a persistent app exists, use 'cpflow apply-template' for template updates
|
|
17
17
|
- Configures app to have org-level secrets with default name `"{APP_PREFIX}-secrets"`
|
|
18
18
|
using org-level policy with default name `"{APP_PREFIX}-secrets-policy"` (names can be customized, see docs)
|
|
19
19
|
- Creates identity for secrets if it does not exist
|
data/lib/cpflow/version.rb
CHANGED
|
@@ -17,12 +17,17 @@ WORKDIR /app
|
|
|
17
17
|
# rely on ExecJS in production. Narrowed to just what the node stage actually
|
|
18
18
|
# ships under /usr/local so we don't drag in unused Debian libs from that image.
|
|
19
19
|
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
|
20
|
-
COPY --from=node /usr/local/bin/npm /usr/local/bin/npm
|
|
21
|
-
COPY --from=node /usr/local/bin/npx /usr/local/bin/npx
|
|
22
|
-
COPY --from=node /usr/local/bin/corepack /usr/local/bin/corepack
|
|
23
20
|
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
|
24
21
|
COPY --from=node /usr/local/include/node /usr/local/include/node
|
|
25
22
|
|
|
23
|
+
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
|
24
|
+
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
|
|
25
|
+
ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack && \
|
|
26
|
+
chmod +x /usr/local/lib/node_modules/npm/bin/npm-cli.js \
|
|
27
|
+
/usr/local/lib/node_modules/npm/bin/npx-cli.js \
|
|
28
|
+
/usr/local/lib/node_modules/corepack/dist/corepack.js && \
|
|
29
|
+
node --version && npm --version && corepack --version
|
|
30
|
+
|
|
26
31
|
# Expose Corepack-managed shims so later build steps can call yarn/pnpm
|
|
27
32
|
# directly during asset precompilation hooks.
|
|
28
33
|
RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \
|
|
@@ -78,6 +83,7 @@ RUN rails assets:precompile
|
|
|
78
83
|
|
|
79
84
|
# add entrypoint
|
|
80
85
|
COPY .controlplane/entrypoint.sh ./
|
|
86
|
+
RUN chmod +x /app/entrypoint.sh
|
|
81
87
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
82
88
|
|
|
83
89
|
CMD ["rails", "s"]
|
|
@@ -1,8 +1,48 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
+
set -e
|
|
2
3
|
# Runs before the main command
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
is_rails_server_command() {
|
|
6
|
+
if [ "${1:-}" = "env" ]; then
|
|
7
|
+
shift
|
|
8
|
+
while [ "$#" -gt 0 ]; do
|
|
9
|
+
case "${1}" in
|
|
10
|
+
*=*) shift ;;
|
|
11
|
+
--) shift; break ;;
|
|
12
|
+
-*) return 1 ;;
|
|
13
|
+
*) break ;;
|
|
14
|
+
esac
|
|
15
|
+
done
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
if [ "${1:-}" = "bundle" ] && [ "${2:-}" = "exec" ]; then
|
|
19
|
+
shift 2
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# Matches generated, optionally env-prefixed, flag-free Thruster invocations.
|
|
23
|
+
# Hand-edited commands with env flags or Thruster flags before rails skip
|
|
24
|
+
# generated DB prep.
|
|
25
|
+
if [ "${1:-}" = "thrust" ] || [ "${1:-}" = "bin/thrust" ] || [ "${1:-}" = "./bin/thrust" ]; then
|
|
26
|
+
shift
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# Thruster may be wrapped with its own `bundle exec`, and the Rails command
|
|
30
|
+
# it proxies may also be wrapped with `bundle exec`.
|
|
31
|
+
if [ "${1:-}" = "bundle" ] && [ "${2:-}" = "exec" ]; then
|
|
32
|
+
shift 2
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
{ [ "${1:-}" = "rails" ] || [ "${1:-}" = "bin/rails" ] || [ "${1:-}" = "./bin/rails" ]; } &&
|
|
36
|
+
{ [ "${2:-}" = "server" ] || [ "${2:-}" = "s" ]; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Match generated Rails server commands; workers and renderers skip DB prep.
|
|
40
|
+
# Generated Dockerfiles use WORKDIR /app; adjust this path if your hand-edited
|
|
41
|
+
# image runs the entrypoint from a different working directory.
|
|
42
|
+
if is_rails_server_command "$@"; then
|
|
43
|
+
echo " -- Preparing database"
|
|
44
|
+
./bin/rails db:prepare
|
|
45
|
+
fi
|
|
6
46
|
|
|
7
47
|
echo " -- Finishing entrypoint.sh, executing command"
|
|
8
48
|
exec "$@"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Template setup of the GVC, roughly corresponding to a Heroku app
|
|
2
2
|
kind: gvc
|
|
3
|
-
name: {{APP_NAME}}
|
|
3
|
+
name: "{{APP_NAME}}"
|
|
4
4
|
spec:
|
|
5
5
|
env:
|
|
6
6
|
- name: DATABASE_URL
|
|
@@ -12,7 +12,7 @@ spec:
|
|
|
12
12
|
- name: RAILS_SERVE_STATIC_FILES
|
|
13
13
|
value: "true"
|
|
14
14
|
- name: SECRET_KEY_BASE
|
|
15
|
-
value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE
|
|
15
|
+
value: "cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE"
|
|
16
16
|
staticPlacement:
|
|
17
17
|
locationLinks:
|
|
18
|
-
- {{APP_LOCATION_LINK}}
|
|
18
|
+
- "{{APP_LOCATION_LINK}}"
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# https://github.com/controlplane-com/examples/blob/main/examples/postgres/manifest.yaml
|
|
3
3
|
|
|
4
4
|
kind: volumeset
|
|
5
|
-
name:
|
|
6
|
-
description:
|
|
5
|
+
name: "{{APP_NAME}}-pg-vs"
|
|
6
|
+
description: "{{APP_NAME}}-pg-vs"
|
|
7
7
|
spec:
|
|
8
8
|
autoscaling:
|
|
9
9
|
maxCapacity: 1000
|
|
@@ -18,7 +18,7 @@ spec:
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
kind: secret
|
|
21
|
-
name:
|
|
21
|
+
name: "{{APP_NAME}}-pg"
|
|
22
22
|
description: ''
|
|
23
23
|
type: dictionary
|
|
24
24
|
data:
|
|
@@ -27,7 +27,7 @@ data:
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
kind: secret
|
|
30
|
-
name:
|
|
30
|
+
name: "{{APP_NAME}}-pg-script"
|
|
31
31
|
type: opaque
|
|
32
32
|
data:
|
|
33
33
|
encoding: base64
|
|
@@ -92,13 +92,13 @@ data:
|
|
|
92
92
|
|
|
93
93
|
---
|
|
94
94
|
kind: identity
|
|
95
|
-
name:
|
|
96
|
-
description:
|
|
95
|
+
name: "{{APP_NAME}}-pg-identity"
|
|
96
|
+
description: "{{APP_NAME}}-pg-identity"
|
|
97
97
|
|
|
98
98
|
---
|
|
99
99
|
kind: policy
|
|
100
|
-
name:
|
|
101
|
-
description:
|
|
100
|
+
name: "{{APP_NAME}}-pg-access"
|
|
101
|
+
description: "{{APP_NAME}}-pg-access"
|
|
102
102
|
bindings:
|
|
103
103
|
- permissions:
|
|
104
104
|
- reveal
|
|
@@ -106,11 +106,14 @@ bindings:
|
|
|
106
106
|
# - use
|
|
107
107
|
# - view
|
|
108
108
|
principalLinks:
|
|
109
|
-
- //gvc/{{APP_NAME}}/identity/
|
|
109
|
+
- "//gvc/{{APP_NAME}}/identity/{{APP_NAME}}-pg-identity"
|
|
110
|
+
# cpflow apply-template replaces {{APP_IDENTITY_LINK}} with the full app workload identity link.
|
|
111
|
+
# Example: //gvc/{{APP_NAME}}/identity/{{APP_NAME}}-identity.
|
|
112
|
+
- "{{APP_IDENTITY_LINK}}"
|
|
110
113
|
targetKind: secret
|
|
111
114
|
targetLinks:
|
|
112
|
-
- //secret/
|
|
113
|
-
- //secret/
|
|
115
|
+
- "//secret/{{APP_NAME}}-pg"
|
|
116
|
+
- "//secret/{{APP_NAME}}-pg-script"
|
|
114
117
|
|
|
115
118
|
---
|
|
116
119
|
kind: workload
|
|
@@ -130,9 +133,9 @@ spec:
|
|
|
130
133
|
- name: PGDATA #The location postgres stores the db. This can be anything other than /var/lib/postgresql/data, but it must be inside the mount point for the volume set
|
|
131
134
|
value: "/var/lib/postgresql/data/pg_data"
|
|
132
135
|
- name: POSTGRES_PASSWORD #The password for the default user
|
|
133
|
-
value: cpln://secret/
|
|
136
|
+
value: "cpln://secret/{{APP_NAME}}-pg.password"
|
|
134
137
|
- name: POSTGRES_USER #The name of the default user
|
|
135
|
-
value: cpln://secret/
|
|
138
|
+
value: "cpln://secret/{{APP_NAME}}-pg.username"
|
|
136
139
|
name: postgres
|
|
137
140
|
image: postgres:15
|
|
138
141
|
command: /bin/bash
|
|
@@ -146,10 +149,10 @@ spec:
|
|
|
146
149
|
- number: 5432
|
|
147
150
|
protocol: tcp
|
|
148
151
|
volumes:
|
|
149
|
-
- uri: cpln://volumeset/
|
|
152
|
+
- uri: "cpln://volumeset/{{APP_NAME}}-pg-vs"
|
|
150
153
|
path: "/var/lib/postgresql/data"
|
|
151
154
|
# Make the ENV value for the entry script a file
|
|
152
|
-
- uri: cpln://secret/
|
|
155
|
+
- uri: "cpln://secret/{{APP_NAME}}-pg-script"
|
|
153
156
|
path: "/usr/local/bin/cpln-entrypoint.sh"
|
|
154
157
|
inheritEnv: false
|
|
155
158
|
livenessProbe:
|
|
@@ -160,7 +163,7 @@ spec:
|
|
|
160
163
|
tcpSocket:
|
|
161
164
|
port: 5432
|
|
162
165
|
failureThreshold: 1
|
|
163
|
-
identityLink: //identity/
|
|
166
|
+
identityLink: "//identity/{{APP_NAME}}-pg-identity"
|
|
164
167
|
defaultOptions:
|
|
165
168
|
capacityAI: false
|
|
166
169
|
autoscaling:
|
|
@@ -8,7 +8,7 @@ spec:
|
|
|
8
8
|
- name: rails
|
|
9
9
|
cpu: 300m
|
|
10
10
|
inheritEnv: true
|
|
11
|
-
image: {{APP_IMAGE_LINK}}
|
|
11
|
+
image: "{{APP_IMAGE_LINK}}"
|
|
12
12
|
memory: 512Mi
|
|
13
13
|
ports:
|
|
14
14
|
- number: 3000
|
|
@@ -29,3 +29,5 @@ spec:
|
|
|
29
29
|
- 0.0.0.0/0
|
|
30
30
|
outboundAllowCIDR:
|
|
31
31
|
- 0.0.0.0/0
|
|
32
|
+
# cpflow apply-template replaces {{APP_IDENTITY_LINK}} with the app workload identity link.
|
|
33
|
+
identityLink: "{{APP_IDENTITY_LINK}}"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
kind: gvc
|
|
2
|
-
name: {{APP_NAME}}
|
|
2
|
+
name: "{{APP_NAME}}"
|
|
3
3
|
spec:
|
|
4
4
|
env:
|
|
5
5
|
- name: RAILS_ENV
|
|
@@ -9,7 +9,7 @@ spec:
|
|
|
9
9
|
- name: RAILS_SERVE_STATIC_FILES
|
|
10
10
|
value: "true"
|
|
11
11
|
- name: SECRET_KEY_BASE
|
|
12
|
-
value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE
|
|
12
|
+
value: "cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE"
|
|
13
13
|
staticPlacement:
|
|
14
14
|
locationLinks:
|
|
15
|
-
- {{APP_LOCATION_LINK}}
|
|
15
|
+
- "{{APP_LOCATION_LINK}}"
|