dwe-core 0.1.0a1__tar.gz

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.
@@ -0,0 +1,729 @@
1
+ Metadata-Version: 2.4
2
+ Name: dwe-core
3
+ Version: 0.1.0a1
4
+ Summary: DWE CLI - Data Warehouse Ecosystem Orchestrator
5
+ Requires-Python: >=3.10
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Dist: PyGithub (>=2.1.1)
13
+ Requires-Dist: copier (>=9.0.0)
14
+ Requires-Dist: gitpython (>=3.1.40)
15
+ Requires-Dist: jinja2 (>=3.1.2)
16
+ Requires-Dist: python-gitlab (>=4.4.0)
17
+ Requires-Dist: rich (>=13.7.0)
18
+ Requires-Dist: typer[all] (>=0.9.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dwe-core
22
+
23
+ The **DWE CLI** (`dwe`) is the orchestration brain of the Data Warehouse Ecosystem. It takes a blank or existing client Git repository and injects a fully working **Adapter** — infrastructure, application config, CI/CD pipelines, and local dev commands — in a single command.
24
+
25
+ ## How it works
26
+
27
+ ```
28
+ dwe create-service test_adapter --git-repo https://github.com/client/repo --envs dev --envs prod
29
+ ```
30
+
31
+ Internally this does:
32
+
33
+ ```
34
+ 1. Clone GitPython clones the client repo to a temp directory
35
+ 2. Hydrate Copier renders the adapter template into the clone
36
+ 3. State CLI writes dwe-state.json
37
+ 4. CI/CD CLI renders per-environment GitHub Actions / GitLab CI files
38
+ 5. Branch initial-commit branch is created and committed
39
+ 6. Env branches dev, prod branches are created from initial-commit
40
+ 7. Push All branches are pushed to the remote
41
+ 8. Secrets GitHub/GitLab API uploads secrets to the repository settings
42
+ ```
43
+
44
+ The result is a client repo that already has working infrastructure code, a `justfile` with `just up` / `just deploy-prod`, and CI/CD that deploys to the right environment when you push to its branch.
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install poetry # if not already installed
52
+ poetry install # from dwe-core source (creates venv, installs deps)
53
+ # or once published:
54
+ pip install dwe-core
55
+ ```
56
+
57
+ Verify:
58
+
59
+ ```bash
60
+ dwe --help
61
+ dwe list-adapters
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Commands
67
+
68
+ ### `dwe create-service`
69
+
70
+ ```
71
+ dwe create-service <adapter_name> \
72
+ --git-repo <url> \
73
+ [--envs <name>]... \ # default: development, main
74
+ [--secrets <json>] \ # e.g. '{"AWS_KEY":"abc"}'
75
+ [--tag <version>] \ # adapter git tag, e.g. v1.2.0
76
+ [--token <api-token>] \ # or set GITHUB_TOKEN / GITLAB_TOKEN
77
+ [--aws-region <region>] \
78
+ [--instance-type <type>] \
79
+ [--clone-dir <path>] # default: temp dir
80
+ ```
81
+
82
+ **Example — full run:**
83
+
84
+ ```bash
85
+ export GITHUB_TOKEN=ghp_xxxx
86
+
87
+ dwe create-service test_adapter \
88
+ --git-repo https://github.com/acme/data-platform \
89
+ --envs development \
90
+ --envs staging \
91
+ --envs main \
92
+ --secrets '{"PULUMI_ACCESS_TOKEN":"pul-xxx","AWS_ACCESS_KEY_ID":"AKI...","AWS_SECRET_ACCESS_KEY":"..."}' \
93
+ --tag v1.0.0 \
94
+ --aws-region eu-west-1 \
95
+ --instance-type t3.small
96
+ ```
97
+
98
+ After this runs, the `data-platform` repo has:
99
+
100
+ ```
101
+ .github/workflows/
102
+ deploy-development.yaml
103
+ deploy-staging.yaml
104
+ deploy-main.yaml
105
+ blueprint/
106
+ html/index.html
107
+ instance-setup.sh
108
+ docker-compose.yml
109
+ docker-compose.prod.yml
110
+ .env.example
111
+ justfile
112
+ infrastructure/
113
+ __main__.py <- project_name, instance_type already substituted
114
+ Pulumi.yaml
115
+ requirements.txt
116
+ dwe-state.json
117
+ .copier-answers.yml <- Copier's internal state (enables future updates)
118
+ ```
119
+
120
+ ### `dwe update-service`
121
+
122
+ ```
123
+ dwe update-service <adapter_name> <local_path> [--tag <version>]
124
+ ```
125
+
126
+ **Example:**
127
+
128
+ ```bash
129
+ dwe update-service test_adapter ./data-platform --tag v1.2.0
130
+ ```
131
+
132
+ Internally:
133
+ 1. Reads `dwe-state.json` and validates the adapter name matches
134
+ 2. Creates a branch `dwe-update-20260322-1.2.0`
135
+ 3. Runs `copier.run_update()` — **smart merge** that preserves your customisations
136
+ 4. Updates `dwe-state.json` with the new version
137
+
138
+ Review the diff on the branch, then merge into your environment branches to trigger deployments.
139
+
140
+ ### `dwe list-adapters`
141
+
142
+ ```bash
143
+ dwe list-adapters
144
+ ```
145
+
146
+ Shows all adapters registered in `adapters.json`.
147
+
148
+ ---
149
+
150
+ ## Adapter Registry (`adapters.json`)
151
+
152
+ ```json
153
+ {
154
+ "test_adapter": {
155
+ "path": "/absolute/path/to/dwe_test_adapter",
156
+ "type": "local",
157
+ "description": "Test adapter: AWS EC2 instance via Pulumi"
158
+ },
159
+ "superset_adapter": {
160
+ "url": "https://github.com/hipposys/dwe-superset-adapter",
161
+ "type": "git",
162
+ "description": "Apache Superset on ECS"
163
+ }
164
+ }
165
+ ```
166
+
167
+ ---
168
+
169
+ ## How to Define a New Adapter
170
+
171
+ An adapter is a **real, runnable project** that also serves as a Copier template. The guiding principle:
172
+
173
+ > **The adapter must work locally as-is.** A developer should be able to `git clone` the adapter, run `just up`, and have a working service — without running the DWE CLI at all.
174
+
175
+ ### Step 1: Create the adapter repository
176
+
177
+ ```bash
178
+ mkdir my_adapter && cd my_adapter
179
+ git init
180
+ ```
181
+
182
+ ### Step 2: Build a working application first
183
+
184
+ Build your service as a real project before adding any template variables. For example, if you're building a Superset adapter:
185
+
186
+ ```bash
187
+ # Make it work locally first
188
+ docker compose up # verify it runs
189
+ ```
190
+
191
+ Only once everything works locally do you introduce `{{ variables }}`.
192
+
193
+ ### Step 3: Directory structure
194
+
195
+ ```
196
+ my_adapter/
197
+ ├── copier.yml # Copier config + question definitions
198
+
199
+ ├── docker-compose.yml # Real, runnable. Uses ${ENV_VAR:-default} for runtime values.
200
+ ├── docker-compose.prod.yml # Production overrides (restart policy, logging)
201
+ ├── .env.example # Template for secrets — committed; .env is git-ignored
202
+ ├── .gitignore
203
+
204
+ ├── justfile # Dev commands (just up, just deploy-prod, just infra-up)
205
+
206
+ ├── blueprint/ # Application-level config files
207
+ │ ├── html/ # or nginx.conf, superset_config.py, etc.
208
+ │ └── instance-setup.sh # EC2 user-data bootstrap script
209
+
210
+ ├── infrastructure/ # Pulumi IaC — only files here use .jinja
211
+ │ ├── __main__.py.jinja # <- .jinja because it embeds {{ project_name }}
212
+ │ ├── Pulumi.yaml.jinja # <- .jinja because it embeds {{ project_name }}
213
+ │ └── requirements.txt
214
+
215
+ └── ci-templates/ # Jinja2 templates rendered by the CLI (not Copier)
216
+ └── deploy.yaml # Uses {{ ENV_NAME }}, {{ AWS_REGION }}
217
+ ```
218
+
219
+ ### Step 4: Write `copier.yml`
220
+
221
+ `copier.yml` controls how Copier processes the adapter. Key settings:
222
+
223
+ ```yaml
224
+ _templates_suffix: .jinja # ONLY files ending in .jinja are treated as templates
225
+ # Everything else is copied verbatim
226
+
227
+ _exclude:
228
+ - copier.yml # Don't copy Copier's own config
229
+ - ci-templates # CLI handles this separately
230
+ - README.md # Adapter's README is not for client repos
231
+ - .git
232
+ - .env # Never copy actual secrets
233
+ - __pycache__
234
+ - "*.pyc"
235
+
236
+ _skip_if_exists:
237
+ - .env.example # Preserve user customisations on updates
238
+
239
+ # Questions (answered non-interactively by the dwe CLI):
240
+ project_name:
241
+ type: str
242
+ help: "Client project name (used for cloud resource naming)"
243
+
244
+ adapter_name:
245
+ type: str
246
+ default: "my_adapter"
247
+ when: false # always set programmatically
248
+
249
+ adapter_version:
250
+ type: str
251
+ default: "v1.0.0"
252
+ when: false # always set programmatically
253
+
254
+ environments:
255
+ type: yaml
256
+ default: "[development, main]"
257
+
258
+ aws_region:
259
+ type: str
260
+ default: "us-east-1"
261
+ ```
262
+
263
+ ### Step 5: Decide what needs Jinja2
264
+
265
+ Apply this rule: **if the value changes per client, use `{{ variable }}`. If it changes per deployment environment, use a `.env` variable.**
266
+
267
+ | File | Approach | Reason |
268
+ |---|---|---|
269
+ | `docker-compose.yml` | `.env` interpolation (`${VAR:-default}`) | Works locally without any substitution; runtime config |
270
+ | `infrastructure/__main__.py` | Jinja2 (`.jinja` extension) | Cloud resource names must be unique per client at provision time |
271
+ | `infrastructure/Pulumi.yaml` | Jinja2 (`.jinja` extension) | Stack name must be unique per client |
272
+ | `justfile` | Verbatim copy (no `.jinja`) | Commands are identical across clients |
273
+ | `blueprint/instance-setup.sh` | Verbatim copy | Generic bootstrap, no client-specific values |
274
+ | `.env.example` | Verbatim copy | Users fill in real values after cloning |
275
+
276
+ **Jinja2 syntax in `.jinja` files:**
277
+
278
+ ```python
279
+ # infrastructure/__main__.py.jinja
280
+ instance = aws.ec2.Instance(
281
+ "{{ project_name }}-instance", # <- substituted by Copier
282
+ instance_type="{{ instance_type }}",
283
+ ...
284
+ )
285
+ ```
286
+
287
+ After `dwe create-service` this becomes:
288
+
289
+ ```python
290
+ instance = aws.ec2.Instance(
291
+ "acme-data-platform-instance",
292
+ instance_type="t3.small",
293
+ ...
294
+ )
295
+ ```
296
+
297
+ ### Step 6: Write `ci-templates/deploy.yaml`
298
+
299
+ This is a Jinja2 file rendered by the `dwe` CLI (not by Copier) to generate one workflow file per environment. The CLI uses `{@ @}` as variable delimiters (not `{{ }}`), so GitHub Actions `${{ secrets.X }}` syntax passes through **untouched** — no escaping needed.
300
+
301
+ ```yaml
302
+ name: Deploy to {@ ENV_NAME @}
303
+
304
+ on:
305
+ push:
306
+ branches:
307
+ - {@ ENV_NAME @}
308
+ pull_request:
309
+ branches:
310
+ - {@ ENV_NAME @}
311
+
312
+ jobs:
313
+ deploy:
314
+ runs-on: ubuntu-latest
315
+ environment: {@ ENV_NAME @}
316
+ steps:
317
+ - uses: actions/checkout@v4
318
+ - name: Deploy
319
+ run: just deploy-prod
320
+ env:
321
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} # passes through unchanged
322
+ AWS_REGION: {@ AWS_REGION @} # substituted by dwe CLI
323
+ ```
324
+
325
+ Available variables: `{@ ENV_NAME @}`, `{@ AWS_REGION @}`.
326
+
327
+ ### Step 7: Register the adapter
328
+
329
+ Add an entry to `dwe-core/adapters.json`:
330
+
331
+ **Local (development):**
332
+ ```json
333
+ {
334
+ "my_adapter": {
335
+ "path": "/absolute/path/to/my_adapter",
336
+ "type": "local",
337
+ "description": "My adapter description"
338
+ }
339
+ }
340
+ ```
341
+
342
+ **Remote Git (production):**
343
+ ```json
344
+ {
345
+ "my_adapter": {
346
+ "url": "https://github.com/your-org/my-adapter",
347
+ "type": "git",
348
+ "description": "My adapter description"
349
+ }
350
+ }
351
+ ```
352
+
353
+ ### Step 8: Test the adapter
354
+
355
+ **Test locally first (without DWE CLI):**
356
+
357
+ ```bash
358
+ cd my_adapter
359
+ cp .env.example .env
360
+ just up # docker compose up — must work here
361
+ ```
362
+
363
+ **Test Copier rendering in isolation:**
364
+
365
+ ```bash
366
+ pip install copier
367
+ copier copy /path/to/my_adapter /tmp/test-output \
368
+ --data project_name=testproject \
369
+ --data aws_region=us-east-1 \
370
+ --defaults --overwrite --trust
371
+
372
+ # Inspect the output
373
+ ls /tmp/test-output
374
+ cat /tmp/test-output/infrastructure/Pulumi.yaml # should have project_name substituted
375
+ cat /tmp/test-output/docker-compose.yml # should be identical to source
376
+ cd /tmp/test-output && docker compose up # should still work
377
+ ```
378
+
379
+ **Test via dwe CLI:**
380
+
381
+ ```bash
382
+ dwe create-service my_adapter \
383
+ --git-repo https://github.com/test-org/empty-repo \
384
+ --envs development \
385
+ --envs main
386
+ ```
387
+
388
+ ---
389
+
390
+ ## Adapter Versioning and Updates
391
+
392
+ Tag your adapter repository with semantic version tags. The DWE CLI and Copier use these tags for `update-service`:
393
+
394
+ ```bash
395
+ cd my_adapter
396
+ git add -A && git commit -m "feat: add postgres service"
397
+ git tag v1.1.0
398
+ git push origin v1.1.0
399
+ ```
400
+
401
+ When a client wants to update:
402
+
403
+ ```bash
404
+ dwe update-service my_adapter ./client-repo --tag v1.1.0
405
+ ```
406
+
407
+ Copier reads the source URL from `.copier-answers.yml` in the client repo, checks out `v1.1.0`, and runs a 3-way merge. Files the user has customised are preserved where possible; conflicts surface as standard git merge conflicts.
408
+
409
+ **What gets updated:**
410
+ - `infrastructure/` — Pulumi code (Jinja2 re-rendered with new template)
411
+ - `blueprint/` — Application config files
412
+ - `justfile` — Dev commands
413
+
414
+ **What is NOT updated (protected):**
415
+ - `.env.example` — skipped if it already exists (`_skip_if_exists` in `copier.yml`)
416
+ - `.copier-answers.yml` — managed by Copier internally
417
+
418
+ ---
419
+
420
+ ## State Files
421
+
422
+ ### `dwe-state.json` (DWE-managed)
423
+
424
+ Written by the `dwe` CLI after `copier.run_copy()`. Tracks DWE-specific metadata:
425
+
426
+ ```json
427
+ {
428
+ "dwe_version": "1.0.0",
429
+ "adapter": {
430
+ "name": "test_adapter",
431
+ "version": "v1.0.0",
432
+ "last_update": "2026-03-22"
433
+ },
434
+ "environments": ["development", "main"],
435
+ "infrastructure": "pulumi"
436
+ }
437
+ ```
438
+
439
+ ### `.copier-answers.yml` (Copier-managed)
440
+
441
+ Written by Copier. Tracks the template source, version, and question answers. **Do not edit manually.** This is what enables `copier.run_update()` to know where the template came from.
442
+
443
+ ```yaml
444
+ # Changes here will be overwritten by copier
445
+ _commit: v1.0.0
446
+ _src_path: /path/to/my_adapter
447
+ project_name: acme-data-platform
448
+ aws_region: eu-west-1
449
+ instance_type: t3.small
450
+ ```
451
+
452
+ Both files coexist. `dwe-state.json` is for DWE tooling; `.copier-answers.yml` is for Copier's update machinery.
453
+
454
+ ---
455
+
456
+ ## Developer Workflow After `create-service`
457
+
458
+ Once the client repo is hydrated, the full developer loop is:
459
+
460
+ **1. Local development (laptop):**
461
+
462
+ ```bash
463
+ git clone https://github.com/client/data-platform
464
+ cd data-platform
465
+ cp .env.example .env # fill in local values (no real AWS keys needed)
466
+ just up # docker compose up — app is running at localhost:8080
467
+ ```
468
+
469
+ **2. Provision cloud infrastructure (once):**
470
+
471
+ ```bash
472
+ # Fill in real AWS keys in .env
473
+ just install-infra # pip install pulumi pulumi-aws
474
+ just infra-preview # see what Pulumi will create
475
+ just infra-up # provision the EC2 instance
476
+ ```
477
+
478
+ **3. Deploy to EC2 (SSH into the instance, then):**
479
+
480
+ ```bash
481
+ git clone https://github.com/client/data-platform /srv/app
482
+ cd /srv/app
483
+ cp .env.example .env # fill in production values
484
+ just deploy-prod # docker compose -f ... up -d
485
+ ```
486
+
487
+ **4. CI/CD (automatic after push):**
488
+
489
+ Pushing to `development` or `main` triggers the corresponding GitHub Actions workflow. See the [CI/CD Workflow Design](#cicd-workflow-design) section below for the full two-path logic.
490
+
491
+ ---
492
+
493
+ ## CI/CD Workflow Design
494
+
495
+ The generated CI/CD workflow (`.github/workflows/deploy-{env}.yaml`) implements a **two-path** logic inspired by the Superset production setup. The key insight: infrastructure changes and application changes require completely different responses.
496
+
497
+ ### The Two Paths
498
+
499
+ ```
500
+ Push to branch
501
+
502
+
503
+ Detect changes
504
+ (dorny/paths-filter)
505
+
506
+ ├─── infrastructure/** changed?
507
+ │ │
508
+ │ ├─ Pull Request → pulumi preview (validate, no apply)
509
+ │ └─ Push → pulumi up --yes (apply infra changes)
510
+
511
+ └─── docker-compose / blueprint changed?
512
+ AND infrastructure NOT changed?
513
+
514
+ └─ Push → SSM: git pull + just deploy-prod
515
+ (redeploy app on the live EC2 instance)
516
+ ```
517
+
518
+ **Why skip deploy when infra also changed?** The `pulumi up` step re-provisions the EC2 instance itself, which already pulls the latest code via its user-data script. Running the app deploy on top of that would be redundant and potentially racy.
519
+
520
+ ### Job Summary
521
+
522
+ | Job | Trigger | What it does |
523
+ |---|---|---|
524
+ | `pulumi-preview` | PR, `infrastructure/**` changed | Runs `pulumi preview` — shows what *would* change, no side effects |
525
+ | `pulumi-apply` | Push, `infrastructure/**` changed | Runs `pulumi up --yes` — applies infra changes |
526
+ | `deploy-app` | Push, app files changed, infra NOT changed | AWS SSM command: `git pull && just deploy-prod` on live EC2 |
527
+
528
+ ### Required Secrets
529
+
530
+ Set these via `dwe create-service --secrets '{...}'` or manually in GitHub repository settings:
531
+
532
+ | Secret | Description |
533
+ |---|---|
534
+ | `AWS_ACCESS_KEY_ID` | AWS credentials for Pulumi and SSM |
535
+ | `AWS_SECRET_ACCESS_KEY` | AWS credentials |
536
+ | `PULUMI_ACCESS_TOKEN` | Pulumi Cloud token |
537
+ | `PULUMI_CONFIG_PASSPHRASE` | Pulumi stack encryption passphrase |
538
+ | `PULUMI_STACK` | Pulumi stack reference, e.g. `myorg/myproject/development` |
539
+ | `EC2_INSTANCE_ID` | Instance ID from `pulumi stack output instance_id`, e.g. `i-0abc1234` |
540
+
541
+ ### SSM Prerequisites
542
+
543
+ The `deploy-app` job uses **AWS Systems Manager (SSM)** instead of SSH — no port 22, no SSH key stored as a secret.
544
+
545
+ To enable SSM on the EC2 instance:
546
+
547
+ **1. IAM instance profile** — attach a role with these policies to the EC2:
548
+ ```json
549
+ {
550
+ "Effect": "Allow",
551
+ "Action": [
552
+ "ssm:UpdateInstanceInformation",
553
+ "ssmmessages:CreateControlChannel",
554
+ "ssmmessages:OpenControlChannel",
555
+ "ec2messages:GetMessages",
556
+ "ec2messages:SendReply"
557
+ ],
558
+ "Resource": "*"
559
+ }
560
+ ```
561
+
562
+ Or simply attach the AWS managed policy `AmazonSSMManagedInstanceCore`.
563
+
564
+ **2. SSM agent** — Amazon Linux 2023 ships with it pre-installed. The `blueprint/instance-setup.sh` bootstrap script ensures it's running:
565
+ ```bash
566
+ systemctl enable amazon-ssm-agent
567
+ systemctl start amazon-ssm-agent
568
+ ```
569
+
570
+ **3. Store the instance ID** — after running `just infra-up`, get the instance ID and store it as a secret:
571
+ ```bash
572
+ cd infrastructure && pulumi stack output instance_id
573
+ # → i-0abc1234567890def
574
+ # Add this to GitHub repository secrets as EC2_INSTANCE_ID
575
+ ```
576
+
577
+ ### Example: What Happens on a Typical Push
578
+
579
+ **Scenario 1 — you edited `blueprint/html/index.html`:**
580
+
581
+ ```
582
+ Push to development branch
583
+
584
+ detect-changes: infrastructure=false, app=true
585
+
586
+ deploy-app runs:
587
+ aws ssm send-command "git pull && just deploy-prod"
588
+ polls every 10s until success
589
+ prints stdout from EC2 instance
590
+
591
+ New HTML is live ~30 seconds after push
592
+ ```
593
+
594
+ **Scenario 2 — you changed `infrastructure/__main__.py.jinja` (e.g. bigger instance type):**
595
+
596
+ ```
597
+ Push to development branch
598
+
599
+ detect-changes: infrastructure=true, app=false
600
+
601
+ pulumi-apply runs:
602
+ pulumi up --yes
603
+ Pulumi modifies the EC2 instance type in-place (or replaces it)
604
+
605
+ Infrastructure updated. New instance pulls latest code via user-data.
606
+ ```
607
+
608
+ **Scenario 3 — you opened a PR with Pulumi changes:**
609
+
610
+ ```
611
+ Pull Request to development
612
+
613
+ detect-changes: infrastructure=true
614
+
615
+ pulumi-preview runs:
616
+ pulumi preview
617
+ Output shown in CI logs — no changes applied
618
+
619
+ Reviewer can see exactly what Pulumi will do before merging.
620
+ ```
621
+
622
+ ### Adapting for Other Platforms
623
+
624
+ The same two-path logic works for GitLab CI. The superset's `.gitlab-ci.yml` uses:
625
+
626
+ ```yaml
627
+ # Skip deploy if terraform changed
628
+ - if: $CI_COMMIT_BRANCH == "main"
629
+ changes:
630
+ - terraform_scalling/**/*
631
+ when: never
632
+ # Only deploy if docker/compose changed
633
+ - if: $CI_COMMIT_BRANCH == "main"
634
+ changes:
635
+ - docker/**/*
636
+ - docker-compose.yml
637
+ ```
638
+
639
+ For your adapter's GitLab template, mirror this pattern with `pulumi` instead of `terraform` and `infrastructure/**` instead of `terraform_scalling/**`.
640
+
641
+ ---
642
+
643
+ ## Adding a New Environment Later
644
+
645
+ Environments are set up at `create-service` time. To add one later:
646
+
647
+ ```bash
648
+ # Create the branch
649
+ git checkout initial-commit
650
+ git checkout -b staging
651
+ git push origin staging
652
+
653
+ # Generate the workflow file
654
+ cp .github/workflows/deploy-development.yaml .github/workflows/deploy-staging.yaml
655
+ # Edit deploy-staging.yaml: change all occurrences of "development" to "staging"
656
+ git add .github/workflows/deploy-staging.yaml
657
+ git commit -m "chore: add staging environment"
658
+ git push
659
+ ```
660
+
661
+ ---
662
+
663
+ ## Releasing to PyPI
664
+
665
+ Two workflows handle the full release lifecycle:
666
+
667
+ ```
668
+ bump version in pyproject.toml → merge to main
669
+
670
+
671
+ tag-version.yml triggers on: push to main, pyproject.toml changed
672
+ reads Poetry version creates git tag vX.Y.Z automatically
673
+
674
+
675
+ (go to GitHub → Releases → Draft a new release → publish it)
676
+
677
+
678
+ pypi-publish.yml triggers on: release published
679
+ poetry build + publish pushes to PyPI via PYPI_TOKEN
680
+ ```
681
+
682
+ ### One-time setup
683
+
684
+ Add `PYPI_TOKEN` to the repository secrets (`Settings → Secrets → Actions`):
685
+
686
+ 1. Go to **https://pypi.org/manage/account/token/** and create an API token scoped to `dwe-core`
687
+ 2. In GitHub: `Settings → Secrets and variables → Actions → New repository secret`
688
+ - Name: `PYPI_TOKEN`
689
+ - Value: the token from PyPI (starts with `pypi-`)
690
+
691
+ ### Release flow
692
+
693
+ **Step 1 — bump the version and merge to `main`:**
694
+
695
+ ```bash
696
+ poetry version patch # 1.0.0 → 1.0.1
697
+ poetry version minor # 1.0.0 → 1.1.0
698
+ poetry version major # 1.0.0 → 2.0.0
699
+ poetry version prerelease # 1.0.0 → 1.0.1a1
700
+ poetry version 1.2.0 # set explicit version
701
+
702
+ git add pyproject.toml
703
+ git commit -m "chore: bump version to $(poetry version -s)"
704
+ git push origin main
705
+ ```
706
+
707
+ `tag-version.yml` fires on the push, reads the version from `pyproject.toml`, and pushes tag `vX.Y.Z`. No manual tagging needed, and it only runs on `main`.
708
+
709
+ **Step 2 — publish the GitHub Release:**
710
+
711
+ Go to `github.com/<org>/dwe-core/releases`, click **Draft a new release**, select the tag just created, and click **Publish release**.
712
+
713
+ `pypi-publish.yml` fires on the publish event: runs `poetry install`, `poetry build`, then `poetry publish -u __token__ -p $PYPI_TOKEN`.
714
+
715
+ ---
716
+
717
+ ## Technical Stack
718
+
719
+ | Concern | Library |
720
+ |---|---|
721
+ | CLI framework | [Typer](https://typer.tiangolo.com/) |
722
+ | Template engine | [Copier](https://copier.readthedocs.io/) |
723
+ | Git operations | [GitPython](https://gitpython.readthedocs.io/) |
724
+ | GitHub secrets | [PyGithub](https://pygithub.readthedocs.io/) |
725
+ | GitLab variables | [python-gitlab](https://python-gitlab.readthedocs.io/) |
726
+ | Runtime templating | [Jinja2](https://jinja.palletsprojects.com/) (for CI templates) |
727
+ | Infrastructure | [Pulumi](https://www.pulumi.com/) |
728
+ | Task runner | [Just](https://just.systems/) |
729
+