Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/ci-cd/azure-devops.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ Each stage:
3. **Loads per-environment variables** — Each stage uses its own variable group (`apim-dev`, `apim-prod`)
4. **Authenticates per-environment** — Uses environment-specific service connections (`AZURE_SERVICE_CONNECTION_DEV`, `AZURE_SERVICE_CONNECTION_PROD`)
5. **Substitutes tokens** — Replaces `{#[TOKEN_NAME]#}` placeholders in `configuration.<env>.yaml` with secret variable values before publishing
6. **Applies overrides** — Passes `--overrides configuration.{env}.yaml` to apply [environment-specific overrides](../guides/environment-overrides.md)
6. **Runs a dry-run validation** — Executes `apiops publish --dry-run` to verify the publish would succeed. If this step fails, the pipeline halts and the real publish is never attempted, preventing partial failures from leaving APIM in an inconsistent state.
7. **Applies overrides** — Passes `--overrides configuration.{env}.yaml` to apply [environment-specific overrides](../guides/environment-overrides.md)

### Publish Pipeline Walkthrough

Expand Down
3 changes: 2 additions & 1 deletion docs/ci-cd/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ The workflow runs automatically when changes are pushed to `main` in these paths
2. **Checks out the repository** with `fetch-depth: 2` (needed for git diff).
3. **Authenticates with Azure** using OIDC federated credentials.
4. **Substitutes tokens** — replaces `{#[TOKEN_NAME]#}` placeholders in `configuration.<env>.yaml` with pipeline secret values.
5. **Runs `apiops publish`** in one of two modes:
5. **Runs a dry-run validation** — executes `apiops publish --dry-run` to verify the publish would succeed. If this fails, the workflow halts and the real publish is never attempted. This prevents partial failures from leaving APIM in an inconsistent state.
6. **Runs `apiops publish`** in one of two modes:
- **Incremental** (default): uses `--commit-id` to publish only changed files.
- **Full**: publishes all artifacts in the repository (useful for recovery or initial setup).

Expand Down
27 changes: 27 additions & 0 deletions docs/guides/dry-run-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,33 @@ This lets reviewers see _"this PR will create 2 APIs and update 1 backend"_ dire

---

## Built-in Pipeline Validation Gate

The publish pipelines generated by `apiops init` include an **automatic dry-run validation step** before every publish. This prevents partial failures from leaving your APIM instance in an inconsistent state.

### How it works

1. The pipeline runs `apiops publish --dry-run` with the same arguments as the real publish
2. If the dry-run exits with a non-zero code (connectivity issues, invalid resources, permission errors), the pipeline **halts immediately**
3. The real publish step only executes if the dry-run succeeds

This means APIM is never modified unless the full operation is validated first.

### What triggers a dry-run failure?

| Scenario | Result |
|----------|--------|
| Resource group or APIM service doesn't exist | Pre-flight validation fails |
| Invalid credentials or expired token | Authentication error |
| Malformed artifact files | Parse error during resource discovery |
| Network connectivity issues | API call timeout/failure |

### Opting out

If you want to skip the dry-run gate (e.g., for faster iteration in dev), remove the "Dry-run validation" step from your generated pipeline file. The publish step works independently.

---

## Related

- [apiops publish](../commands/publish.md) — Full command reference
Expand Down
91 changes: 62 additions & 29 deletions src/templates/azure-devops/publish-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string {
const defaultEnvironment = config.environments[0] ?? 'dev';
const envValues = config.environments.map((env) => ` - '${env}'`).join('\n');

const stages = config.environments.map((env) => {
const envUpper = env.toUpperCase();

return `- stage: Publish_${env}
displayName: 'Publish to ${env}'
condition: eq('\${{ parameters.ENVIRONMENT }}', '${env}')
// A single parameterized stage drives all environments. The ENVIRONMENT
// parameter is resolved at template (compile) time, so it can select the
// variable group, deployment environment, config file, and env-suffixed
// variable names (via upper()) without duplicating the stage per environment.
const stage = `- stage: Publish
displayName: 'Publish to \${{ parameters.ENVIRONMENT }}'
variables:
- group: apim-${env}
- group: apim-\${{ parameters.ENVIRONMENT }}
jobs:
- deployment: Deploy
displayName: 'Deploy to ${env}'
environment: ${env}
displayName: 'Deploy to \${{ parameters.ENVIRONMENT }}'
environment: \${{ parameters.ENVIRONMENT }}
pool:
vmImage: 'ubuntu-latest'
strategy:
Expand Down Expand Up @@ -54,54 +54,87 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string {
displayName: 'Install dependencies'

- task: replacetokens@6
displayName: 'Substitute tokens in configuration.${env}.yaml'
displayName: 'Substitute tokens in configuration.\${{ parameters.ENVIRONMENT }}.yaml'
inputs:
sources: 'configuration.${env}.yaml'
sources: 'configuration.\${{ parameters.ENVIRONMENT }}.yaml'
tokenPattern: 'custom'
tokenPrefix: '{#['
tokenSuffix: ']#}'
missingVarAction: 'keep'
missingVarLog: 'error'

- script: |
if grep -q '{#\\[' configuration.${env}.yaml; then
echo "Unresolved tokens remain in configuration.${env}.yaml"
grep -o '{#\\[[^]]*\\]#}' configuration.${env}.yaml | sort -u
if grep -q '{#\\[' configuration.\${{ parameters.ENVIRONMENT }}.yaml; then
echo "Unresolved tokens remain in configuration.\${{ parameters.ENVIRONMENT }}.yaml"
grep -o '{#\\[[^]]*\\]#}' configuration.\${{ parameters.ENVIRONMENT }}.yaml | sort -u
exit 1
fi
displayName: 'Validate token substitution (${env})'
displayName: 'Validate token substitution'

- task: AzureCLI@2
displayName: 'Dry-run validation (incremental)'
condition: and(succeeded(), ne('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo'))
inputs:
azureSubscription: 'AZURE_SERVICE_CONNECTION_\${{ upper(parameters.ENVIRONMENT) }}'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
npx apiops publish \\
--resource-group $(APIM_RESOURCE_GROUP_\${{ upper(parameters.ENVIRONMENT) }}) \\
--service-name $(APIM_SERVICE_NAME_\${{ upper(parameters.ENVIRONMENT) }}) \\
--source ${config.artifactDir} \\
--overrides configuration.\${{ parameters.ENVIRONMENT }}.yaml \\
--commit-id $(Build.SourceVersion) \\
--subscription-id $(AZURE_SUBSCRIPTION_ID) \\
--dry-run

- task: AzureCLI@2
displayName: 'Dry-run validation (all artifacts)'
condition: and(succeeded(), eq('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo'))
inputs:
azureSubscription: 'AZURE_SERVICE_CONNECTION_\${{ upper(parameters.ENVIRONMENT) }}'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
npx apiops publish \\
--resource-group $(APIM_RESOURCE_GROUP_\${{ upper(parameters.ENVIRONMENT) }}) \\
--service-name $(APIM_SERVICE_NAME_\${{ upper(parameters.ENVIRONMENT) }}) \\
--source ${config.artifactDir} \\
--overrides configuration.\${{ parameters.ENVIRONMENT }}.yaml \\
--subscription-id $(AZURE_SUBSCRIPTION_ID) \\
--dry-run

- task: AzureCLI@2
displayName: 'Publish to ${env} (incremental - last commit only)'
condition: ne('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')
displayName: 'Publish (incremental - last commit only)'
condition: and(succeeded(), ne('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo'))
inputs:
azureSubscription: 'AZURE_SERVICE_CONNECTION_${envUpper}'
azureSubscription: 'AZURE_SERVICE_CONNECTION_\${{ upper(parameters.ENVIRONMENT) }}'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
npx apiops publish \\
--resource-group $(APIM_RESOURCE_GROUP_${envUpper}) \\
--service-name $(APIM_SERVICE_NAME_${envUpper}) \\
--resource-group $(APIM_RESOURCE_GROUP_\${{ upper(parameters.ENVIRONMENT) }}) \\
--service-name $(APIM_SERVICE_NAME_\${{ upper(parameters.ENVIRONMENT) }}) \\
--source ${config.artifactDir} \\
--overrides configuration.${env}.yaml \\
--overrides configuration.\${{ parameters.ENVIRONMENT }}.yaml \\
--commit-id $(Build.SourceVersion) \\
--subscription-id $(AZURE_SUBSCRIPTION_ID)

- task: AzureCLI@2
displayName: 'Publish to ${env} (all artifacts)'
condition: eq('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')
displayName: 'Publish (all artifacts)'
condition: and(succeeded(), eq('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo'))
inputs:
azureSubscription: 'AZURE_SERVICE_CONNECTION_${envUpper}'
azureSubscription: 'AZURE_SERVICE_CONNECTION_\${{ upper(parameters.ENVIRONMENT) }}'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
npx apiops publish \\
--resource-group $(APIM_RESOURCE_GROUP_${envUpper}) \\
--service-name $(APIM_SERVICE_NAME_${envUpper}) \\
--resource-group $(APIM_RESOURCE_GROUP_\${{ upper(parameters.ENVIRONMENT) }}) \\
--service-name $(APIM_SERVICE_NAME_\${{ upper(parameters.ENVIRONMENT) }}) \\
--source ${config.artifactDir} \\
--overrides configuration.${env}.yaml \\
--overrides configuration.\${{ parameters.ENVIRONMENT }}.yaml \\
--subscription-id $(AZURE_SUBSCRIPTION_ID)
`;
}).join('\n');

return `# Azure DevOps Pipeline: Run APIM Publisher

Expand Down Expand Up @@ -132,6 +165,6 @@ parameters:
${envValues}

stages:
${stages}
${stage}
`;
}
8 changes: 4 additions & 4 deletions src/templates/github-actions/extract-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,18 @@ jobs:
echo " Service Name: \${{ env.APIM_SERVICE_NAME }}"

- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '22'

- name: Install dependencies
run: npm install

- name: Azure Login (Federated Credential)
uses: azure/login@v2
uses: azure/login@v3
with:
client-id: \${{ secrets.AZURE_CLIENT_ID }}
tenant-id: \${{ secrets.AZURE_TENANT_ID }}
Expand Down Expand Up @@ -110,7 +110,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Download artifacts
uses: actions/download-artifact@v4
Expand Down
Loading
Loading