Skip to content

Commit e280513

Browse files
CopilotEMaher
authored andcommitted
feat: add token/placeholder substitution to publish pipelines
Adds {#[TOKEN_NAME]#} substitution as a dedicated step in both GitHub Actions and Azure DevOps publish pipelines generated by `apiops init`, compatible with APIOps Toolkit configuration files. - GitHub Actions: cschleiden/replace-tokens@v1.3 with {#[ / ]#} - Azure DevOps: replacetokens@6 with {#[ / ]#} - Step runs before apiops publish so secrets are never committed - New docs/guides/token-substitution.md covering syntax, setup, migration from APIOps Toolkit, and common examples - Updated ci-cd/github-actions.md and ci-cd/azure-devops.md Closes #36
1 parent c76bbcb commit e280513

8 files changed

Lines changed: 382 additions & 2 deletions

File tree

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Requires Node.js 22 or later.
4242
- **Incremental publish** — Deploy only changed resources via git diff
4343
- **Dry-run mode** — Preview changes before applying them
4444
- **CI/CD scaffolding**`apiops init` generates GitHub Actions or Azure DevOps pipelines
45+
- **Token substitution** — Replace `{#[TOKEN_NAME]#}` placeholders in config files with pipeline secrets before publish
4546
- **Multiple auth methods** — Azure CLI, managed identity, workload identity (OIDC), service principal
4647

4748
## Documentation Structure
@@ -64,6 +65,7 @@ docs/
6465
│ ├── multi-environment.md — Dev / staging / prod promotion
6566
│ ├── multi-team-workflows.md — Selective extraction, CODEOWNERS
6667
│ ├── code-first-workflow.md — IDE → git → CI/CD → APIM
68+
│ ├── token-substitution.md — Pipeline token/placeholder substitution
6769
│ └── migration-from-v1.md — Migrate from Azure/apiops toolkit
6870
├── ci-cd/
6971
│ ├── github-actions.md — GitHub Actions integration

docs/ci-cd/azure-devops.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ Each stage:
144144
2. **Uses a deployment job** — Wraps the publish step in a `deployment` job targeting an [Azure DevOps environment](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/environments) for approval gates
145145
3. **Loads per-environment variables** — Each stage uses its own variable group (`apim-dev`, `apim-prod`)
146146
4. **Authenticates per-environment** — Uses environment-specific service connections (`AZURE_SERVICE_CONNECTION_DEV`, `AZURE_SERVICE_CONNECTION_PROD`)
147-
5. **Applies overrides** — Passes `--override configuration.{env}.yaml` to apply [environment-specific overrides](../guides/environment-overrides.md)
147+
5. **Substitutes tokens** — Replaces `{#[TOKEN_NAME]#}` placeholders in `configuration.<env>.yaml` with secret variable values before publishing
148+
6. **Applies overrides** — Passes `--override configuration.{env}.yaml` to apply [environment-specific overrides](../guides/environment-overrides.md)
148149

149150
### Publish Pipeline Walkthrough
150151

@@ -293,6 +294,27 @@ In your `package.json`, pin to a specific version:
293294
}
294295
```
295296

297+
### Using Token Substitution
298+
299+
To replace `{#[TOKEN_NAME]#}` placeholders in `configuration.<env>.yaml` with secret variable values:
300+
301+
1. **Install the [Replace Tokens extension](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens)** in your Azure DevOps organization (if not already installed).
302+
303+
2. **Add secret variables** to the `apim-<env>` variable group (e.g., `PROD_SECRET_VALUE`). Mark them as secret.
304+
305+
3. The generated substitution step runs automatically before publish:
306+
307+
```yaml
308+
- task: replacetokens@6
309+
displayName: 'Substitute tokens in configuration.prod.yaml'
310+
inputs:
311+
sources: 'configuration.prod.yaml'
312+
tokenPrefix: '{#['
313+
tokenSuffix: ']#}'
314+
```
315+
316+
See the [Token Substitution Guide](../guides/token-substitution.md) for full details, including migration from APIOps Toolkit.
317+
296318
---
297319

298320
## Troubleshooting
@@ -317,4 +339,5 @@ In your `package.json`, pin to a specific version:
317339
- [apiops publish](../commands/publish.md) — publish command reference
318340
- [Authentication Guide](../guides/authentication.md) — auth methods and RBAC
319341
- [Environment Overrides](../guides/environment-overrides.md) — per-environment configuration
342+
- [Token Substitution](../guides/token-substitution.md) — pipeline placeholder substitution with `{#[TOKEN_NAME]#}`
320343
- [Filtering Resources](../guides/filtering-resources.md) — extract specific APIs

docs/ci-cd/github-actions.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ The workflow runs automatically when changes are pushed to `main` in these paths
100100
1. **Resolves the commit ID** — captures `GITHUB_SHA` for incremental publish.
101101
2. **Checks out the repository** with `fetch-depth: 2` (needed for git diff).
102102
3. **Authenticates with Azure** using OIDC federated credentials.
103-
4. **Runs `apiops publish`** in one of two modes:
103+
4. **Substitutes tokens** — replaces `{#[TOKEN_NAME]#}` placeholders in `configuration.<env>.yaml` with pipeline secret values.
104+
5. **Runs `apiops publish`** in one of two modes:
104105
- **Incremental** (default): uses `--commit-id` to publish only changed files.
105106
- **Full**: publishes all artifacts in the repository (useful for recovery or initial setup).
106107

@@ -212,6 +213,24 @@ Use GitHub environment protection rules for production deployments:
212213

213214
The publish workflow will pause and wait for approval before deploying to prod.
214215

216+
### Using Token Substitution
217+
218+
To replace `{#[TOKEN_NAME]#}` placeholders in your configuration YAML with pipeline secrets, add the secret mappings to the `env:` block of the generated substitution step:
219+
220+
```yaml
221+
- name: Substitute tokens in configuration.prod.yaml
222+
uses: cschleiden/replace-tokens@v1.3
223+
with:
224+
tokenPrefix: '{#['
225+
tokenSuffix: ']#}'
226+
files: '["configuration.prod.yaml"]'
227+
env:
228+
MY_SECRET: ${{ secrets.MY_SECRET }}
229+
BACKEND_URL: ${{ secrets.BACKEND_URL }}
230+
```
231+
232+
See the [Token Substitution Guide](../guides/token-substitution.md) for full details, including migration from APIOps Toolkit.
233+
215234
### Adding Environment Overrides
216235

217236
To use [environment-specific overrides](../guides/environment-overrides.md), add the `--overrides` flag to the publish step in the workflow:
@@ -271,4 +290,5 @@ For authentication issues, see the [Authentication Guide](../guides/authenticati
271290

272291
- [Authentication Guide](../guides/authentication.md) — all auth methods and RBAC roles
273292
- [Environment Overrides](../guides/environment-overrides.md) — per-environment configuration
293+
- [Token Substitution](../guides/token-substitution.md) — pipeline placeholder substitution with `{#[TOKEN_NAME]#}`
274294
- [Scenarios and Workflows](../guides/scenarios-and-workflows.md) — portal-first vs. code-first patterns

docs/guides/token-substitution.md

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# Token/Placeholder Substitution
2+
3+
Token substitution lets you store secrets and environment-specific values in your pipeline's secret store rather than in your configuration YAML files. The publish pipeline replaces `{#[TOKEN_NAME]#}` placeholders in `configuration.<env>.yaml` with the actual values before running `apiops publish`.
4+
5+
This feature is compatible with APIOps Toolkit configuration files — users migrating from APIOps Toolkit can use their existing configuration files without modification.
6+
7+
---
8+
9+
## How It Works
10+
11+
1. **Define placeholders** in your `configuration.<env>.yaml` using the `{#[TOKEN_NAME]#}` syntax:
12+
13+
```yaml
14+
namedValues:
15+
- name: my-api-secret
16+
properties:
17+
displayName: my-api-secret
18+
secret: true
19+
value: "{#[PROD_SECRET_VALUE]#}"
20+
- name: backend-url
21+
properties:
22+
displayName: backend-url
23+
value: "{#[BACKEND_API_URL]#}"
24+
```
25+
26+
2. **Store actual values** in your pipeline's secret store (GitHub Actions Secrets or Azure DevOps variable groups / Key Vault).
27+
28+
3. **Token substitution runs automatically** as a pipeline step before `apiops publish`. Placeholders are replaced with the actual values in memory — the files on disk are modified only within the pipeline run and the secrets are never committed to the repository.
29+
30+
The substitution follows this pattern:
31+
32+
```
33+
{#[TOKEN_NAME]#} → value of environment variable TOKEN_NAME
34+
```
35+
36+
---
37+
38+
## GitHub Actions Setup
39+
40+
### Generated Step
41+
42+
`apiops init` generates a substitution step in each environment's publish job:
43+
44+
```yaml
45+
- name: Substitute tokens in configuration.prod.yaml
46+
uses: cschleiden/replace-tokens@v1.3
47+
with:
48+
tokenPrefix: '{#['
49+
tokenSuffix: ']#}'
50+
files: '["configuration.prod.yaml"]'
51+
env:
52+
# Map pipeline secrets/variables to environment variables so that
53+
# {#[TOKEN_NAME]#} placeholders in configuration.prod.yaml are replaced
54+
# with their actual values before the publish step runs. Example:
55+
# MY_SECRET: ${{ secrets.MY_SECRET }}
56+
```
57+
58+
### Mapping Secrets to Tokens
59+
60+
To substitute a token, add the corresponding secret to your GitHub environment and map it in the `env` block:
61+
62+
```yaml
63+
- name: Substitute tokens in configuration.prod.yaml
64+
uses: cschleiden/replace-tokens@v1.3
65+
with:
66+
tokenPrefix: '{#['
67+
tokenSuffix: ']#}'
68+
files: '["configuration.prod.yaml"]'
69+
env:
70+
PROD_SECRET_VALUE: ${{ secrets.PROD_SECRET_VALUE }}
71+
BACKEND_API_URL: ${{ secrets.BACKEND_API_URL }}
72+
```
73+
74+
> **Important:** The environment variable name must exactly match the token name inside `{#[...]#}`. Token names are case-sensitive.
75+
76+
### Step-by-Step for GitHub Actions
77+
78+
1. **Add secrets to your GitHub environment:**
79+
- Go to **Settings → Environments → prod → Add secret**
80+
- Add a secret for each token (e.g., `PROD_SECRET_VALUE`)
81+
82+
2. **Map secrets to env vars** in the substitution step's `env:` block as shown above.
83+
84+
3. **Define placeholders** in `configuration.prod.yaml` using the matching token names.
85+
86+
4. The substitution step runs during the publish job and replaces the placeholders before `apiops publish` is called.
87+
88+
### Example
89+
90+
`configuration.prod.yaml`:
91+
```yaml
92+
namedValues:
93+
- name: payment-api-key
94+
properties:
95+
displayName: payment-api-key
96+
secret: true
97+
value: "{#[PAYMENT_API_KEY]#}"
98+
```
99+
100+
GitHub environment secret: `PAYMENT_API_KEY = sk-live-abc123...`
101+
102+
Workflow `env:` mapping:
103+
```yaml
104+
env:
105+
PAYMENT_API_KEY: ${{ secrets.PAYMENT_API_KEY }}
106+
```
107+
108+
Result: the placeholder `{#[PAYMENT_API_KEY]#}` is replaced with `sk-live-abc123...` before publish.
109+
110+
---
111+
112+
## Azure DevOps Setup
113+
114+
### Generated Step
115+
116+
`apiops init` generates a substitution step in each environment's deployment job using the [Replace Tokens](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens) extension:
117+
118+
```yaml
119+
- task: replacetokens@6
120+
displayName: 'Substitute tokens in configuration.prod.yaml'
121+
inputs:
122+
sources: 'configuration.prod.yaml'
123+
tokenPrefix: '{#['
124+
tokenSuffix: ']#}'
125+
```
126+
127+
> **Prerequisite:** The [Replace Tokens extension](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens) (by Guillaume Rouchon / qetza) must be installed in your Azure DevOps organization from the Visual Studio Marketplace.
128+
129+
### Mapping Variables to Tokens
130+
131+
The `replacetokens` task automatically reads from pipeline variables (including those from variable groups). Add your secret values as variables in the `apim-<env>` variable group:
132+
133+
1. Go to **Pipelines → Library → apim-prod**
134+
2. Add a variable for each token (e.g., `PROD_SECRET_VALUE`)
135+
3. Check **"Keep this value secret"** to mark it as a secret variable
136+
137+
The substitution task will automatically replace `{#[PROD_SECRET_VALUE]#}` with the variable value from the group.
138+
139+
### Step-by-Step for Azure DevOps
140+
141+
1. **Install the Replace Tokens extension** in your Azure DevOps organization if not already present.
142+
143+
2. **Add secret variables to your variable group:**
144+
- Go to **Pipelines → Library → apim-prod**
145+
- Add each token as a secret variable (e.g., `PROD_SECRET_VALUE`)
146+
147+
3. **Define placeholders** in `configuration.prod.yaml` using matching variable names.
148+
149+
4. The substitution step runs automatically before the publish task.
150+
151+
### Example
152+
153+
`configuration.prod.yaml`:
154+
```yaml
155+
backends:
156+
- name: order-service
157+
properties:
158+
url: "{#[ORDER_SERVICE_URL]#}"
159+
description: Order processing backend
160+
```
161+
162+
Variable group `apim-prod`:
163+
| Variable | Value | Secret |
164+
|----------|-------|--------|
165+
| `ORDER_SERVICE_URL` | `https://orders.contoso.com/api` | ✓ |
166+
167+
Result: `{#[ORDER_SERVICE_URL]#}` is replaced with `https://orders.contoso.com/api` before publish.
168+
169+
---
170+
171+
## Migration from APIOps Toolkit
172+
173+
If you are migrating from APIOps Toolkit, your existing `configuration.<env>.yaml` files that use `{#[TOKEN_NAME]#}` placeholders work without modification. The same syntax is supported.
174+
175+
The only difference is where secrets are stored and mapped:
176+
177+
| | APIOps Toolkit | APIOps CLI |
178+
|---|---|---|
179+
| **Token syntax** | `{#[TOKEN_NAME]#}` | `{#[TOKEN_NAME]#}` (identical) |
180+
| **GitHub Actions** | `cschleiden/replace-tokens@v1.3` | `cschleiden/replace-tokens@v1.3` (same action) |
181+
| **Azure DevOps** | `qetza.replacetokens@6` | `replacetokens@6` (same extension) |
182+
| **Token prefix/suffix** | `{#[` / `]#}` | `{#[` / `]#}` (identical) |
183+
184+
### Migration Steps
185+
186+
1. Copy your existing `configuration.<env>.yaml` files to your new repository — no changes required.
187+
188+
2. Run `apiops init` to generate the pipeline scaffolding. The publish pipeline includes token substitution steps out of the box.
189+
190+
3. Re-create your secrets in the new pipeline:
191+
- **GitHub Actions**: Add each secret to the corresponding GitHub environment.
192+
- **Azure DevOps**: Add each secret to the corresponding `apim-<env>` variable group.
193+
194+
4. For GitHub Actions, add the `env:` mappings to the substitution step as described in [GitHub Actions Setup](#github-actions-setup).
195+
196+
---
197+
198+
## Common Use Cases
199+
200+
### Named Value Secrets
201+
202+
```yaml
203+
namedValues:
204+
- name: subscription-key
205+
properties:
206+
displayName: subscription-key
207+
secret: true
208+
value: "{#[SUBSCRIPTION_KEY]#}"
209+
```
210+
211+
### Backend URLs
212+
213+
```yaml
214+
backends:
215+
- name: inventory-api
216+
properties:
217+
url: "{#[INVENTORY_API_BASE_URL]#}"
218+
```
219+
220+
### Multiple Tokens in One File
221+
222+
```yaml
223+
namedValues:
224+
- name: db-connection
225+
properties:
226+
value: "{#[DB_CONNECTION_STRING]#}"
227+
- name: auth-secret
228+
properties:
229+
secret: true
230+
value: "{#[AUTH_SECRET_KEY]#}"
231+
backends:
232+
- name: payment-service
233+
properties:
234+
url: "{#[PAYMENT_SERVICE_URL]#}"
235+
```
236+
237+
### Using Different Values per Environment
238+
239+
Use separate `configuration.<env>.yaml` files (e.g., `configuration.dev.yaml`, `configuration.prod.yaml`) each referencing the same token names. The pipeline substitutes values from the environment-specific secret store, so `{#[API_KEY]#}` resolves to the dev key in dev and the prod key in prod.
240+
241+
---
242+
243+
## Security Notes
244+
245+
- Tokens are replaced **in the pipeline runner's memory** — they are never committed to the repository.
246+
- Use your pipeline platform's secret storage (GitHub Actions Secrets or Azure DevOps secret variables / Key Vault) — never store actual secret values in YAML files.
247+
- The replaced configuration YAML files are only visible within the single pipeline run and are discarded after the run completes.
248+
249+
---
250+
251+
## Related
252+
253+
- [Environment Overrides](environment-overrides.md) — merge environment-specific configuration before publishing
254+
- [GitHub Actions Integration](../ci-cd/github-actions.md) — full GitHub Actions pipeline guide
255+
- [Azure DevOps Integration](../ci-cd/azure-devops.md) — full Azure DevOps pipeline guide
256+
- [apiops init](../commands/init.md) — generate pipeline scaffolding

src/templates/azure-devops/publish-pipeline.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string {
4242
- script: npm ci
4343
displayName: 'Install dependencies'
4444
45+
- task: replacetokens@6
46+
displayName: 'Substitute tokens in configuration.${env}.yaml'
47+
inputs:
48+
sources: 'configuration.${env}.yaml'
49+
tokenPrefix: '{#['
50+
tokenSuffix: ']#}'
51+
4552
- task: AzureCLI@2
4653
displayName: 'Publish to ${env} (incremental - last commit only)'
4754
condition: ne('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')

src/templates/github-actions/publish-workflow.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ ${autoDeployComment}
4848
tenant-id: \${{ secrets.AZURE_TENANT_ID }}
4949
subscription-id: \${{ secrets.AZURE_SUBSCRIPTION_ID }}
5050
51+
- name: Substitute tokens in configuration.${env}.yaml
52+
uses: cschleiden/replace-tokens@v1.3
53+
with:
54+
tokenPrefix: '{#['
55+
tokenSuffix: ']#}'
56+
files: '["configuration.${env}.yaml"]'
57+
env:
58+
# Map pipeline secrets/variables to environment variables so that
59+
# {#[TOKEN_NAME]#} placeholders in configuration.${env}.yaml are replaced
60+
# with their actual values before the publish step runs. Example:
61+
# MY_SECRET: \${{ secrets.MY_SECRET }}
62+
5163
- name: Publish to ${env} (incremental - last commit only)
5264
if: \${{ github.event.inputs.COMMIT_ID_CHOICE != 'publish-all-artifacts-in-repo' }}
5365
run: |

0 commit comments

Comments
 (0)