From ebd7f9fb4a72576f67236ccf4daa40b4df169825 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 22 Jun 2026 16:00:28 -0700 Subject: [PATCH 1/7] feat: add dry-run validation step to publish pipeline templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish pipelines generated by piops init now include a dry-run validation gate before the actual publish. If the dry-run fails (e.g., connectivity issues, invalid resources, permission errors), the pipeline halts immediately — preventing partial failures from leaving APIM in an inconsistent state. Changes: - GitHub Actions workflow template: added dry-run validation steps - Azure DevOps pipeline template: added dry-run validation tasks - Unit tests for both templates - Updated CI/CD and dry-run documentation Closes #116 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ci-cd/azure-devops.md | 3 +- docs/ci-cd/github-actions.md | 3 +- docs/guides/dry-run-workflow.md | 27 +++++++ .../azure-devops/publish-pipeline.ts | 37 +++++++++- .../github-actions/publish-workflow.ts | 23 ++++++ .../azure-devops/publish-pipeline.test.ts | 72 ++++++++++++++++++- .../github-actions/publish-workflow.test.ts | 68 ++++++++++++++++++ 7 files changed, 227 insertions(+), 6 deletions(-) diff --git a/docs/ci-cd/azure-devops.md b/docs/ci-cd/azure-devops.md index 65369105..b1374146 100644 --- a/docs/ci-cd/azure-devops.md +++ b/docs/ci-cd/azure-devops.md @@ -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..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 diff --git a/docs/ci-cd/github-actions.md b/docs/ci-cd/github-actions.md index 02eb834c..810c6d22 100644 --- a/docs/ci-cd/github-actions.md +++ b/docs/ci-cd/github-actions.md @@ -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..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). diff --git a/docs/guides/dry-run-workflow.md b/docs/guides/dry-run-workflow.md index f6c28ed0..cc7e2b67 100644 --- a/docs/guides/dry-run-workflow.md +++ b/docs/guides/dry-run-workflow.md @@ -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 diff --git a/src/templates/azure-devops/publish-pipeline.ts b/src/templates/azure-devops/publish-pipeline.ts index 2f4a3349..9fb0111b 100644 --- a/src/templates/azure-devops/publish-pipeline.ts +++ b/src/templates/azure-devops/publish-pipeline.ts @@ -70,9 +70,42 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string { fi displayName: 'Validate token substitution (${env})' + - task: AzureCLI@2 + displayName: 'Dry-run validation (${env}, incremental)' + condition: and(succeeded(), ne('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')) + inputs: + azureSubscription: 'AZURE_SERVICE_CONNECTION_${envUpper}' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + npx apiops publish \\ + --resource-group $(APIM_RESOURCE_GROUP_${envUpper}) \\ + --service-name $(APIM_SERVICE_NAME_${envUpper}) \\ + --source ${config.artifactDir} \\ + --overrides configuration.${env}.yaml \\ + --commit-id $(Build.SourceVersion) \\ + --subscription-id $(AZURE_SUBSCRIPTION_ID) \\ + --dry-run + + - task: AzureCLI@2 + displayName: 'Dry-run validation (${env}, all artifacts)' + condition: and(succeeded(), eq('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')) + inputs: + azureSubscription: 'AZURE_SERVICE_CONNECTION_${envUpper}' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + npx apiops publish \\ + --resource-group $(APIM_RESOURCE_GROUP_${envUpper}) \\ + --service-name $(APIM_SERVICE_NAME_${envUpper}) \\ + --source ${config.artifactDir} \\ + --overrides configuration.${env}.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') + condition: and(succeeded(), ne('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')) inputs: azureSubscription: 'AZURE_SERVICE_CONNECTION_${envUpper}' scriptType: 'bash' @@ -88,7 +121,7 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string { - task: AzureCLI@2 displayName: 'Publish to ${env} (all artifacts)' - condition: eq('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo') + condition: and(succeeded(), eq('\${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')) inputs: azureSubscription: 'AZURE_SERVICE_CONNECTION_${envUpper}' scriptType: 'bash' diff --git a/src/templates/github-actions/publish-workflow.ts b/src/templates/github-actions/publish-workflow.ts index 8958c61d..2a810a3c 100644 --- a/src/templates/github-actions/publish-workflow.ts +++ b/src/templates/github-actions/publish-workflow.ts @@ -103,6 +103,29 @@ ${autoDeployComment} exit 1 fi + - name: Dry-run validation (${env}, incremental) + if: \${{ github.event.inputs.COMMIT_ID_CHOICE != 'publish-all-artifacts-in-repo' }} + run: | + npx apiops publish \\ + --subscription-id \${{ secrets.AZURE_SUBSCRIPTION_ID }} \\ + --resource-group \${{ secrets.APIM_RESOURCE_GROUP_${envUpper} }} \\ + --service-name \${{ secrets.APIM_SERVICE_NAME_${envUpper} }} \\ + --source ${config.artifactDir} \\ + --overrides configuration.${env}.yaml \\ + --commit-id \${{ needs.get-commit.outputs.commit_id }} \\ + --dry-run + + - name: Dry-run validation (${env}, all artifacts) + if: \${{ github.event.inputs.COMMIT_ID_CHOICE == 'publish-all-artifacts-in-repo' }} + run: | + npx apiops publish \\ + --subscription-id \${{ secrets.AZURE_SUBSCRIPTION_ID }} \\ + --resource-group \${{ secrets.APIM_RESOURCE_GROUP_${envUpper} }} \\ + --service-name \${{ secrets.APIM_SERVICE_NAME_${envUpper} }} \\ + --source ${config.artifactDir} \\ + --overrides configuration.${env}.yaml \\ + --dry-run + - name: Publish to ${env} (incremental - last commit only) if: \${{ github.event.inputs.COMMIT_ID_CHOICE != 'publish-all-artifacts-in-repo' }} run: | diff --git a/tests/unit/templates/azure-devops/publish-pipeline.test.ts b/tests/unit/templates/azure-devops/publish-pipeline.test.ts index 0d519486..e839339d 100644 --- a/tests/unit/templates/azure-devops/publish-pipeline.test.ts +++ b/tests/unit/templates/azure-devops/publish-pipeline.test.ts @@ -147,8 +147,8 @@ describe('azure-devops/publish-pipeline', () => { }); expect(pipeline).toContain('incremental - last commit only'); expect(pipeline).toContain('all artifacts'); - expect(pipeline).toContain("ne('${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')"); - expect(pipeline).toContain("eq('${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo')"); + expect(pipeline).toContain("and(succeeded(), ne('${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo'))"); + expect(pipeline).toContain("and(succeeded(), eq('${{ parameters.COMMIT_ID_CHOICE }}', 'publish-all-artifacts-in-repo'))"); }); it('should pass Build.SourceVersion as commit-id in incremental step', () => { @@ -255,5 +255,73 @@ describe('azure-devops/publish-pipeline', () => { expect(tokenIdx).toBeGreaterThan(0); expect(tokenIdx).toBeLessThan(publishIdx); }); + + it('should include dry-run validation steps before publish steps', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + expect(pipeline).toContain('Dry-run validation (dev, incremental)'); + expect(pipeline).toContain('Dry-run validation (dev, all artifacts)'); + }); + + it('should include --dry-run flag in dry-run validation steps', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const lines = pipeline.split('\n'); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const dryRunSection = lines.slice(dryRunIncrIdx, dryRunIncrIdx + 20).join('\n'); + expect(dryRunSection).toContain('--dry-run'); + }); + + it('should place dry-run validation before actual publish steps', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const dryRunIdx = pipeline.indexOf('Dry-run validation (dev, incremental)'); + const publishIdx = pipeline.indexOf("Publish to dev (incremental"); + expect(dryRunIdx).toBeGreaterThan(0); + expect(dryRunIdx).toBeLessThan(publishIdx); + }); + + it('should include dry-run validation for each environment', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev', 'prod'], + }); + expect(pipeline).toContain('Dry-run validation (dev, incremental)'); + expect(pipeline).toContain('Dry-run validation (dev, all artifacts)'); + expect(pipeline).toContain('Dry-run validation (prod, incremental)'); + expect(pipeline).toContain('Dry-run validation (prod, all artifacts)'); + }); + + it('should pass commit-id in incremental dry-run step', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const lines = pipeline.split('\n'); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const nextTaskIdx = lines.findIndex((l, i) => i > dryRunIncrIdx + 1 && l.includes("- task:")); + const dryRunSection = lines.slice(dryRunIncrIdx, nextTaskIdx).join('\n'); + expect(dryRunSection).toContain('--commit-id'); + expect(dryRunSection).toContain('--dry-run'); + }); + + it('should not pass commit-id in all-artifacts dry-run step', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const lines = pipeline.split('\n'); + const dryRunAllIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, all artifacts)')); + const nextTaskIdx = lines.findIndex((l, i) => i > dryRunAllIdx + 1 && l.includes("- task:")); + const dryRunSection = lines.slice(dryRunAllIdx, nextTaskIdx).join('\n'); + expect(dryRunSection).not.toContain('--commit-id'); + expect(dryRunSection).toContain('--dry-run'); + }); }); }); diff --git a/tests/unit/templates/github-actions/publish-workflow.test.ts b/tests/unit/templates/github-actions/publish-workflow.test.ts index 1af69a60..f1089088 100644 --- a/tests/unit/templates/github-actions/publish-workflow.test.ts +++ b/tests/unit/templates/github-actions/publish-workflow.test.ts @@ -265,5 +265,73 @@ describe('github-actions/publish-workflow', () => { expect(substituteIdx).toBeLessThan(validateSubstitutionIdx); expect(validateSubstitutionIdx).toBeLessThan(publishIdx); }); + + it('should include dry-run validation steps before publish steps', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + expect(workflow).toContain('Dry-run validation (dev, incremental)'); + expect(workflow).toContain('Dry-run validation (dev, all artifacts)'); + }); + + it('should include --dry-run flag in dry-run validation steps', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const lines = workflow.split('\n'); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const dryRunSection = lines.slice(dryRunIncrIdx, dryRunIncrIdx + 15).join('\n'); + expect(dryRunSection).toContain('--dry-run'); + }); + + it('should place dry-run validation before actual publish steps', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const dryRunIdx = workflow.indexOf('Dry-run validation (dev, incremental)'); + const publishIdx = workflow.indexOf('Publish to dev (incremental'); + expect(dryRunIdx).toBeGreaterThan(0); + expect(dryRunIdx).toBeLessThan(publishIdx); + }); + + it('should include dry-run validation for each environment', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev', 'prod'], + }); + expect(workflow).toContain('Dry-run validation (dev, incremental)'); + expect(workflow).toContain('Dry-run validation (dev, all artifacts)'); + expect(workflow).toContain('Dry-run validation (prod, incremental)'); + expect(workflow).toContain('Dry-run validation (prod, all artifacts)'); + }); + + it('should pass commit-id in incremental dry-run step', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const lines = workflow.split('\n'); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const nextStepIdx = lines.findIndex((l, i) => i > dryRunIncrIdx + 1 && l.includes('- name:')); + const dryRunSection = lines.slice(dryRunIncrIdx, nextStepIdx).join('\n'); + expect(dryRunSection).toContain('--commit-id'); + expect(dryRunSection).toContain('--dry-run'); + }); + + it('should not pass commit-id in all-artifacts dry-run step', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + const lines = workflow.split('\n'); + const dryRunAllIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, all artifacts)')); + const nextStepIdx = lines.findIndex((l, i) => i > dryRunAllIdx + 1 && l.includes('- name:')); + const dryRunSection = lines.slice(dryRunAllIdx, nextStepIdx).join('\n'); + expect(dryRunSection).not.toContain('--commit-id'); + expect(dryRunSection).toContain('--dry-run'); + }); }); }); From fea2ccc0b1b6e3f0ab88031651f66346f2a30bd8 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Thu, 25 Jun 2026 23:15:06 +0000 Subject: [PATCH 2/7] fix: fail publish pipeline on missing tokens via missingVarLog error --- src/templates/azure-devops/publish-pipeline.ts | 1 + tests/unit/templates/azure-devops/publish-pipeline.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/templates/azure-devops/publish-pipeline.ts b/src/templates/azure-devops/publish-pipeline.ts index 9fb0111b..2050c667 100644 --- a/src/templates/azure-devops/publish-pipeline.ts +++ b/src/templates/azure-devops/publish-pipeline.ts @@ -61,6 +61,7 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string { tokenPrefix: '{#[' tokenSuffix: ']#}' missingVarAction: 'keep' + missingVarLog: 'error' - script: | if grep -q '{#\\[' configuration.${env}.yaml; then diff --git a/tests/unit/templates/azure-devops/publish-pipeline.test.ts b/tests/unit/templates/azure-devops/publish-pipeline.test.ts index e839339d..8c358f63 100644 --- a/tests/unit/templates/azure-devops/publish-pipeline.test.ts +++ b/tests/unit/templates/azure-devops/publish-pipeline.test.ts @@ -223,6 +223,7 @@ describe('azure-devops/publish-pipeline', () => { expect(pipeline).toContain("tokenPrefix: '{#['"); expect(pipeline).toContain("tokenSuffix: ']#}'"); expect(pipeline).toContain("missingVarAction: 'keep'"); + expect(pipeline).toContain("missingVarLog: 'error'"); }); it('should validate unresolved tokens after substitution', () => { From fa94b13cebddcf6059ad22279ed938947b7bea76 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 26 Jun 2026 00:01:44 +0000 Subject: [PATCH 3/7] update azdo pipeline to publish one env at a time. --- .../azure-devops/publish-pipeline.ts | 73 +++++++++-------- .../azure-devops/publish-pipeline.test.ts | 78 ++++++++----------- 2 files changed, 70 insertions(+), 81 deletions(-) diff --git a/src/templates/azure-devops/publish-pipeline.ts b/src/templates/azure-devops/publish-pipeline.ts index 2050c667..e8b204f8 100644 --- a/src/templates/azure-devops/publish-pipeline.ts +++ b/src/templates/azure-devops/publish-pipeline.ts @@ -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: @@ -54,9 +54,9 @@ 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: ']#}' @@ -64,78 +64,77 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string { 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 (${env}, incremental)' + displayName: 'Dry-run validation (incremental)' 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) \\ --dry-run - task: AzureCLI@2 - displayName: 'Dry-run validation (${env}, all artifacts)' + displayName: 'Dry-run validation (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) \\ --dry-run - task: AzureCLI@2 - displayName: 'Publish to ${env} (incremental - last commit only)' + 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)' + 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 @@ -166,6 +165,6 @@ parameters: ${envValues} stages: -${stages} +${stage} `; } diff --git a/tests/unit/templates/azure-devops/publish-pipeline.test.ts b/tests/unit/templates/azure-devops/publish-pipeline.test.ts index 8c358f63..92641715 100644 --- a/tests/unit/templates/azure-devops/publish-pipeline.test.ts +++ b/tests/unit/templates/azure-devops/publish-pipeline.test.ts @@ -75,14 +75,15 @@ describe('azure-devops/publish-pipeline', () => { expect(pipeline).toContain("- 'prod'"); }); - it('should create stage for each environment', () => { + it('should create a single parameterized publish stage', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'staging', 'prod'], }); - expect(pipeline).toContain('stage: Publish_dev'); - expect(pipeline).toContain('stage: Publish_staging'); - expect(pipeline).toContain('stage: Publish_prod'); + expect(pipeline).toContain('stage: Publish'); + expect(pipeline).not.toContain('stage: Publish_dev'); + expect(pipeline).not.toContain('stage: Publish_staging'); + expect(pipeline).not.toContain('stage: Publish_prod'); }); it('should not chain stages with dependsOn', () => { @@ -90,26 +91,24 @@ describe('azure-devops/publish-pipeline', () => { artifactDir: './apim-artifacts', environments: ['dev', 'staging', 'prod'], }); - expect(pipeline).not.toContain('dependsOn: Publish_'); + expect(pipeline).not.toContain('dependsOn: Publish'); }); - it('should filter stages by ENVIRONMENT parameter', () => { + it('should drive the stage from the ENVIRONMENT parameter', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain("eq('${{ parameters.ENVIRONMENT }}', 'dev')"); - expect(pipeline).toContain("eq('${{ parameters.ENVIRONMENT }}', 'prod')"); - expect(pipeline).not.toContain("eq('${{ parameters.ENVIRONMENT }}', 'all')"); + expect(pipeline).toContain("displayName: 'Publish to ${{ parameters.ENVIRONMENT }}'"); + expect(pipeline).not.toContain("eq('${{ parameters.ENVIRONMENT }}', 'dev')"); }); - it('should use environment-specific variable groups', () => { + it('should select the variable group from the ENVIRONMENT parameter', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain('- group: apim-dev'); - expect(pipeline).toContain('- group: apim-prod'); + expect(pipeline).toContain('- group: apim-${{ parameters.ENVIRONMENT }}'); }); it('should use deployment job with environment', () => { @@ -118,7 +117,7 @@ describe('azure-devops/publish-pipeline', () => { environments: ['dev'], }); expect(pipeline).toContain('deployment: Deploy'); - expect(pipeline).toContain('environment: dev'); + expect(pipeline).toContain('environment: ${{ parameters.ENVIRONMENT }}'); }); it('should use runOnce deployment strategy', () => { @@ -165,7 +164,7 @@ describe('azure-devops/publish-pipeline', () => { environments: ['dev'], }); const lines = pipeline.split('\n'); - const allArtStart = lines.findIndex((l) => l.includes("Publish to dev (all artifacts)")); + const allArtStart = lines.findIndex((l) => l.includes("Publish (all artifacts)")); // Find the next step or stage boundary after the all-artifacts step const sectionEnd = lines.findIndex((l, i) => i > allArtStart + 1 && (l.includes('- task:') || l.includes('- stage:'))); const end = sectionEnd === -1 ? lines.length : sectionEnd; @@ -173,33 +172,29 @@ describe('azure-devops/publish-pipeline', () => { expect(allArtSection).not.toContain('--commit-id'); }); - it('should use environment-specific service connection', () => { + it('should select the service connection from the ENVIRONMENT parameter', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain("azureSubscription: 'AZURE_SERVICE_CONNECTION_DEV'"); - expect(pipeline).toContain("azureSubscription: 'AZURE_SERVICE_CONNECTION_PROD'"); + expect(pipeline).toContain("azureSubscription: 'AZURE_SERVICE_CONNECTION_${{ upper(parameters.ENVIRONMENT) }}'"); }); - it('should use environment-specific resource group and service name', () => { + it('should reference env-suffixed resource group and service name via parameter', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain('$(APIM_RESOURCE_GROUP_DEV)'); - expect(pipeline).toContain('$(APIM_SERVICE_NAME_DEV)'); - expect(pipeline).toContain('$(APIM_RESOURCE_GROUP_PROD)'); - expect(pipeline).toContain('$(APIM_SERVICE_NAME_PROD)'); + expect(pipeline).toContain('$(APIM_RESOURCE_GROUP_${{ upper(parameters.ENVIRONMENT) }})'); + expect(pipeline).toContain('$(APIM_SERVICE_NAME_${{ upper(parameters.ENVIRONMENT) }})'); }); - it('should use environment-specific override files', () => { + it('should reference the env-specific override file via parameter', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain('--overrides configuration.dev.yaml'); - expect(pipeline).toContain('--overrides configuration.prod.yaml'); + expect(pipeline).toContain('--overrides configuration.${{ parameters.ENVIRONMENT }}.yaml'); }); it('should use lockfile-aware dependency install', () => { @@ -231,19 +226,16 @@ describe('azure-devops/publish-pipeline', () => { artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain('Validate token substitution (dev)'); - expect(pipeline).toContain('Validate token substitution (prod)'); - expect(pipeline).toContain("grep -q '{#\\[' configuration.dev.yaml"); - expect(pipeline).toContain("grep -q '{#\\[' configuration.prod.yaml"); + expect(pipeline).toContain('Validate token substitution'); + expect(pipeline).toContain("grep -q '{#\\[' configuration.${{ parameters.ENVIRONMENT }}.yaml"); }); - it('should target environment-specific configuration file for token substitution', () => { + it('should target the parameterized configuration file for token substitution', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain("sources: 'configuration.dev.yaml'"); - expect(pipeline).toContain("sources: 'configuration.prod.yaml'"); + expect(pipeline).toContain("sources: 'configuration.${{ parameters.ENVIRONMENT }}.yaml'"); }); it('should place token substitution step before publish steps', () => { @@ -262,8 +254,8 @@ describe('azure-devops/publish-pipeline', () => { artifactDir: './apim-artifacts', environments: ['dev'], }); - expect(pipeline).toContain('Dry-run validation (dev, incremental)'); - expect(pipeline).toContain('Dry-run validation (dev, all artifacts)'); + expect(pipeline).toContain('Dry-run validation (incremental)'); + expect(pipeline).toContain('Dry-run validation (all artifacts)'); }); it('should include --dry-run flag in dry-run validation steps', () => { @@ -272,7 +264,7 @@ describe('azure-devops/publish-pipeline', () => { environments: ['dev'], }); const lines = pipeline.split('\n'); - const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (incremental)')); const dryRunSection = lines.slice(dryRunIncrIdx, dryRunIncrIdx + 20).join('\n'); expect(dryRunSection).toContain('--dry-run'); }); @@ -282,21 +274,19 @@ describe('azure-devops/publish-pipeline', () => { artifactDir: './apim-artifacts', environments: ['dev'], }); - const dryRunIdx = pipeline.indexOf('Dry-run validation (dev, incremental)'); - const publishIdx = pipeline.indexOf("Publish to dev (incremental"); + const dryRunIdx = pipeline.indexOf('Dry-run validation (incremental)'); + const publishIdx = pipeline.indexOf("Publish (incremental"); expect(dryRunIdx).toBeGreaterThan(0); expect(dryRunIdx).toBeLessThan(publishIdx); }); - it('should include dry-run validation for each environment', () => { + it('should include dry-run validation steps for the parameterized environment', () => { const pipeline = generatePublishPipeline({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(pipeline).toContain('Dry-run validation (dev, incremental)'); - expect(pipeline).toContain('Dry-run validation (dev, all artifacts)'); - expect(pipeline).toContain('Dry-run validation (prod, incremental)'); - expect(pipeline).toContain('Dry-run validation (prod, all artifacts)'); + expect(pipeline).toContain('Dry-run validation (incremental)'); + expect(pipeline).toContain('Dry-run validation (all artifacts)'); }); it('should pass commit-id in incremental dry-run step', () => { @@ -305,7 +295,7 @@ describe('azure-devops/publish-pipeline', () => { environments: ['dev'], }); const lines = pipeline.split('\n'); - const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (incremental)')); const nextTaskIdx = lines.findIndex((l, i) => i > dryRunIncrIdx + 1 && l.includes("- task:")); const dryRunSection = lines.slice(dryRunIncrIdx, nextTaskIdx).join('\n'); expect(dryRunSection).toContain('--commit-id'); @@ -318,7 +308,7 @@ describe('azure-devops/publish-pipeline', () => { environments: ['dev'], }); const lines = pipeline.split('\n'); - const dryRunAllIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, all artifacts)')); + const dryRunAllIdx = lines.findIndex((l) => l.includes('Dry-run validation (all artifacts)')); const nextTaskIdx = lines.findIndex((l, i) => i > dryRunAllIdx + 1 && l.includes("- task:")); const dryRunSection = lines.slice(dryRunAllIdx, nextTaskIdx).join('\n'); expect(dryRunSection).not.toContain('--commit-id'); From 389e37e546ce5b5fa106c4207d4fa4993a7ab8ac Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 26 Jun 2026 00:16:37 +0000 Subject: [PATCH 4/7] streaming publish pipeline/workflow --- .../github-actions/publish-workflow.ts | 170 +++++++++--------- .../github-actions/publish-workflow.test.ts | 87 ++++----- 2 files changed, 124 insertions(+), 133 deletions(-) diff --git a/src/templates/github-actions/publish-workflow.ts b/src/templates/github-actions/publish-workflow.ts index 2a810a3c..7172c4e3 100644 --- a/src/templates/github-actions/publish-workflow.ts +++ b/src/templates/github-actions/publish-workflow.ts @@ -2,7 +2,8 @@ // Licensed under the MIT license. /** * GitHub Actions publish workflow template - * Push-to-main trigger with commit ID choice, environment selection, and multi-env stages + * Push-to-main trigger with commit ID choice and a single parameterized publish + * job driven by a workflow-level environment variable (TARGET_ENV) */ export interface PublishWorkflowConfig { @@ -11,22 +12,58 @@ export interface PublishWorkflowConfig { } export function generatePublishWorkflow(config: PublishWorkflowConfig): string { + const defaultEnvironment = config.environments[0] ?? 'dev'; const envChoices = config.environments.map((env) => ` - ${env}`).join('\n'); - const envJobs = config.environments.map((env, idx) => { - const envUpper = env.toUpperCase(); - const autoDeployComment = idx === 0 - ? ` # To enable automatic deployment on push to main, uncomment the condition below: - # if: github.event.inputs.ENVIRONMENT == '${env}' || github.event_name == 'push'` - : ` # To enable automatic deployment on push to main, uncomment the condition below: - # if: github.event.inputs.ENVIRONMENT == '${env}' || github.event_name == 'push' - # And change needs to: needs: [get-commit, publish-${config.environments[idx - 1]}]`; - - return ` publish-${env}: -${autoDeployComment} - if: github.event.inputs.ENVIRONMENT == '${env}' + return `name: Run APIM Publisher + +on: + push: + branches: + - main + paths: + - '${config.artifactDir}/**' + - 'configuration.*.yaml' + workflow_dispatch: + inputs: + COMMIT_ID_CHOICE: + description: 'Choose "publish-all-artifacts-in-repo" only when you want to force republishing all artifacts (e.g. after build failure). Otherwise stick with the default behavior of "publish-artifacts-in-last-commit"' + required: true + type: choice + default: publish-artifacts-in-last-commit + options: + - publish-artifacts-in-last-commit + - publish-all-artifacts-in-repo + ENVIRONMENT: + description: 'Choose which environment to publish to' + required: true + type: choice + default: ${defaultEnvironment} + options: +${envChoices} + +permissions: + id-token: write + contents: read + +# A single workflow-level variable selects the target environment. On manual runs +# it comes from the ENVIRONMENT input; on push to main it defaults to '${defaultEnvironment}'. +env: + TARGET_ENV: \${{ github.event.inputs.ENVIRONMENT || '${defaultEnvironment}' }} + +jobs: + get-commit: runs-on: ubuntu-latest - environment: ${env} + outputs: + commit_id: \${{ steps.commit.outputs.commit_id }} + steps: + - name: Set the Commit Id + id: commit + run: echo "commit_id=\${GITHUB_SHA}" >> $GITHUB_OUTPUT + + publish: + runs-on: ubuntu-latest + environment: \${{ github.event.inputs.ENVIRONMENT || '${defaultEnvironment}' }} needs: get-commit steps: - name: Checkout repository @@ -42,6 +79,12 @@ ${autoDeployComment} - name: Install dependencies run: npm install + - name: Resolve target environment + id: env + run: | + echo "name=\${TARGET_ENV}" >> "$GITHUB_OUTPUT" + echo "upper=\$(echo "\${TARGET_ENV}" | tr '[:lower:]' '[:upper:]')" >> "$GITHUB_OUTPUT" + - name: Azure Login (Federated Credential) uses: azure/login@v2 with: @@ -49,15 +92,16 @@ ${autoDeployComment} tenant-id: \${{ secrets.AZURE_TENANT_ID }} subscription-id: \${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Validate token source values (${env}) + - name: Validate token source values env: AVAILABLE_SECRETS_JSON: \${{ toJSON(secrets) }} run: | missing=0 - tokens=$(grep -o '{#\\[[^]]*\\]#}' configuration.${env}.yaml | sed -E 's/^\\{#\\[([^]]+)\\]#\\}$/\\1/' | sort -u || true) + config_file="configuration.\${TARGET_ENV}.yaml" + tokens=$(grep -o '{#\\[[^]]*\\]#}' "$config_file" | sed -E 's/^\\{#\\[([^]]+)\\]#\\}$/\\1/' | sort -u || true) if [ -z "$tokens" ]; then - echo "No tokens found in configuration.${env}.yaml" + echo "No tokens found in $config_file" exit 0 fi @@ -86,111 +130,67 @@ ${autoDeployComment} exit 1 fi - - name: Substitute tokens in configuration.${env}.yaml + - name: Substitute tokens in configuration file uses: cschleiden/replace-tokens@v1.3 with: tokenPrefix: '{#[' tokenSuffix: ']#}' - files: '["configuration.${env}.yaml"]' + files: '["configuration.\${{ env.TARGET_ENV }}.yaml"]' # Token values are injected in the previous step based on token names. - # Ensure tokens in configuration.${env}.yaml match secret names exactly. + # Ensure tokens in the configuration file match secret names exactly. - - name: Validate token substitution (${env}) + - name: Validate token substitution run: | - if grep -q '{#\\[' configuration.${env}.yaml; then - echo "Unresolved tokens remain in configuration.${env}.yaml" - grep -o '{#\\[[^]]*\\]#}' configuration.${env}.yaml | sort -u + config_file="configuration.\${TARGET_ENV}.yaml" + if grep -q '{#\\[' "$config_file"; then + echo "Unresolved tokens remain in $config_file" + grep -o '{#\\[[^]]*\\]#}' "$config_file" | sort -u exit 1 fi - - name: Dry-run validation (${env}, incremental) + - name: Dry-run validation (incremental) if: \${{ github.event.inputs.COMMIT_ID_CHOICE != 'publish-all-artifacts-in-repo' }} run: | npx apiops publish \\ --subscription-id \${{ secrets.AZURE_SUBSCRIPTION_ID }} \\ - --resource-group \${{ secrets.APIM_RESOURCE_GROUP_${envUpper} }} \\ - --service-name \${{ secrets.APIM_SERVICE_NAME_${envUpper} }} \\ + --resource-group \${{ secrets[format('APIM_RESOURCE_GROUP_{0}', steps.env.outputs.upper)] }} \\ + --service-name \${{ secrets[format('APIM_SERVICE_NAME_{0}', steps.env.outputs.upper)] }} \\ --source ${config.artifactDir} \\ - --overrides configuration.${env}.yaml \\ + --overrides configuration.\${{ env.TARGET_ENV }}.yaml \\ --commit-id \${{ needs.get-commit.outputs.commit_id }} \\ --dry-run - - name: Dry-run validation (${env}, all artifacts) + - name: Dry-run validation (all artifacts) if: \${{ github.event.inputs.COMMIT_ID_CHOICE == 'publish-all-artifacts-in-repo' }} run: | npx apiops publish \\ --subscription-id \${{ secrets.AZURE_SUBSCRIPTION_ID }} \\ - --resource-group \${{ secrets.APIM_RESOURCE_GROUP_${envUpper} }} \\ - --service-name \${{ secrets.APIM_SERVICE_NAME_${envUpper} }} \\ + --resource-group \${{ secrets[format('APIM_RESOURCE_GROUP_{0}', steps.env.outputs.upper)] }} \\ + --service-name \${{ secrets[format('APIM_SERVICE_NAME_{0}', steps.env.outputs.upper)] }} \\ --source ${config.artifactDir} \\ - --overrides configuration.${env}.yaml \\ + --overrides configuration.\${{ env.TARGET_ENV }}.yaml \\ --dry-run - - name: Publish to ${env} (incremental - last commit only) + - name: Publish (incremental - last commit only) if: \${{ github.event.inputs.COMMIT_ID_CHOICE != 'publish-all-artifacts-in-repo' }} run: | npx apiops publish \\ --subscription-id \${{ secrets.AZURE_SUBSCRIPTION_ID }} \\ - --resource-group \${{ secrets.APIM_RESOURCE_GROUP_${envUpper} }} \\ - --service-name \${{ secrets.APIM_SERVICE_NAME_${envUpper} }} \\ + --resource-group \${{ secrets[format('APIM_RESOURCE_GROUP_{0}', steps.env.outputs.upper)] }} \\ + --service-name \${{ secrets[format('APIM_SERVICE_NAME_{0}', steps.env.outputs.upper)] }} \\ --source ${config.artifactDir} \\ - --overrides configuration.${env}.yaml \\ + --overrides configuration.\${{ env.TARGET_ENV }}.yaml \\ --commit-id \${{ needs.get-commit.outputs.commit_id }} - - name: Publish to ${env} (all artifacts) + - name: Publish (all artifacts) if: \${{ github.event.inputs.COMMIT_ID_CHOICE == 'publish-all-artifacts-in-repo' }} run: | npx apiops publish \\ --subscription-id \${{ secrets.AZURE_SUBSCRIPTION_ID }} \\ - --resource-group \${{ secrets.APIM_RESOURCE_GROUP_${envUpper} }} \\ - --service-name \${{ secrets.APIM_SERVICE_NAME_${envUpper} }} \\ + --resource-group \${{ secrets[format('APIM_RESOURCE_GROUP_{0}', steps.env.outputs.upper)] }} \\ + --service-name \${{ secrets[format('APIM_SERVICE_NAME_{0}', steps.env.outputs.upper)] }} \\ --source ${config.artifactDir} \\ - --overrides configuration.${env}.yaml -`; - }).join('\n'); - - return `name: Run APIM Publisher - -on: - push: - branches: - - main - paths: - - '${config.artifactDir}/**' - - 'configuration.*.yaml' - workflow_dispatch: - inputs: - COMMIT_ID_CHOICE: - description: 'Choose "publish-all-artifacts-in-repo" only when you want to force republishing all artifacts (e.g. after build failure). Otherwise stick with the default behavior of "publish-artifacts-in-last-commit"' - required: true - type: choice - default: publish-artifacts-in-last-commit - options: - - publish-artifacts-in-last-commit - - publish-all-artifacts-in-repo - ENVIRONMENT: - description: 'Choose which environment to publish to' - required: true - type: choice - default: ${config.environments[0]} - options: -${envChoices} - -permissions: - id-token: write - contents: read - -jobs: - get-commit: - runs-on: ubuntu-latest - outputs: - commit_id: \${{ steps.commit.outputs.commit_id }} - steps: - - name: Set the Commit Id - id: commit - run: echo "commit_id=\${GITHUB_SHA}" >> $GITHUB_OUTPUT - -${envJobs} + --overrides configuration.\${{ env.TARGET_ENV }}.yaml `; } diff --git a/tests/unit/templates/github-actions/publish-workflow.test.ts b/tests/unit/templates/github-actions/publish-workflow.test.ts index f1089088..363bffbd 100644 --- a/tests/unit/templates/github-actions/publish-workflow.test.ts +++ b/tests/unit/templates/github-actions/publish-workflow.test.ts @@ -75,40 +75,41 @@ describe('github-actions/publish-workflow', () => { expect(workflow).toContain('GITHUB_SHA'); }); - it('should create job for each environment', () => { + it('should create a single parameterized publish job', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'staging', 'prod'], }); - expect(workflow).toContain('publish-dev:'); - expect(workflow).toContain('publish-staging:'); - expect(workflow).toContain('publish-prod:'); + expect(workflow).toContain('publish:'); + expect(workflow).not.toContain('publish-dev:'); + expect(workflow).not.toContain('publish-staging:'); + expect(workflow).not.toContain('publish-prod:'); }); - it('should set environment for each job', () => { + it('should drive the job environment from the ENVIRONMENT input', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('environment: dev'); - expect(workflow).toContain('environment: prod'); + expect(workflow).toContain("environment: ${{ github.event.inputs.ENVIRONMENT || 'dev' }}"); + expect(workflow).not.toContain('environment: prod'); }); - it('should chain jobs with needs dependencies', () => { + it('should define a workflow-level TARGET_ENV variable defaulting to the first environment', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', - environments: ['dev', 'staging', 'prod'], + environments: ['dev', 'prod'], }); - expect(workflow).toContain('needs: [get-commit, publish-dev]'); - expect(workflow).toContain('needs: [get-commit, publish-staging]'); + expect(workflow).toContain("TARGET_ENV: ${{ github.event.inputs.ENVIRONMENT || 'dev' }}"); }); - it('should have first environment depend on get-commit only', () => { + it('should have the publish job depend on get-commit', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); expect(workflow).toContain('needs: get-commit'); + expect(workflow).not.toContain('needs: [get-commit, publish-dev]'); }); it('should have conditional steps for incremental vs all artifacts publish', () => { @@ -141,35 +142,31 @@ describe('github-actions/publish-workflow', () => { expect(allArtifactsSection).not.toContain('--commit-id'); }); - it('should filter jobs by ENVIRONMENT input', () => { + it('should select the target environment from the ENVIRONMENT input', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain("ENVIRONMENT == 'dev'"); - expect(workflow).toContain("ENVIRONMENT == 'prod'"); + expect(workflow).toContain("TARGET_ENV: ${{ github.event.inputs.ENVIRONMENT || 'dev' }}"); + expect(workflow).not.toContain("ENVIRONMENT == 'dev'"); }); - it('should use environment-specific secrets', () => { + it('should select env-suffixed secrets dynamically by environment', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('${{ secrets.APIM_RESOURCE_GROUP_DEV }}'); - expect(workflow).toContain('${{ secrets.APIM_SERVICE_NAME_DEV }}'); - expect(workflow).toContain('${{ secrets.APIM_RESOURCE_GROUP_PROD }}'); - expect(workflow).toContain('${{ secrets.APIM_SERVICE_NAME_PROD }}'); + expect(workflow).toContain("${{ secrets[format('APIM_RESOURCE_GROUP_{0}', steps.env.outputs.upper)] }}"); + expect(workflow).toContain("${{ secrets[format('APIM_SERVICE_NAME_{0}', steps.env.outputs.upper)] }}"); }); - it('should use environment-specific secrets for resource group and service name', () => { + it('should resolve an uppercase environment suffix for secret lookup', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('${{ secrets.APIM_RESOURCE_GROUP_DEV }}'); - expect(workflow).toContain('${{ secrets.APIM_SERVICE_NAME_DEV }}'); - expect(workflow).toContain('${{ secrets.APIM_RESOURCE_GROUP_PROD }}'); - expect(workflow).toContain('${{ secrets.APIM_SERVICE_NAME_PROD }}'); + expect(workflow).toContain('id: env'); + expect(workflow).toContain("tr '[:lower:]' '[:upper:]'"); }); it('should have id-token write permission for OIDC', () => { @@ -216,13 +213,12 @@ describe('github-actions/publish-workflow', () => { expect(workflow).toContain("tokenSuffix: ']#}'"); }); - it('should target environment-specific configuration file for token substitution', () => { + it('should target the parameterized configuration file for token substitution', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('files: \'["configuration.dev.yaml"]\''); - expect(workflow).toContain('files: \'["configuration.prod.yaml"]\''); + expect(workflow).toContain('files: \'["configuration.${{ env.TARGET_ENV }}.yaml"]\''); }); it('should place token substitution step before publish steps', () => { @@ -241,15 +237,12 @@ describe('github-actions/publish-workflow', () => { artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('Validate token source values (dev)'); - expect(workflow).toContain('Validate token source values (prod)'); + expect(workflow).toContain('Validate token source values'); expect(workflow).toContain('AVAILABLE_SECRETS_JSON: ${{ toJSON(secrets) }}'); expect(workflow).toContain("echo \"::error::Missing secret for token '$token'\""); expect(workflow).toContain("printf '%s=%s\\n' \"$token\" \"$value\" >> \"$GITHUB_ENV\""); - expect(workflow).toContain('Validate token substitution (dev)'); - expect(workflow).toContain('Validate token substitution (prod)'); - expect(workflow).toContain("grep -q '{#\\[' configuration.dev.yaml"); - expect(workflow).toContain("grep -q '{#\\[' configuration.prod.yaml"); + expect(workflow).toContain('Validate token substitution'); + expect(workflow).toContain("grep -q '{#\\[' \"$config_file\""); }); it('should place token validation step between substitution and publish', () => { @@ -257,9 +250,9 @@ describe('github-actions/publish-workflow', () => { artifactDir: './apim-artifacts', environments: ['dev'], }); - const validateSourcesIdx = workflow.indexOf('Validate token source values (dev)'); + const validateSourcesIdx = workflow.indexOf('Validate token source values'); const substituteIdx = workflow.indexOf('cschleiden/replace-tokens'); - const validateSubstitutionIdx = workflow.indexOf('Validate token substitution (dev)'); + const validateSubstitutionIdx = workflow.indexOf('Validate token substitution'); const publishIdx = workflow.indexOf('npx apiops publish'); expect(validateSourcesIdx).toBeLessThan(substituteIdx); expect(substituteIdx).toBeLessThan(validateSubstitutionIdx); @@ -271,8 +264,8 @@ describe('github-actions/publish-workflow', () => { artifactDir: './apim-artifacts', environments: ['dev'], }); - expect(workflow).toContain('Dry-run validation (dev, incremental)'); - expect(workflow).toContain('Dry-run validation (dev, all artifacts)'); + expect(workflow).toContain('Dry-run validation (incremental)'); + expect(workflow).toContain('Dry-run validation (all artifacts)'); }); it('should include --dry-run flag in dry-run validation steps', () => { @@ -281,7 +274,7 @@ describe('github-actions/publish-workflow', () => { environments: ['dev'], }); const lines = workflow.split('\n'); - const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (incremental)')); const dryRunSection = lines.slice(dryRunIncrIdx, dryRunIncrIdx + 15).join('\n'); expect(dryRunSection).toContain('--dry-run'); }); @@ -291,21 +284,19 @@ describe('github-actions/publish-workflow', () => { artifactDir: './apim-artifacts', environments: ['dev'], }); - const dryRunIdx = workflow.indexOf('Dry-run validation (dev, incremental)'); - const publishIdx = workflow.indexOf('Publish to dev (incremental'); + const dryRunIdx = workflow.indexOf('Dry-run validation (incremental)'); + const publishIdx = workflow.indexOf('Publish (incremental'); expect(dryRunIdx).toBeGreaterThan(0); expect(dryRunIdx).toBeLessThan(publishIdx); }); - it('should include dry-run validation for each environment', () => { + it('should include parameterized dry-run validation steps', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('Dry-run validation (dev, incremental)'); - expect(workflow).toContain('Dry-run validation (dev, all artifacts)'); - expect(workflow).toContain('Dry-run validation (prod, incremental)'); - expect(workflow).toContain('Dry-run validation (prod, all artifacts)'); + expect(workflow).toContain('Dry-run validation (incremental)'); + expect(workflow).toContain('Dry-run validation (all artifacts)'); }); it('should pass commit-id in incremental dry-run step', () => { @@ -314,7 +305,7 @@ describe('github-actions/publish-workflow', () => { environments: ['dev'], }); const lines = workflow.split('\n'); - const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, incremental)')); + const dryRunIncrIdx = lines.findIndex((l) => l.includes('Dry-run validation (incremental)')); const nextStepIdx = lines.findIndex((l, i) => i > dryRunIncrIdx + 1 && l.includes('- name:')); const dryRunSection = lines.slice(dryRunIncrIdx, nextStepIdx).join('\n'); expect(dryRunSection).toContain('--commit-id'); @@ -327,7 +318,7 @@ describe('github-actions/publish-workflow', () => { environments: ['dev'], }); const lines = workflow.split('\n'); - const dryRunAllIdx = lines.findIndex((l) => l.includes('Dry-run validation (dev, all artifacts)')); + const dryRunAllIdx = lines.findIndex((l) => l.includes('Dry-run validation (all artifacts)')); const nextStepIdx = lines.findIndex((l, i) => i > dryRunAllIdx + 1 && l.includes('- name:')); const dryRunSection = lines.slice(dryRunAllIdx, nextStepIdx).join('\n'); expect(dryRunSection).not.toContain('--commit-id'); From 519d86ed08bbc25552a39d879439529c27e94bd7 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 26 Jun 2026 00:25:57 +0000 Subject: [PATCH 5/7] fix: bump GitHub Actions checkout/setup-node to v5 (Node 20 runtime deprecation) --- src/templates/github-actions/extract-workflow.ts | 6 +++--- src/templates/github-actions/publish-workflow.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/templates/github-actions/extract-workflow.ts b/src/templates/github-actions/extract-workflow.ts index c41195c5..72759dcb 100644 --- a/src/templates/github-actions/extract-workflow.ts +++ b/src/templates/github-actions/extract-workflow.ts @@ -62,10 +62,10 @@ 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' @@ -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 diff --git a/src/templates/github-actions/publish-workflow.ts b/src/templates/github-actions/publish-workflow.ts index 7172c4e3..7edcbb93 100644 --- a/src/templates/github-actions/publish-workflow.ts +++ b/src/templates/github-actions/publish-workflow.ts @@ -67,12 +67,12 @@ jobs: needs: get-commit steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '22' From dd994d2b30f2c29d2f7c19fffd77b9c0047869fd Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 26 Jun 2026 00:32:21 +0000 Subject: [PATCH 6/7] fix: bump azure/login to v3 and replace-tokens to v1.4 (Node 24 runtime) --- src/templates/github-actions/extract-workflow.ts | 2 +- src/templates/github-actions/publish-workflow.ts | 4 ++-- tests/unit/templates/github-actions/extract-workflow.test.ts | 2 +- tests/unit/templates/github-actions/publish-workflow.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/templates/github-actions/extract-workflow.ts b/src/templates/github-actions/extract-workflow.ts index 72759dcb..78395349 100644 --- a/src/templates/github-actions/extract-workflow.ts +++ b/src/templates/github-actions/extract-workflow.ts @@ -73,7 +73,7 @@ jobs: 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 }} diff --git a/src/templates/github-actions/publish-workflow.ts b/src/templates/github-actions/publish-workflow.ts index 7edcbb93..567b7e7f 100644 --- a/src/templates/github-actions/publish-workflow.ts +++ b/src/templates/github-actions/publish-workflow.ts @@ -86,7 +86,7 @@ jobs: echo "upper=\$(echo "\${TARGET_ENV}" | tr '[:lower:]' '[:upper:]')" >> "$GITHUB_OUTPUT" - 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 }} @@ -131,7 +131,7 @@ jobs: fi - name: Substitute tokens in configuration file - uses: cschleiden/replace-tokens@v1.3 + uses: cschleiden/replace-tokens@v1.4 with: tokenPrefix: '{#[' tokenSuffix: ']#}' diff --git a/tests/unit/templates/github-actions/extract-workflow.test.ts b/tests/unit/templates/github-actions/extract-workflow.test.ts index 0503b9fa..a55db35c 100644 --- a/tests/unit/templates/github-actions/extract-workflow.test.ts +++ b/tests/unit/templates/github-actions/extract-workflow.test.ts @@ -40,7 +40,7 @@ describe('github-actions/extract-workflow', () => { it('should include Azure login step with federated credentials', () => { const workflow = generateExtractWorkflow({ artifactDir: './apim-artifacts' }); expect(workflow).toContain('Azure Login (Federated Credential)'); - expect(workflow).toContain('azure/login@v2'); + expect(workflow).toContain('azure/login@v3'); expect(workflow).toContain('${{ secrets.AZURE_CLIENT_ID }}'); expect(workflow).toContain('${{ secrets.AZURE_TENANT_ID }}'); expect(workflow).toContain('${{ secrets.AZURE_SUBSCRIPTION_ID }}'); diff --git a/tests/unit/templates/github-actions/publish-workflow.test.ts b/tests/unit/templates/github-actions/publish-workflow.test.ts index 363bffbd..f04201df 100644 --- a/tests/unit/templates/github-actions/publish-workflow.test.ts +++ b/tests/unit/templates/github-actions/publish-workflow.test.ts @@ -208,7 +208,7 @@ describe('github-actions/publish-workflow', () => { artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); - expect(workflow).toContain('cschleiden/replace-tokens@v1.3'); + expect(workflow).toContain('cschleiden/replace-tokens@v1.4'); expect(workflow).toContain("tokenPrefix: '{#['"); expect(workflow).toContain("tokenSuffix: ']#}'"); }); From d70acbfeb48d460908c2ff551a9bd66da57c2df9 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 26 Jun 2026 00:41:38 +0000 Subject: [PATCH 7/7] fixing test --- src/templates/github-actions/publish-workflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/github-actions/publish-workflow.ts b/src/templates/github-actions/publish-workflow.ts index 567b7e7f..161f0f33 100644 --- a/src/templates/github-actions/publish-workflow.ts +++ b/src/templates/github-actions/publish-workflow.ts @@ -83,7 +83,7 @@ jobs: id: env run: | echo "name=\${TARGET_ENV}" >> "$GITHUB_OUTPUT" - echo "upper=\$(echo "\${TARGET_ENV}" | tr '[:lower:]' '[:upper:]')" >> "$GITHUB_OUTPUT" + echo "upper=$(echo "\${TARGET_ENV}" | tr '[:lower:]' '[:upper:]')" >> "$GITHUB_OUTPUT" - name: Azure Login (Federated Credential) uses: azure/login@v3