@gambi97/keel-cli 0.3.4 → 0.3.6
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.
- package/README.md +27 -18
- package/dist/bootstrap/github.js +20 -44
- package/dist/bootstrap/scaleway.js +25 -4
- package/dist/config.js +14 -5
- package/dist/generate.js +30 -8
- package/dist/index.js +7 -2
- package/dist/prompts.js +1 -1
- package/dist/state.js +8 -3
- package/dist/ui.js +3 -2
- package/package.json +1 -1
- package/templates/README.md +33 -14
- package/templates/_partials/apply-header.yml +7 -1
- package/templates/_partials/apply-job.yml +3 -2
package/README.md
CHANGED
|
@@ -62,7 +62,8 @@ non-empty repository is reported immediately, and only that answer is asked
|
|
|
62
62
|
again). It picks up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your
|
|
63
63
|
environment as defaults, then shows a **complete summary of what it will
|
|
64
64
|
create and where**. Nothing is touched before you confirm. When it finishes, push to `main` (or merge the
|
|
65
|
-
first PR) and the pipeline provisions the infrastructure
|
|
65
|
+
first PR) and the pipeline provisions the non-production infrastructure; a
|
|
66
|
+
version tag (`vX.Y.Z`) promotes to production.
|
|
66
67
|
|
|
67
68
|
Non-interactive and dry-run:
|
|
68
69
|
|
|
@@ -150,14 +151,16 @@ flowchart TD
|
|
|
150
151
|
CLI -->|creates state bucket| SCW[(Scaleway<br/>Object Storage)]
|
|
151
152
|
CLI -->|creates repo, secrets,<br/>variables, branch rules| GH[GitHub repo]
|
|
152
153
|
CLI -->|creates project, envs,<br/>placeholder secrets| INF[Infisical]
|
|
153
|
-
GH -->|
|
|
154
|
-
|
|
154
|
+
GH -->|merge to main| ACT[GitHub Actions]
|
|
155
|
+
GH -->|tag vX.Y.Z| ACT
|
|
156
|
+
ACT -->|apply non-prod / prod| PROD[Scaleway:<br/>Container + SQL + Registry]
|
|
155
157
|
INF -.->|app secrets at runtime| PROD
|
|
156
158
|
```
|
|
157
159
|
|
|
158
160
|
The CLI does the **bootstrap**, in seconds, on your machine. The first
|
|
159
161
|
`terraform apply` runs **in CI** on the first push to `main`, so nobody needs
|
|
160
|
-
local tooling or production credentials.
|
|
162
|
+
local tooling or production credentials. Non-production environments deploy on
|
|
163
|
+
every merge; production is promoted by pushing a version tag.
|
|
161
164
|
|
|
162
165
|
## What gets created, and when
|
|
163
166
|
|
|
@@ -167,7 +170,7 @@ local tooling or production credentials.
|
|
|
167
170
|
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
168
171
|
| Your machine | The generated repo: Terraform, workflows, README, initial git commit |
|
|
169
172
|
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key) |
|
|
170
|
-
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production`
|
|
173
|
+
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` promoted by a version tag); branch protection on `main` |
|
|
171
174
|
| Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), `DATABASE_URL` / `APP_URL` placeholders per environment, and `S3_*` placeholders when Object Storage is enabled |
|
|
172
175
|
|
|
173
176
|
**Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
|
|
@@ -190,7 +193,7 @@ my-app/
|
|
|
190
193
|
├── README.md # operating manual for the repo
|
|
191
194
|
├── .github/workflows/
|
|
192
195
|
│ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
|
|
193
|
-
│ ├── terraform-apply.yml # main: apply
|
|
196
|
+
│ ├── terraform-apply.yml # merge to main: apply non-prod; tag vX.Y.Z: apply prod
|
|
194
197
|
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
|
|
195
198
|
├── .keel/manifest.json # committed record: keel version, contract version, options
|
|
196
199
|
├── versions.tf · providers.tf · backend.tf
|
|
@@ -222,8 +225,8 @@ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
|
|
|
222
225
|
short version of the first deploy:
|
|
223
226
|
|
|
224
227
|
1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
|
|
225
|
-
databases (and an Object Storage bucket,
|
|
226
|
-
|
|
228
|
+
databases for the non-production environments (and an Object Storage bucket,
|
|
229
|
+
if enabled). Production waits for a version tag (step 5).
|
|
227
230
|
2. **Build and push your app image** to the registry endpoint from the apply
|
|
228
231
|
output (replace `<env>` with your target environment):
|
|
229
232
|
```sh
|
|
@@ -234,9 +237,12 @@ short version of the first deploy:
|
|
|
234
237
|
3. **Set `container_image`** in the tfvars and open a PR: the next apply
|
|
235
238
|
creates the containers.
|
|
236
239
|
4. **Replace the placeholder secrets** in Infisical with real values. The app
|
|
237
|
-
reads `DATABASE_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from
|
|
238
|
-
environment; non-production environments also receive
|
|
239
|
-
and enforce it.
|
|
240
|
+
reads `DATABASE_URL`, `APP_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from
|
|
241
|
+
its environment; non-production environments also receive
|
|
242
|
+
`BASIC_AUTH_ENABLED=true` and enforce it.
|
|
243
|
+
5. **Release to production**: push a version tag — `git tag v1.0.0 && git push
|
|
244
|
+
--tags` — and the production apply runs against that commit. A merge to
|
|
245
|
+
`main` never deploys production.
|
|
240
246
|
|
|
241
247
|
## Prerequisites
|
|
242
248
|
|
|
@@ -293,7 +299,9 @@ creating anything**.
|
|
|
293
299
|
- Actions secrets are encrypted client-side (libsodium sealed box) before
|
|
294
300
|
upload.
|
|
295
301
|
- `main` is protected: force pushes and deletion blocked, PRs need a green
|
|
296
|
-
plan
|
|
302
|
+
plan. Production never deploys from a merge — it is gated behind a version
|
|
303
|
+
tag, a free promotion control that works on any GitHub plan (required-reviewer
|
|
304
|
+
rules need a paid plan on private repos) and lives auditably in the repo.
|
|
297
305
|
- Terraform state lives in a private, versioned bucket with S3-native state
|
|
298
306
|
locking (`use_lockfile`), so concurrent applies cannot corrupt it.
|
|
299
307
|
- The state bucket is **restricted by a bucket policy** to the identity that
|
|
@@ -373,12 +381,13 @@ endpoints. Credentials live in Infisical, the container gets
|
|
|
373
381
|
**Which environments do I get, and can I change them?**
|
|
374
382
|
You choose at creation: production only, staging + production (default), or
|
|
375
383
|
dev + staging + production — interactively or with `--environments`
|
|
376
|
-
(e.g. `--environments prod` or `--environments dev,staging,prod`).
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
384
|
+
(e.g. `--environments prod` or `--environments dev,staging,prod`). Non-production
|
|
385
|
+
environments deploy on merge to `main` and enable Basic Auth by default;
|
|
386
|
+
production is promoted by pushing a version tag (`vX.Y.Z`), never by a merge. To
|
|
387
|
+
add a non-production environment later: add a `<env>.tfvars` (the plan and drift
|
|
388
|
+
workflows discover environments from the tfvars files at the repo root
|
|
389
|
+
automatically), an Infisical environment with the same slug, and a `main`-gated
|
|
390
|
+
job in `terraform-apply.yml`.
|
|
382
391
|
|
|
383
392
|
**Can I extend the generated repo with my own Terraform?**
|
|
384
393
|
Yes, and additions never edit generated files: drop a new `.tf` file at the
|
package/dist/bootstrap/github.js
CHANGED
|
@@ -24,18 +24,21 @@ export class GitHubError extends Error {
|
|
|
24
24
|
export async function authenticate(token) {
|
|
25
25
|
// Silence Octokit's own request logging: expected non-2xx responses (a 404
|
|
26
26
|
// for a repo that does not exist yet, a 401 for a bad token) are handled
|
|
27
|
-
// here and would otherwise scribble over the prompt spinner.
|
|
27
|
+
// here and would otherwise scribble over the prompt spinner. The same sink
|
|
28
|
+
// is passed at the request layer too: @octokit/request reads
|
|
29
|
+
// `request.log` (falling back to console) for deprecation notices carried
|
|
30
|
+
// in response headers, bypassing the instance logger.
|
|
31
|
+
const silentLog = { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
|
|
28
32
|
const octokit = new Octokit({
|
|
29
33
|
auth: token,
|
|
30
|
-
log:
|
|
34
|
+
log: silentLog,
|
|
35
|
+
request: { log: silentLog },
|
|
31
36
|
});
|
|
32
37
|
let login;
|
|
33
|
-
let ownerId;
|
|
34
38
|
let scopes;
|
|
35
39
|
try {
|
|
36
40
|
const { data, headers } = await octokit.users.getAuthenticated();
|
|
37
41
|
login = data.login;
|
|
38
|
-
ownerId = data.id;
|
|
39
42
|
scopes = headers['x-oauth-scopes'];
|
|
40
43
|
}
|
|
41
44
|
catch {
|
|
@@ -53,7 +56,7 @@ export async function authenticate(token) {
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
|
-
return { octokit, owner: login
|
|
59
|
+
return { octokit, owner: login };
|
|
57
60
|
}
|
|
58
61
|
export async function createContext(github) {
|
|
59
62
|
const identity = await authenticate(github.token);
|
|
@@ -209,11 +212,8 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
|
|
|
209
212
|
INFISICAL_HOST: answers.infisical.host,
|
|
210
213
|
};
|
|
211
214
|
await setVariables(ctx, variables);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await protectMainBranch(ctx, answers),
|
|
215
|
-
].filter((w) => w !== undefined);
|
|
216
|
-
return warnings.length > 0 ? warnings.join('\n\n') : undefined;
|
|
215
|
+
await configureEnvironments(ctx, answers);
|
|
216
|
+
return protectMainBranch(ctx, answers);
|
|
217
217
|
}
|
|
218
218
|
async function setSecrets(ctx, secrets) {
|
|
219
219
|
await sodium.ready;
|
|
@@ -255,44 +255,20 @@ async function setVariables(ctx, variables) {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
/**
|
|
258
|
-
* Each environment gets a GitHub deployment environment
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* auto-deploy on merge. Like the branch-protection miss, this degrades to a
|
|
264
|
-
* warning instead of killing the run.
|
|
258
|
+
* Each environment gets a GitHub deployment environment, used by the apply
|
|
259
|
+
* workflow for deployment tracking and history. No reviewer gate is requested:
|
|
260
|
+
* production is promoted by a version tag — the tag IS the gate, free on every
|
|
261
|
+
* plan, while required reviewers would need GitHub Enterprise Cloud on private
|
|
262
|
+
* repositories and would only duplicate what the tag already enforces.
|
|
265
263
|
*/
|
|
266
264
|
export async function configureEnvironments(ctx, answers) {
|
|
267
|
-
const ungated = [];
|
|
268
265
|
for (const env of answers.environments) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
catch (error) {
|
|
278
|
-
// Only the reviewer rule can trip the plan limit; re-throw anything else
|
|
279
|
-
// (and any failure on a non-gated environment, which asked for no rule).
|
|
280
|
-
if (!env.gated || error.status !== 422)
|
|
281
|
-
throw error;
|
|
282
|
-
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
283
|
-
owner: ctx.owner,
|
|
284
|
-
repo: ctx.repo,
|
|
285
|
-
environment_name: env.githubEnvironment,
|
|
286
|
-
});
|
|
287
|
-
ungated.push(env.githubEnvironment);
|
|
288
|
-
}
|
|
266
|
+
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
267
|
+
owner: ctx.owner,
|
|
268
|
+
repo: ctx.repo,
|
|
269
|
+
environment_name: env.githubEnvironment,
|
|
270
|
+
});
|
|
289
271
|
}
|
|
290
|
-
if (ungated.length === 0)
|
|
291
|
-
return undefined;
|
|
292
|
-
return (`Created the ${ungated.join(', ')} environment(s) without the manual-approval gate: ` +
|
|
293
|
-
'required reviewers need GitHub Enterprise Cloud on private repositories, so production ' +
|
|
294
|
-
'will auto-deploy on merge to main. Make the repository public (reviewers are free there) ' +
|
|
295
|
-
'or upgrade the plan, then re-run to add the gate.');
|
|
296
272
|
}
|
|
297
273
|
/**
|
|
298
274
|
* Require a green plan on PRs and forbid force-pushes/deletion of main.
|
|
@@ -33,6 +33,26 @@ export async function validateScalewayCredentials(scaleway) {
|
|
|
33
33
|
`not ${organizationId}. Check --scw-organization-id.`, 'organization');
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
/**
|
|
38
|
+
* Bucket-metadata calls made right after CreateBucket can race its propagation
|
|
39
|
+
* and answer NoSuchBucket for a bucket that provably exists (we created or
|
|
40
|
+
* Head-checked it moments before). Retry exactly that error with a short
|
|
41
|
+
* backoff; anything else is a real failure and is thrown immediately.
|
|
42
|
+
*/
|
|
43
|
+
export async function retryWhileBucketPropagates(fn, attempts = 5, baseDelayMs = 1000) {
|
|
44
|
+
for (let attempt = 1;; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
return await fn();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.name !== 'NoSuchBucket' || attempt >= attempts) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
await sleep(attempt * baseDelayMs);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
36
56
|
function s3Client(answers) {
|
|
37
57
|
return new S3Client({
|
|
38
58
|
region: answers.region,
|
|
@@ -107,13 +127,14 @@ async function lockDownStateBucket(client, bucket, answers) {
|
|
|
107
127
|
],
|
|
108
128
|
};
|
|
109
129
|
try {
|
|
110
|
-
await client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }));
|
|
130
|
+
await retryWhileBucketPropagates(() => client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) })));
|
|
111
131
|
return undefined;
|
|
112
132
|
}
|
|
113
133
|
catch (error) {
|
|
114
134
|
return (`Could not apply a policy to bucket "${bucket}" ` +
|
|
115
135
|
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
116
|
-
'the state bucket was NOT restricted to your identity.'
|
|
136
|
+
'the state bucket was NOT restricted to your identity. ' +
|
|
137
|
+
'Re-run keel to retry this step.');
|
|
117
138
|
}
|
|
118
139
|
}
|
|
119
140
|
/**
|
|
@@ -144,10 +165,10 @@ export async function ensureStateBucket(answers) {
|
|
|
144
165
|
try {
|
|
145
166
|
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
146
167
|
// Versioning protects the state history against accidental overwrites.
|
|
147
|
-
await client.send(new PutBucketVersioningCommand({
|
|
168
|
+
await retryWhileBucketPropagates(() => client.send(new PutBucketVersioningCommand({
|
|
148
169
|
Bucket: bucket,
|
|
149
170
|
VersioningConfiguration: { Status: 'Enabled' },
|
|
150
|
-
}));
|
|
171
|
+
})));
|
|
151
172
|
created = true;
|
|
152
173
|
}
|
|
153
174
|
catch (createError) {
|
package/dist/config.js
CHANGED
|
@@ -11,7 +11,6 @@ const ENV_DEFAULTS = {
|
|
|
11
11
|
displayName: 'Development',
|
|
12
12
|
githubEnvironment: 'dev',
|
|
13
13
|
production: false,
|
|
14
|
-
gated: false,
|
|
15
14
|
minScale: 0,
|
|
16
15
|
maxScale: 1,
|
|
17
16
|
},
|
|
@@ -19,7 +18,6 @@ const ENV_DEFAULTS = {
|
|
|
19
18
|
displayName: 'Staging',
|
|
20
19
|
githubEnvironment: 'staging',
|
|
21
20
|
production: false,
|
|
22
|
-
gated: false,
|
|
23
21
|
minScale: 0,
|
|
24
22
|
maxScale: 1,
|
|
25
23
|
},
|
|
@@ -27,7 +25,6 @@ const ENV_DEFAULTS = {
|
|
|
27
25
|
displayName: 'Production',
|
|
28
26
|
githubEnvironment: 'production',
|
|
29
27
|
production: true,
|
|
30
|
-
gated: true,
|
|
31
28
|
minScale: 0,
|
|
32
29
|
// Start small: one instance is enough to go live and costs the least;
|
|
33
30
|
// raising the ceiling later is a one-line prod.tfvars change.
|
|
@@ -50,12 +47,25 @@ export const DEFAULT_ENV_PRESET = 'staging+prod';
|
|
|
50
47
|
* Lock a resume's configuration to what the repo was generated with. Scaling
|
|
51
48
|
* is intentionally not carried: it only shapes the tfvars, which are already
|
|
52
49
|
* generated on any run that has a manifest, so post-generate steps never read it.
|
|
50
|
+
*
|
|
51
|
+
* The GitHub repo identity is locked too: on resume the target repository is
|
|
52
|
+
* already decided (keel created and pushed it), so the picker must not run and
|
|
53
|
+
* must never frame the project's own — now non-empty — repo as "create a new
|
|
54
|
+
* one". Older manifests without a github block fall back to the project name,
|
|
55
|
+
* which is the repo default and the only repository the project could own. An
|
|
56
|
+
* explicit --repo-name still wins (the established flag-precedence rule).
|
|
53
57
|
*/
|
|
54
58
|
export function hydrateConfigFromManifest(partial, manifest) {
|
|
55
59
|
partial.region = manifest.region;
|
|
56
60
|
partial.environments = manifest.environments;
|
|
57
61
|
partial.objectStorage = manifest.options.objectStorage;
|
|
58
62
|
partial.basicAuth = manifest.options.basicAuth;
|
|
63
|
+
if (!partial.github.repoName) {
|
|
64
|
+
partial.github.repoName = manifest.github?.repoName ?? manifest.projectName;
|
|
65
|
+
}
|
|
66
|
+
if (partial.github.repoPrivate === undefined && manifest.github?.repoPrivate !== undefined) {
|
|
67
|
+
partial.github.repoPrivate = manifest.github.repoPrivate;
|
|
68
|
+
}
|
|
59
69
|
}
|
|
60
70
|
export class ConfigError extends Error {
|
|
61
71
|
}
|
|
@@ -210,8 +220,7 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
|
210
220
|
displayName: def.displayName,
|
|
211
221
|
githubEnvironment: def.githubEnvironment,
|
|
212
222
|
production: def.production,
|
|
213
|
-
|
|
214
|
-
// Basic Auth is a non-production safety net; production is never gated by it.
|
|
223
|
+
// Basic Auth is a non-production safety net; production never has it.
|
|
215
224
|
basicAuth: def.production ? false : basicAuth,
|
|
216
225
|
minScale,
|
|
217
226
|
maxScale,
|
package/dist/generate.js
CHANGED
|
@@ -26,6 +26,12 @@ function renderManifest(answers) {
|
|
|
26
26
|
objectStorage: answers.objectStorage,
|
|
27
27
|
basicAuth: answers.basicAuth,
|
|
28
28
|
},
|
|
29
|
+
// Recorded so a resume locks the repository instead of re-running the
|
|
30
|
+
// picker; see hydrateConfigFromManifest.
|
|
31
|
+
github: {
|
|
32
|
+
repoName: answers.github.repoName,
|
|
33
|
+
repoPrivate: answers.github.repoPrivate,
|
|
34
|
+
},
|
|
29
35
|
};
|
|
30
36
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
31
37
|
}
|
|
@@ -99,16 +105,32 @@ function isSpecial(rel) {
|
|
|
99
105
|
* in deploy order, each `needs:` the previous so applies run sequentially.
|
|
100
106
|
*/
|
|
101
107
|
function renderApplyWorkflow(source, answers, globalTokens) {
|
|
102
|
-
|
|
108
|
+
// Non-production environments deploy on merge to main (chained in order);
|
|
109
|
+
// production is promoted by a version tag. The triggers and the per-job
|
|
110
|
+
// guards are assembled from which environments exist, so a prod-only repo
|
|
111
|
+
// has no branch trigger and a prod-less selection has no tag trigger.
|
|
112
|
+
const nonProd = answers.environments.filter((env) => !env.production);
|
|
113
|
+
const prod = answers.environments.find((env) => env.production);
|
|
114
|
+
const triggers = [];
|
|
115
|
+
if (nonProd.length > 0)
|
|
116
|
+
triggers.push(' branches: [main]');
|
|
117
|
+
if (prod)
|
|
118
|
+
triggers.push(' tags: ["v*.*.*"]');
|
|
119
|
+
const header = renderContent(readFileSync(join(source, '_partials/apply-header.yml'), 'utf8'), { ...globalTokens, __APPLY_TRIGGERS__: triggers.join('\n') }, '_partials/apply-header.yml');
|
|
103
120
|
const jobTemplate = readFileSync(join(source, '_partials/apply-job.yml'), 'utf8');
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
const renderJob = (env, applyIf, needs) => renderContent(jobTemplate, {
|
|
122
|
+
__ENV_SLUG__: env.slug,
|
|
123
|
+
__GH_ENVIRONMENT__: env.githubEnvironment,
|
|
124
|
+
__APPLY_IF__: applyIf,
|
|
125
|
+
__NEEDS_LINE__: needs,
|
|
126
|
+
}, '_partials/apply-job.yml');
|
|
127
|
+
const jobs = nonProd.map((env, i) => {
|
|
128
|
+
const previous = nonProd[i - 1];
|
|
129
|
+
return renderJob(env, "github.ref == 'refs/heads/main'", previous ? ` needs: apply-${previous.slug}\n` : '');
|
|
111
130
|
});
|
|
131
|
+
if (prod) {
|
|
132
|
+
jobs.push(renderJob(prod, "startsWith(github.ref, 'refs/tags/')", ''));
|
|
133
|
+
}
|
|
112
134
|
return header + jobs.join('\n');
|
|
113
135
|
}
|
|
114
136
|
function renderContent(content, tokens, file) {
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js
|
|
|
7
7
|
import { generateProject, GenerateError, readManifest } from './generate.js';
|
|
8
8
|
import { toolVersion } from './meta.js';
|
|
9
9
|
import { confirmSummary, fillMissing } from './prompts.js';
|
|
10
|
-
import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
|
|
10
|
+
import { isDone, loadState, markDone, STATE_FILE, stepData, stepWarning, } from './state.js';
|
|
11
11
|
import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
|
|
12
12
|
import { ensureStateBucket, validateScalewayCredentials } from './bootstrap/scaleway.js';
|
|
13
13
|
import { bootstrapInfisical, validateInfisical } from './bootstrap/infisical.js';
|
|
@@ -316,8 +316,13 @@ async function runBootstrap(answers, state, options) {
|
|
|
316
316
|
log.info(step.skipMessage);
|
|
317
317
|
continue;
|
|
318
318
|
}
|
|
319
|
+
// A step that degraded with a warning last run is re-run (idempotent) so
|
|
320
|
+
// the degraded part can heal — e.g. a bucket policy that raced creation.
|
|
321
|
+
if (stepWarning(state, step.name)) {
|
|
322
|
+
log.info('Previous run finished this step with a warning — retrying it.');
|
|
323
|
+
}
|
|
319
324
|
const result = await withSpinner(step.label(answers, ctx), () => step.run(answers, ctx, state));
|
|
320
|
-
markDone(answers.targetDir, state, step.name, result?.data);
|
|
325
|
+
markDone(answers.targetDir, state, step.name, result?.data, result?.warning);
|
|
321
326
|
if (result?.warning)
|
|
322
327
|
log.warn(result.warning);
|
|
323
328
|
}
|
package/dist/prompts.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function fillMissing(partial, options) {
|
|
|
55
55
|
const resuming = manifest !== undefined && manifest.projectName === out.projectName;
|
|
56
56
|
if (manifest && resuming) {
|
|
57
57
|
hydrateConfigFromManifest(out, manifest);
|
|
58
|
-
log.info(`Resuming "${out.projectName}" — region, environments and options are locked to its .keel manifest.`);
|
|
58
|
+
log.info(`Resuming "${out.projectName}" — repository, region, environments and options are locked to its .keel manifest.`);
|
|
59
59
|
}
|
|
60
60
|
// Each block opens with a "┌ <Provider>" section corner and closes with a
|
|
61
61
|
// "└ <Provider> connected — …" line, so every question flows inside its own
|
package/dist/state.js
CHANGED
|
@@ -18,10 +18,15 @@ export function saveState(targetDir, state) {
|
|
|
18
18
|
writeFileSync(join(targetDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
19
19
|
}
|
|
20
20
|
export function isDone(state, step) {
|
|
21
|
-
|
|
21
|
+
const record = state.steps[step];
|
|
22
|
+
return record !== undefined && record.warning === undefined;
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
/** The warning a previous run recorded for this step, if it degraded. */
|
|
25
|
+
export function stepWarning(state, step) {
|
|
26
|
+
return state.steps[step]?.warning;
|
|
27
|
+
}
|
|
28
|
+
export function markDone(targetDir, state, step, data, warning) {
|
|
29
|
+
state.steps[step] = { ...(data ? { data } : {}), ...(warning ? { warning } : {}) };
|
|
25
30
|
saveState(targetDir, state);
|
|
26
31
|
}
|
|
27
32
|
export function stepData(state, step, key) {
|
package/dist/ui.js
CHANGED
|
@@ -59,7 +59,7 @@ export function renderSummary(answers, dryRun) {
|
|
|
59
59
|
for (const env of answers.environments) {
|
|
60
60
|
const flags = [
|
|
61
61
|
`scale ${env.minScale}-${env.maxScale}`,
|
|
62
|
-
env.
|
|
62
|
+
env.production ? 'deploys on version tag' : 'deploys on merge to main',
|
|
63
63
|
env.basicAuth ? 'basic auth' : null,
|
|
64
64
|
]
|
|
65
65
|
.filter(Boolean)
|
|
@@ -79,9 +79,10 @@ export function renderNextSteps(answers, repoUrl, containerUrlHint) {
|
|
|
79
79
|
` registry created by the first pipeline run (${containerUrlHint}).`,
|
|
80
80
|
` 2. Replace the placeholder secrets in Infisical (${answers.infisical.host})`,
|
|
81
81
|
` project "${answers.infisical.projectName}" with real values.`,
|
|
82
|
-
` 3. Push to main (or merge a PR) to
|
|
82
|
+
` 3. Push to main (or merge a PR) to deploy the non-production environments:`,
|
|
83
83
|
` ${repoUrl}/actions`,
|
|
84
84
|
` 4. Set container_image in ${answers.environments.map((e) => `${e.slug}.tfvars`).join(' / ')} once an image exists.`,
|
|
85
|
+
` 5. Release to production with a version tag: git tag v1.0.0 && git push --tags`,
|
|
85
86
|
'',
|
|
86
87
|
`Repository: ${repoUrl}`,
|
|
87
88
|
].join('\n');
|
package/package.json
CHANGED
package/templates/README.md
CHANGED
|
@@ -40,12 +40,18 @@ Credentials live in GitHub Actions encrypted secrets (CI) and in Infisical
|
|
|
40
40
|
|
|
41
41
|
```
|
|
42
42
|
PR to main -> terraform fmt + validate + plan (every environment), shown as PR checks
|
|
43
|
-
merge/push main -> apply
|
|
43
|
+
merge/push main -> apply non-production environments in order (dev, staging)
|
|
44
|
+
tag vX.Y.Z -> apply production (the commit the tag points at)
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
- `main` is protected: PRs need a green plan, force pushes are blocked.
|
|
47
|
-
-
|
|
48
|
-
|
|
48
|
+
- Non-production environments apply on every merge to `main`, in order, each
|
|
49
|
+
`needs:` the previous one — a continuously-deployed staging.
|
|
50
|
+
- **Production is promoted by a version tag**, not by a merge: push a tag like
|
|
51
|
+
`v1.2.0` and the production apply runs against that commit. The tag is the
|
|
52
|
+
gate — it works on any GitHub plan (unlike required-reviewer rules, which
|
|
53
|
+
need a paid plan on private repos), lives in the repo, and rolling back is
|
|
54
|
+
re-tagging an earlier commit. Cut one with `git tag v1.2.0 && git push --tags`.
|
|
49
55
|
- After each apply, the pipeline syncs the secrets Terraform produced to
|
|
50
56
|
Infisical for that environment: a ready-to-use Postgres `DATABASE_URL`
|
|
51
57
|
(whose credential is a dedicated IAM application that can only read/write
|
|
@@ -60,9 +66,10 @@ merge/push main -> apply each environment in order; any gated environment (pro
|
|
|
60
66
|
## First deploy
|
|
61
67
|
|
|
62
68
|
1. **Merge or push to `main`.** The first pipeline run creates registry and
|
|
63
|
-
databases (and the Object Storage
|
|
64
|
-
Container is intentionally skipped until
|
|
65
|
-
is empty), so the first apply is green.
|
|
69
|
+
databases for the non-production environments (and the Object Storage
|
|
70
|
+
bucket, if enabled). The Serverless Container is intentionally skipped until
|
|
71
|
+
an image exists (`container_image` is empty), so the first apply is green.
|
|
72
|
+
Production waits for a version tag (step 5).
|
|
66
73
|
|
|
67
74
|
2. **Build and push the app image** (any Dockerfile, listening on port 8080
|
|
68
75
|
by default), replacing `<env>` with your target environment (e.g. staging):
|
|
@@ -90,7 +97,19 @@ merge/push main -> apply each environment in order; any gated environment (pro
|
|
|
90
97
|
|
|
91
98
|
Every secret in the Infisical environment is injected into the container
|
|
92
99
|
as a secret environment variable, so adding an app secret is: add it in
|
|
93
|
-
Infisical, re-run the apply (
|
|
100
|
+
Infisical, re-run the apply (a merge to `main` for non-prod, a new tag for
|
|
101
|
+
production).
|
|
102
|
+
|
|
103
|
+
5. **Release to production**: once staging looks good, cut a version tag on the
|
|
104
|
+
commit you want live and push it:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
git tag v1.0.0
|
|
108
|
+
git push --tags
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The production apply runs against that commit. Nothing reaches production
|
|
112
|
+
without a tag, so a merge to `main` can never deploy it by accident.
|
|
94
113
|
|
|
95
114
|
## Basic Auth on non-production environments
|
|
96
115
|
|
|
@@ -115,13 +134,13 @@ environment enables it is the `enable_basic_auth` flag in its `<env>.tfvars`.
|
|
|
115
134
|
secrets (repo Settings > Secrets and variables > Actions).
|
|
116
135
|
- **Add a custom domain**: add a `scaleway_container_domain` resource in
|
|
117
136
|
`modules/app_stack` pointing at the container, plus your DNS record.
|
|
118
|
-
- **Add
|
|
119
|
-
drift workflows discover environments from the tfvars files
|
|
120
|
-
create an Infisical environment with the same slug, and add a
|
|
121
|
-
`.github/workflows/terraform-apply.yml`
|
|
122
|
-
`
|
|
123
|
-
(<env>)` check becomes required on
|
|
124
|
-
branch-protection settings.
|
|
137
|
+
- **Add a non-production environment**: add a `<env>.tfvars` at the repo root
|
|
138
|
+
(the plan and drift workflows discover environments from the tfvars files
|
|
139
|
+
automatically), create an Infisical environment with the same slug, and add a
|
|
140
|
+
job in `.github/workflows/terraform-apply.yml` — mirror an existing non-prod
|
|
141
|
+
job (`if: github.ref == 'refs/heads/main'`) and set its `needs:` to chain
|
|
142
|
+
after the previous one. Note: the new `plan (<env>)` check becomes required on
|
|
143
|
+
`main` only after you add it in the branch-protection settings.
|
|
125
144
|
- **Pin provider versions**: after any local `terraform init`, commit the
|
|
126
145
|
generated `.terraform.lock.hcl` so CI resolves the exact same provider
|
|
127
146
|
builds on every run.
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
name: Terraform apply
|
|
2
2
|
|
|
3
|
+
# Non-production environments deploy on every merge to main; production is
|
|
4
|
+
# promoted by pushing a version tag (vX.Y.Z), so a merge never touches prod.
|
|
5
|
+
# Tag-based promotion is the free, git-native gate (no paid environment rule):
|
|
6
|
+
# the tag lives in the repo, is auditable, and rolling back is re-tagging.
|
|
3
7
|
on:
|
|
4
8
|
push:
|
|
5
|
-
|
|
9
|
+
__APPLY_TRIGGERS__
|
|
6
10
|
|
|
7
11
|
permissions:
|
|
8
12
|
contents: read
|
|
9
13
|
|
|
10
14
|
concurrency:
|
|
15
|
+
# Serialize all applies against the shared state bucket; a tag deploy and a
|
|
16
|
+
# main deploy never run at the same time.
|
|
11
17
|
group: terraform-apply
|
|
12
18
|
cancel-in-progress: false
|
|
13
19
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
apply-__ENV_SLUG__:
|
|
2
2
|
name: apply (__ENV_SLUG__)
|
|
3
3
|
runs-on: ubuntu-latest
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Non-prod runs on merge to main; prod runs only on a version tag.
|
|
5
|
+
if: __APPLY_IF__
|
|
6
|
+
__NEEDS_LINE__ environment: __GH_ENVIRONMENT__
|
|
6
7
|
steps:
|
|
7
8
|
- uses: actions/checkout@v4
|
|
8
9
|
|