Update dokku-vm dokku version to 0.38.30 #643
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ────────────────────────────────────────────────────────────────────────────── | |
| # ValidateSampleDeployments.yml | |
| # | |
| # Combined workflow: AI-powered PR summary + ADX deployment validation. | |
| # | |
| # Job 1 – summarize: | |
| # Posts an AI-generated reviewer-friendly summary comment on a PR using the | |
| # instructions in .github/agents/summarizer.agent.md. | |
| # Runs Microsoft Security DevOps (Template Analyzer, Checkov, Trivy, Terrascan) | |
| # and feeds findings to the AI model via an agentic tool-calling loop. | |
| # | |
| # Job 2 – selected-pipeline: | |
| # Validates sample deployments against Azure Data Explorer (ADX) logs. | |
| # Requires the adx-readonly environment, MEMBER/OWNER/COLLABORATOR auth, | |
| # and validate-samples.yml to have passed. | |
| # | |
| # Triggered by a "/validate" comment on a pull request. Uses issue_comment | |
| # (not pull_request) so GITHUB_TOKEN has full permissions for fork PRs. | |
| # | |
| # Security gates (enforced in the `gate` job, before any PR-controlled code | |
| # is read by downstream jobs): | |
| # 1. Commenter authorization: only MEMBER/OWNER/COLLABORATOR can drive any | |
| # job. `issue_comment` events fire for any GitHub user, so this gate | |
| # is the only thing preventing fork-PR authors (or arbitrary commenters) | |
| # from triggering `summarize`/`selected-pipeline` with `pull-requests: | |
| # write` / `security-events: write` / Azure-OIDC tokens. ICM 31000000622897. | |
| # 2. Protected-files safeguard: PRs that modify this workflow or the | |
| # `summarizer.agent.md` system prompt are blocked unless the PR author | |
| # is MEMBER/OWNER, so downstream jobs never read attacker-controlled | |
| # copies of those files. | |
| # Downstream jobs (`summarize`, `selected-pipeline`) re-assert the commenter | |
| # check as defense-in-depth. | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| name: Validate PR (Summarize + ADX) | |
| on: | |
| issue_comment: | |
| types: [created] | |
| # `pull_request_target` (not `pull_request`) for the post-merge | |
| # commit-generated-on-merge job: GitHub forces GITHUB_TOKEN to read-only | |
| # on `pull_request` events from forks regardless of the `permissions:` | |
| # block, which 403s the auto-PR branch push. `pull_request_target` runs | |
| # in the base-repo context with full token permissions even for fork | |
| # PRs. This is safe here because the job only checks out the default | |
| # branch (never the fork's PR head) and only downloads a pre-validated | |
| # artifact — no fork code is executed. | |
| pull_request_target: | |
| types: [closed] | |
| # Workflow-level concurrency: one run per PR | |
| concurrency: | |
| group: validate-pr-${{ github.event.issue.number || github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ============================================================================ | |
| # JOB 0 – gate | |
| # | |
| # Blocks the workflow until validate-samples.yml has reached a terminal | |
| # success conclusion for the PR HEAD SHA. | |
| # * completed + success -> proceed (exit 0) | |
| # * completed + failure-like -> fail immediately | |
| # * non-terminal (queued/in_progress/...) or no-run -> poll every 30s | |
| # for up to 10 min, then re-evaluate as above. | |
| # | |
| # Both `summarize` and `selected-pipeline` declare `needs: [gate]` so a | |
| # gate failure/timeout skips them entirely. | |
| # ============================================================================ | |
| gate: | |
| name: Wait for validate-samples.yml to succeed | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 12 | |
| # Only run on /validate comments on pull requests | |
| if: >- | |
| github.event.issue.pull_request && | |
| startsWith(github.event.comment.body, '/validate') | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| actions: read | |
| checks: write | |
| outputs: | |
| head_sha: ${{ steps.pr-head.outputs.head_sha }} | |
| steps: | |
| # ── Security gate 1/2: commenter authorization ───────────────────── | |
| # `issue_comment` events fire for any GitHub user who can comment on | |
| # the PR (i.e., anyone on a public repo). All downstream jobs read | |
| # PR-controlled inputs and/or hold write tokens, so we authorize the | |
| # commenter here, before any checkout or API call against the PR. | |
| # See ICM 31000000622897 / MSRC 119361. | |
| - name: Check commenter permission (basic) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "Comment author association: ${{ github.event.comment.author_association }}" | |
| case "${{ github.event.comment.author_association }}" in | |
| MEMBER|OWNER|COLLABORATOR) ;; | |
| *) | |
| echo "::error::Not authorized to /validate. Comment author_association='${{ github.event.comment.author_association }}'; only MEMBER/OWNER/COLLABORATOR may trigger this workflow." | |
| exit 1 | |
| ;; | |
| esac | |
| # ── Security gate 2/2: block edits to protected workflow files ───── | |
| # If the PR modifies this workflow or the `summarizer.agent.md` | |
| # system prompt, the PR author must be MEMBER/OWNER. This prevents | |
| # a fork-PR author from substituting a malicious system prompt or | |
| # workflow definition that a maintainer would then run with | |
| # `/validate`. Mirrors the in-job safeguard in `selected-pipeline` | |
| # but lifted to `gate` so it short-circuits `summarize` as well. | |
| - name: Safeguard – block protected-file edits by non MEMBER/OWNER | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const allowed = new Set(["MEMBER", "OWNER"]); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pull_number = context.payload.issue.number; | |
| const [prData, files] = await Promise.all([ | |
| github.rest.pulls.get({ owner, repo, pull_number }), | |
| github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| ) | |
| ]); | |
| const assoc = prData.data.author_association; | |
| // Any file executed with the privileged token/OIDC of this | |
| // workflow (via checkout of PR HEAD) must be listed here so | |
| // fork PRs cannot substitute a malicious version. Directory | |
| // entries end with "/" and match by prefix; file entries | |
| // match exactly. See ICM 31000000643564. | |
| const protectedDirs = [ | |
| ".github/workflows/", | |
| ".github/scripts/", | |
| ".github/actions/", | |
| ".github/agents/", | |
| ]; | |
| const protectedFiles = [ | |
| ".github/CODEOWNERS", | |
| ]; | |
| const isProtected = (name) => | |
| protectedDirs.some(d => name.startsWith(d)) || | |
| protectedFiles.includes(name); | |
| const touchedNames = files.map(f => f.filename).filter(isProtected); | |
| const touched = touchedNames.length > 0; | |
| core.info(`PR author_association=${assoc}; protected files touched=${touched}`); | |
| if (touched && !allowed.has(assoc)) { | |
| core.setFailed( | |
| `Blocked: protected file(s) ${touchedNames.join(', ')} modified by PR author with ` + | |
| `author_association='${assoc}'. Only MEMBER or OWNER may modify these files.` | |
| ); | |
| } | |
| # Sparse checkout just the helper script directory so subsequent steps | |
| # can call .github/scripts/upsert-check-run.sh. | |
| - name: Checkout helper scripts | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| sparse-checkout: | | |
| .github/scripts | |
| sparse-checkout-cone-mode: false | |
| - name: Resolve PR HEAD SHA | |
| id: pr-head | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.issue.number }} | |
| run: | | |
| set -euo pipefail | |
| gh_api_retry () { | |
| local out | |
| if out=$(gh api "$@" 2>&1); then | |
| printf '%s' "${out}" | |
| return 0 | |
| fi | |
| echo " (transient gh api failure, retrying once: ${out})" >&2 | |
| sleep 2 | |
| gh api "$@" | |
| } | |
| HEAD_SHA=$(gh_api_retry "/repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha') | |
| if [[ -z "${HEAD_SHA}" ]]; then | |
| echo "ERROR: Failed to resolve HEAD SHA for PR #${PR_NUMBER}." >&2 | |
| exit 1 | |
| fi | |
| echo "PR #${PR_NUMBER} HEAD SHA: ${HEAD_SHA}" | |
| echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" | |
| # Publish the required check as in_progress so reviewers see live status | |
| # the moment /validate is acknowledged. The final conclusion is written | |
| # by the `report-check` job at the end of the workflow. | |
| - name: Mark adx-deployment-validation check in_progress | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| CHECK_NAME: adx-deployment-validation | |
| HEAD_SHA: ${{ steps.pr-head.outputs.head_sha }} | |
| STATUS: in_progress | |
| TITLE: Validating in progress… | |
| SUMMARY: | | |
| `/validate` was received. Waiting for `validate-samples.yml` | |
| to succeed for this commit, then running ADX deployment | |
| validation. | |
| DETAILS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: bash .github/scripts/upsert-check-run.sh | |
| - name: Wait for validate-samples.yml on PR HEAD SHA | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| HEAD_SHA: ${{ steps.pr-head.outputs.head_sha }} | |
| POLL_INTERVAL_SECONDS: '30' | |
| POLL_TIMEOUT_SECONDS: '600' | |
| run: | | |
| set -euo pipefail | |
| # ── Helper: gh api with one immediate retry on transient failure ── | |
| gh_api_retry () { | |
| local out | |
| if out=$(gh api "$@" 2>&1); then | |
| printf '%s' "${out}" | |
| return 0 | |
| fi | |
| echo " (transient gh api failure, retrying once: ${out})" >&2 | |
| sleep 2 | |
| gh api "$@" | |
| } | |
| echo "Polling validate-samples.yml for SHA ${HEAD_SHA} every ${POLL_INTERVAL_SECONDS}s (timeout ${POLL_TIMEOUT_SECONDS}s)..." | |
| # Conclusions considered terminal failures (immediate fail). | |
| FAILURE_CONCLUSIONS="failure cancelled timed_out action_required startup_failure stale" | |
| DEADLINE=$(( $(date +%s) + POLL_TIMEOUT_SECONDS )) | |
| LAST_STATUS="" | |
| ATTEMPT=0 | |
| while :; do | |
| ATTEMPT=$((ATTEMPT + 1)) | |
| RUN_JSON=$(gh_api_retry \ | |
| "/repos/${REPO}/actions/workflows/validate-samples.yml/runs?head_sha=${HEAD_SHA}&per_page=1" \ | |
| --jq '.workflow_runs[0] // {}') | |
| STATUS=$(echo "${RUN_JSON}" | jq -r '.status // empty') | |
| CONCLUSION=$(echo "${RUN_JSON}" | jq -r '.conclusion // empty') | |
| LAST_STATUS="${STATUS:-no-run-yet}" | |
| echo "[attempt ${ATTEMPT}] status='${LAST_STATUS}' conclusion='${CONCLUSION:-<none>}'" | |
| if [[ "${STATUS}" == "completed" ]]; then | |
| if [[ "${CONCLUSION}" == "success" ]]; then | |
| echo "✅ validate-samples.yml succeeded for SHA ${HEAD_SHA}." | |
| exit 0 | |
| fi | |
| for f in ${FAILURE_CONCLUSIONS}; do | |
| if [[ "${CONCLUSION}" == "${f}" ]]; then | |
| echo "❌ validate-samples.yml conclusion='${CONCLUSION}' for SHA ${HEAD_SHA}." >&2 | |
| echo " Fix the failures and push a new commit; once validate-samples passes, comment /validate again." >&2 | |
| exit 1 | |
| fi | |
| done | |
| # Unknown/neutral/skipped — treat as not-yet-success and keep polling. | |
| echo " conclusion '${CONCLUSION}' is neither success nor a known failure; continuing to poll." >&2 | |
| fi | |
| NOW=$(date +%s) | |
| if (( NOW >= DEADLINE )); then | |
| echo "⏱️ validate-samples.yml did not reach a terminal state within ${POLL_TIMEOUT_SECONDS} seconds (last status='${LAST_STATUS}')." >&2 | |
| echo " Wait for validate-samples to complete, then comment /validate again." >&2 | |
| exit 1 | |
| fi | |
| sleep "${POLL_INTERVAL_SECONDS}" | |
| done | |
| summarize: | |
| name: Summarize PR sample | |
| runs-on: ubuntu-latest | |
| needs: [gate] | |
| # Only run on /validate comments on pull requests (and after gate succeeds) | |
| if: >- | |
| github.event.issue.pull_request && | |
| startsWith(github.event.comment.body, '/validate') | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| copilot-requests: write | |
| security-events: write | |
| steps: | |
| # ── Commenter authorization (defense-in-depth) ───────────────────── | |
| # `gate` already enforces this, but `issue_comment` triggers fire for | |
| # any GitHub user and this job holds `pull-requests: write` + | |
| # `security-events: write` while reading PR-controlled `.github/agents/ | |
| # summarizer.agent.md` as the AI system prompt. Re-asserting the | |
| # commenter check here keeps `summarize` safe even if the `gate` | |
| # check is ever bypassed or refactored. See ICM 31000000622897. | |
| - name: Check commenter permission (basic) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "Comment author association: ${{ github.event.comment.author_association }}" | |
| case "${{ github.event.comment.author_association }}" in | |
| MEMBER|OWNER|COLLABORATOR) ;; | |
| *) | |
| echo "::error::Not authorized to /validate. Comment author_association='${{ github.event.comment.author_association }}'; only MEMBER/OWNER/COLLABORATOR may trigger this workflow." | |
| exit 1 | |
| ;; | |
| esac | |
| # ── 1. Resolve the PR head ref ────────────────────────────────────── | |
| - name: Get PR details | |
| id: pr | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number | |
| }); | |
| core.setOutput('head_ref', pr.data.head.ref); | |
| core.setOutput('head_sha', pr.data.head.sha); | |
| core.setOutput('head_repo', pr.data.head.repo.full_name); | |
| # ── 2. Detect changed sample folders (API only, no checkout) ──────── | |
| - name: Detect changed sample folders | |
| id: detect | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const path = require('path'); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pull_number = context.issue.number; | |
| const headSha = '${{ steps.pr.outputs.head_sha }}'; | |
| core.info(`Detecting changed samples for PR #${pull_number}...`); | |
| // Get changed files via API (no checkout needed) | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| ); | |
| const changedFiles = files.map(f => f.filename); | |
| core.info(`Changed files (${changedFiles.length}):`); | |
| changedFiles.forEach(f => core.info(` ${f}`)); | |
| const SAMPLE_ROOTS = [ | |
| 'quickstarts/', | |
| 'demos/', | |
| 'application-workloads/', | |
| 'modules/', | |
| 'subscription-deployments/', | |
| 'managementgroup-deployments/', | |
| 'tenant-deployments/' | |
| ]; | |
| // Build a set of all changed file paths for quick lookup | |
| const changedSet = new Set(changedFiles); | |
| // Use the Git tree API to check file existence at the PR head | |
| // Cache tree lookups to avoid redundant API calls | |
| const treeCache = new Map(); | |
| async function dirContains(dirPath, filename) { | |
| if (treeCache.has(dirPath)) { | |
| return treeCache.get(dirPath).has(filename); | |
| } | |
| try { | |
| // Get the tree for this directory at the PR head commit | |
| const { data } = await github.rest.git.getTree({ | |
| owner, repo, | |
| tree_sha: `${headSha}:${dirPath}` | |
| }); | |
| const names = new Set(data.tree.map(e => e.path)); | |
| treeCache.set(dirPath, names); | |
| return names.has(filename); | |
| } catch { | |
| treeCache.set(dirPath, new Set()); | |
| return false; | |
| } | |
| } | |
| const seenDirs = new Set(); | |
| for (const changedFile of changedFiles) { | |
| const matchedRoot = SAMPLE_ROOTS.find(r => changedFile.startsWith(r)); | |
| if (!matchedRoot) continue; | |
| const relRoot = matchedRoot.replace(/\/$/, ''); | |
| let dir = path.posix.dirname(changedFile); | |
| while (dir !== relRoot && dir !== '.') { | |
| if (seenDirs.has(dir)) break; | |
| // Check if metadata.json and README.md exist via Git tree API | |
| const hasMeta = await dirContains(dir, 'metadata.json'); | |
| const hasReadme = await dirContains(dir, 'README.md'); | |
| if (hasMeta && hasReadme) { | |
| seenDirs.add(dir); | |
| break; | |
| } | |
| dir = path.posix.dirname(dir); | |
| } | |
| } | |
| const samplesList = [...seenDirs]; | |
| const samplesJson = JSON.stringify(samplesList); | |
| core.info(`Detected sample folders: ${samplesJson}`); | |
| core.setOutput('samples_json', samplesJson); | |
| // Build sparse-checkout paths: agent instructions + sample folders | |
| const sparsePaths = ['.github/agents/']; | |
| for (const s of samplesList) { | |
| sparsePaths.push(s + '/'); | |
| } | |
| core.setOutput('sparse_paths', sparsePaths.join('\n')); | |
| core.info(`Sparse checkout paths:\n${sparsePaths.join('\n')}`); | |
| # ── 3. Sparse checkout PR head (only agent files + sample folders) ── | |
| - name: Checkout PR head (sparse) | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| repository: ${{ steps.pr.outputs.head_repo }} | |
| ref: ${{ steps.pr.outputs.head_sha }} | |
| fetch-depth: 1 | |
| sparse-checkout: ${{ steps.detect.outputs.sparse_paths }} | |
| # ── 4. Add reaction to acknowledge the command ────────────────────── | |
| - name: React to comment | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| await github.rest.reactions.createForIssueComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: context.payload.comment.id, | |
| content: 'eyes' | |
| }); | |
| # ── 5. Run Microsoft Security DevOps (Template Analyzer, Checkov, Trivy, Terrascan) ── | |
| - name: Run MSDO security scan | |
| id: msdo | |
| uses: microsoft/security-devops-action@latest | |
| continue-on-error: true | |
| with: | |
| tools: templateanalyzer,checkov,trivy,terrascan | |
| categories: IaC | |
| # ── 6. Parse SARIF security findings ──────────────────────────────── | |
| - name: Parse SARIF findings | |
| id: security | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| env: | |
| SARIF_FILE: ${{ steps.msdo.outputs.sarifFile }} | |
| SAMPLES_JSON: ${{ steps.detect.outputs.samples_json }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const sarifPath = process.env.SARIF_FILE; | |
| const samples = JSON.parse(process.env.SAMPLES_JSON || '[]'); | |
| const findings = []; | |
| if (!sarifPath || !fs.existsSync(sarifPath)) { | |
| core.warning('SARIF file not found – security scan may have failed.'); | |
| core.setOutput('findings_json', JSON.stringify(findings)); | |
| core.setOutput('scan_status', 'unavailable'); | |
| return; | |
| } | |
| try { | |
| const sarif = JSON.parse(fs.readFileSync(sarifPath, 'utf8')); | |
| const workspace = process.env.GITHUB_WORKSPACE; | |
| for (const run of (sarif.runs || [])) { | |
| const toolName = run.tool?.driver?.name || 'unknown'; | |
| const rulesById = {}; | |
| for (const rule of (run.tool?.driver?.rules || [])) { | |
| rulesById[rule.id] = rule; | |
| } | |
| for (const result of (run.results || [])) { | |
| const ruleId = result.ruleId || 'unknown'; | |
| const rule = rulesById[ruleId] || {}; | |
| const level = result.level || rule.defaultConfiguration?.level || 'warning'; | |
| // Map SARIF levels to severity | |
| const severityMap = { error: 'high', warning: 'medium', note: 'low', none: 'info' }; | |
| const severity = severityMap[level] || 'medium'; | |
| const location = result.locations?.[0]?.physicalLocation; | |
| let filePath = location?.artifactLocation?.uri || ''; | |
| // Normalize file path relative to workspace | |
| if (filePath.startsWith('file://')) { | |
| filePath = filePath.replace('file://', ''); | |
| } | |
| if (workspace && filePath.startsWith(workspace)) { | |
| filePath = filePath.substring(workspace.length + 1); | |
| } | |
| // Only include findings in changed sample folders | |
| const inSample = samples.length === 0 || | |
| samples.some(s => filePath.startsWith(s + '/') || filePath === s); | |
| if (!inSample) continue; | |
| findings.push({ | |
| tool: toolName, | |
| ruleId, | |
| severity, | |
| message: result.message?.text || rule.shortDescription?.text || ruleId, | |
| file: filePath, | |
| startLine: location?.region?.startLine || null, | |
| helpUri: rule.helpUri || null | |
| }); | |
| } | |
| } | |
| // Sort: high → medium → low (use ?? to handle 0 correctly) | |
| const severityOrder = { high: 0, medium: 1, low: 2, info: 3 }; | |
| findings.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3)); | |
| core.info(`Parsed ${findings.length} security finding(s) in sample folders.`); | |
| } catch (err) { | |
| core.warning(`Failed to parse SARIF: ${err.message}`); | |
| } | |
| // Cap findings to avoid blowing up the context | |
| const capped = findings.slice(0, 50); | |
| core.setOutput('findings_json', JSON.stringify(capped)); | |
| core.setOutput('scan_status', 'completed'); | |
| # ── 7. Build bounded context and run Copilot inference ────────────── | |
| - name: Checkout trusted summarizer assets | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| repository: ${{ github.repository }} | |
| ref: ${{ github.sha }} | |
| fetch-depth: 1 | |
| path: .trusted-summary | |
| sparse-checkout: | | |
| .github/agents/summarizer.agent.md | |
| .github/scripts/build-summary-prompt.cjs | |
| sparse-checkout-cone-mode: false | |
| - name: Prepare summary prompt | |
| id: prompt | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| env: | |
| SAMPLES_JSON: ${{ steps.detect.outputs.samples_json }} | |
| SECURITY_FINDINGS_JSON: ${{ steps.security.outputs.findings_json }} | |
| SCAN_STATUS: ${{ steps.security.outputs.scan_status }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { buildSummaryPrompt } = require(path.join( | |
| process.env.GITHUB_WORKSPACE, | |
| '.trusted-summary', | |
| '.github', | |
| 'scripts', | |
| 'build-summary-prompt.cjs' | |
| )); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pull_number = context.issue.number; | |
| const samples = JSON.parse(process.env.SAMPLES_JSON || '[]'); | |
| const securityFindings = JSON.parse( | |
| process.env.SECURITY_FINDINGS_JSON || '[]' | |
| ); | |
| const prData = await github.rest.pulls.get({ | |
| owner, repo, pull_number | |
| }); | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| ); | |
| const changedFiles = files.map(file => file.filename); | |
| const MAX_DIFF_CHARS = 2000; | |
| const diffResponse = await github.rest.pulls.get({ | |
| owner, repo, pull_number, | |
| mediaType: { format: 'diff' } | |
| }); | |
| const fullDiff = typeof diffResponse.data === 'string' | |
| ? diffResponse.data | |
| : ''; | |
| const diffExcerpt = fullDiff.length > MAX_DIFF_CHARS | |
| ? `${fullDiff.substring(0, MAX_DIFF_CHARS)}\n... (truncated)` | |
| : fullDiff; | |
| const prompt = buildSummaryPrompt({ | |
| workspaceRoot: process.env.GITHUB_WORKSPACE, | |
| pullNumber: pull_number, | |
| pullTitle: prData.data.title, | |
| samples, | |
| changedFiles, | |
| diffExcerpt, | |
| scanStatus: process.env.SCAN_STATUS || 'unavailable', | |
| securityFindings | |
| }); | |
| const promptFile = path.join( | |
| process.env.RUNNER_TEMP, | |
| 'quickstart-summary-prompt.txt' | |
| ); | |
| fs.writeFileSync(promptFile, prompt, 'utf8'); | |
| core.info(`Prepared bounded summary prompt (${Buffer.byteLength(prompt)} bytes).`); | |
| core.setOutput('prompt_file', promptFile); | |
| - name: Set up Node.js for Copilot CLI | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 | |
| with: | |
| node-version: 24 | |
| - name: Install Copilot CLI | |
| shell: bash | |
| run: npm install --global @github/copilot@1.0.78 | |
| - name: Run Copilot inference | |
| id: inference | |
| continue-on-error: true | |
| uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8 | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| with: | |
| provider: copilot | |
| token: ${{ github.token }} | |
| model: gpt-4.1 | |
| prompt-file: ${{ steps.prompt.outputs.prompt_file }} | |
| system-prompt-file: .trusted-summary/.github/agents/summarizer.agent.md | |
| # ── 8. Post the summary when inference succeeds ──────────────────── | |
| - name: Generate and post summary | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| env: | |
| INFERENCE_OUTCOME: ${{ steps.inference.outcome }} | |
| INFERENCE_RESPONSE_FILE: ${{ steps.inference.outputs.response-file }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const outcome = process.env.INFERENCE_OUTCOME; | |
| const responseFile = process.env.INFERENCE_RESPONSE_FILE; | |
| if (outcome !== 'success' || !responseFile || !fs.existsSync(responseFile)) { | |
| core.warning( | |
| 'Copilot inference was unavailable; skipping the informational PR summary.' | |
| ); | |
| await core.summary | |
| .addHeading('Quickstart sample summary') | |
| .addRaw('Copilot inference was unavailable. Deployment validation was not affected.') | |
| .write(); | |
| try { | |
| await github.rest.reactions.createForIssueComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: context.payload.comment.id, | |
| content: 'confused' | |
| }); | |
| } catch (reactionError) { | |
| core.warning(`Could not add inference-failure reaction: ${reactionError.message}`); | |
| } | |
| return; | |
| } | |
| const summary = fs.readFileSync(responseFile, 'utf8').trim(); | |
| if (!summary) { | |
| core.warning( | |
| 'Copilot inference returned an empty response; skipping the informational PR summary.' | |
| ); | |
| await core.summary | |
| .addHeading('Quickstart sample summary') | |
| .addRaw('Copilot inference returned an empty response. Deployment validation was not affected.') | |
| .write(); | |
| return; | |
| } | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pull_number = context.issue.number; | |
| const marker = '<!-- quickstart-summarizer-bot -->'; | |
| const commentBody = `${marker}\n## 🤖 Quickstart Sample Summary\n\n${summary}\n\n---\n*Generated by the quickstart summarizer agent (v3 · GitHub Copilot + MSDO security) · triggered by /validate*`; | |
| const comments = await github.paginate( | |
| github.rest.issues.listComments, | |
| { owner, repo, issue_number: pull_number, per_page: 100 } | |
| ); | |
| const existing = comments.find( | |
| comment => comment.body && comment.body.includes(marker) | |
| ); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner, repo, | |
| comment_id: existing.id, | |
| body: commentBody | |
| }); | |
| core.info(`Updated existing summary comment (id=${existing.id}).`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, repo, | |
| issue_number: pull_number, | |
| body: commentBody | |
| }); | |
| core.info('Created new summary comment on PR.'); | |
| } | |
| await github.rest.reactions.createForIssueComment({ | |
| owner, repo, | |
| comment_id: context.payload.comment.id, | |
| content: 'rocket' | |
| }); | |
| selected-pipeline: | |
| name: Validate ARM Deployments via ADX | |
| runs-on: ubuntu-latest | |
| needs: [gate] | |
| environment: adx-readonly | |
| # Run if the gate confirmed validate-samples.yml succeeded for the PR HEAD | |
| # SHA, regardless of how `summarize` concluded. (`summarize` is a separate | |
| # informational job; its failure or cancellation must not skip the | |
| # deployment validation that gates merge.) | |
| if: >- | |
| !cancelled() && | |
| needs.gate.result == 'success' && | |
| github.event.issue.pull_request && | |
| startsWith(github.event.comment.body, '/validate') | |
| permissions: | |
| # `contents: write` is required by the `Commit generated azuredeploy.json | |
| # to PR head branch` step at the end of this job, which (for same-repo | |
| # PRs only) pushes the generated ARM JSON onto the PR's head branch so | |
| # the merge naturally carries it into master. Fork PRs are handled by | |
| # the post-merge `commit-generated-on-merge` job's auto-PR fallback. | |
| contents: write | |
| pull-requests: read | |
| issues: read | |
| id-token: write | |
| actions: read | |
| # Job-level outputs consumed by the `report-check` job to map this job's | |
| # result to a GitHub Check Run conclusion. | |
| outputs: | |
| skip: ${{ steps.preflight.outputs.skip }} | |
| sample_count: ${{ steps.preflight.outputs.sample_count }} | |
| steps: | |
| - name: Check commenter permission (basic) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "Comment author association: ${{ github.event.comment.author_association }}" | |
| case "${{ github.event.comment.author_association }}" in | |
| MEMBER|OWNER|COLLABORATOR) ;; | |
| *) | |
| echo "Not authorized to /validate" | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Get PR number | |
| id: pr | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.issue.number }} | |
| run: | | |
| set -euo pipefail | |
| echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| # Resolve head SHA so the artifact uploaded later in this job can be | |
| # uniquely named per PR head commit and located by the merge job. | |
| # Also resolve head_repo and head_ref so the path-A push step at the | |
| # end of this job can decide whether the PR is from the same repo | |
| # (push to head branch) or a fork (skip; merge job will auto-PR). | |
| PR_JSON=$(gh api "/repos/${REPO}/pulls/${PR_NUMBER}") | |
| HEAD_SHA=$(echo "${PR_JSON}" | jq -r '.head.sha') | |
| HEAD_REF=$(echo "${PR_JSON}" | jq -r '.head.ref') | |
| HEAD_REPO=$(echo "${PR_JSON}" | jq -r '.head.repo.full_name') | |
| echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" | |
| echo "head_ref=${HEAD_REF}" >> "$GITHUB_OUTPUT" | |
| echo "head_repo=${HEAD_REPO}" >> "$GITHUB_OUTPUT" | |
| # ── Preflight: classify whether this PR contains deployment-affecting | |
| # changes, and if so, in how many distinct sample folders. | |
| # | |
| # Outputs: | |
| # skip - 'true' if the PR has NO deploy-affecting changes; | |
| # downstream ADX steps are then skipped and the | |
| # `report-check` job publishes a `success` check with | |
| # "Auto-passed: no deployment-affecting changes." | |
| # sample_count - number of distinct sample folders containing a | |
| # deploy-affecting change. | |
| # | |
| # Multi-sample deployable PRs fail this step explicitly: the existing | |
| # ADX validation only supports a single changed metadata.json/sample | |
| # per PR. Authors are told to split the PR. | |
| - name: Preflight – classify deploy-affecting changes | |
| id: preflight | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pull_number = parseInt('${{ steps.pr.outputs.number }}', 10); | |
| const prData = await github.rest.pulls.get({ owner, repo, pull_number }); | |
| const headSha = prData.data.head.sha; | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| ); | |
| const SAMPLE_ROOTS = [ | |
| 'quickstarts/', | |
| 'demos/', | |
| 'application-workloads/', | |
| 'modules/', | |
| 'subscription-deployments/', | |
| 'managementgroup-deployments/', | |
| 'tenant-deployments/' | |
| ]; | |
| // Predicate: does this changed-file path affect a deployment? | |
| // Conservative: unknown paths under a sample root count as | |
| // deploy-affecting so we don't accidentally auto-pass a real | |
| // template change. | |
| function isDeployAffecting(filename) { | |
| const matchedRoot = SAMPLE_ROOTS.find(r => filename.startsWith(r)); | |
| if (!matchedRoot) return false; | |
| const lower = filename.toLowerCase(); | |
| const base = lower.split('/').pop(); | |
| // Always non-deploy-affecting (sample metadata / docs / images). | |
| if (base === 'metadata.json') return false; | |
| if (base === 'readme.md' || base === 'contributing.md') return false; | |
| if (lower.endsWith('.md')) return false; | |
| if (lower.endsWith('.png') || lower.endsWith('.jpg') || | |
| lower.endsWith('.jpeg') || lower.endsWith('.gif') || | |
| lower.endsWith('.svg') || lower.endsWith('.ico')) return false; | |
| if (base === '.gitignore') return false; | |
| // Always deploy-affecting (templates, params, scripts, prereqs). | |
| if (lower.endsWith('.bicep') || lower.endsWith('.bicepparam')) return true; | |
| if (lower.endsWith('.json')) return true; | |
| if (lower.endsWith('.ps1') || lower.endsWith('.sh')) return true; | |
| if (lower.includes('/prereqs/')) return true; | |
| // Conservative default for anything else under a sample root. | |
| return true; | |
| } | |
| const deployFiles = files | |
| .filter(f => isDeployAffecting(f.filename)); | |
| core.info(`Total changed files: ${files.length}`); | |
| core.info(`Deploy-affecting files: ${deployFiles.length}`); | |
| for (const f of deployFiles) core.info(` ${f.status}\t${f.filename}`); | |
| if (deployFiles.length === 0) { | |
| core.info('No deployment-affecting changes detected — skipping ADX validation.'); | |
| core.setOutput('skip', 'true'); | |
| core.setOutput('sample_count', '0'); | |
| return; | |
| } | |
| // Resolve each deploy-affecting file to its sample folder by | |
| // walking up the directory tree until we find a dir containing | |
| // both metadata.json and README.md (mirrors validate-samples.yml). | |
| const treeCache = new Map(); | |
| async function dirContains(dirPath, filename) { | |
| if (treeCache.has(dirPath)) { | |
| return treeCache.get(dirPath).has(filename); | |
| } | |
| try { | |
| const { data } = await github.rest.git.getTree({ | |
| owner, repo, tree_sha: `${headSha}:${dirPath}` | |
| }); | |
| const names = new Set(data.tree.map(e => e.path)); | |
| treeCache.set(dirPath, names); | |
| return names.has(filename); | |
| } catch { | |
| treeCache.set(dirPath, new Set()); | |
| return false; | |
| } | |
| } | |
| const sampleFolders = new Set(); | |
| for (const f of deployFiles) { | |
| const root = SAMPLE_ROOTS.find(r => f.filename.startsWith(r)); | |
| const relRoot = root.replace(/\/$/, ''); | |
| let dir = f.filename.split('/').slice(0, -1).join('/'); | |
| while (dir && dir !== relRoot && dir !== '.') { | |
| if (sampleFolders.has(dir)) break; | |
| const hasMeta = await dirContains(dir, 'metadata.json'); | |
| const hasReadme = await dirContains(dir, 'README.md'); | |
| if (hasMeta && hasReadme) { | |
| sampleFolders.add(dir); | |
| break; | |
| } | |
| dir = dir.split('/').slice(0, -1).join('/'); | |
| } | |
| } | |
| const folderList = [...sampleFolders]; | |
| core.info(`Distinct sample folders touched: ${folderList.length}`); | |
| for (const d of folderList) core.info(` ${d}`); | |
| core.setOutput('sample_count', String(folderList.length)); | |
| core.setOutput('skip', 'false'); | |
| if (folderList.length > 1) { | |
| core.setFailed( | |
| `Multi-sample deployable PR detected (${folderList.length} sample folders). ` + | |
| `The ADX validation pipeline only supports a single changed sample per PR. ` + | |
| `Please split deployable changes across multiple PRs (one sample per PR).` | |
| ); | |
| } | |
| - name: Safeguard – block protected-file edits by non MEMBER/OWNER | |
| id: safeguard | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const allowed = new Set(["MEMBER", "OWNER"]); | |
| const pull_number = parseInt("${{ steps.pr.outputs.number }}", 10); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const [prData, files] = await Promise.all([ | |
| github.rest.pulls.get({ owner, repo, pull_number }), | |
| github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| ) | |
| ]); | |
| const assoc = prData.data.author_association; | |
| // Any file executed with the privileged token/OIDC of this | |
| // workflow (via checkout of PR HEAD) must be listed here so | |
| // fork PRs cannot substitute a malicious version. Directory | |
| // entries end with "/" and match by prefix; file entries | |
| // match exactly. See ICM 31000000643564. | |
| const protectedDirs = [ | |
| ".github/workflows/", | |
| ".github/scripts/", | |
| ".github/actions/", | |
| ".github/agents/", | |
| ]; | |
| const protectedFiles = [ | |
| ".github/CODEOWNERS", | |
| ]; | |
| const isProtected = (name) => | |
| protectedDirs.some(d => name.startsWith(d)) || | |
| protectedFiles.includes(name); | |
| const touchedNames = files.map(f => f.filename).filter(isProtected); | |
| const touched = touchedNames.length > 0; | |
| core.info(`PR author_association=${assoc}; protected files touched=${touched}`); | |
| // Pin the head SHA observed here so the subsequent PR-HEAD | |
| // checkout resolves to the same commit the safeguard just | |
| // evaluated. Closes the TOCTOU window on refs/pull/N/head. | |
| const headSha = prData.data.head.sha; | |
| core.setOutput("head_sha", headSha); | |
| core.info(`Pinned PR head SHA for downstream checkout: ${headSha}`); | |
| if (touched && !allowed.has(assoc)) { | |
| core.setFailed( | |
| `Blocked: protected file(s) ${touchedNames.join(', ')} modified by PR author with ` + | |
| `author_association='${assoc}'. Only MEMBER or OWNER may modify these files.` | |
| ); | |
| } | |
| - name: Checkout PR HEAD | |
| if: steps.preflight.outputs.skip != 'true' | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| ref: ${{ steps.safeguard.outputs.head_sha }} | |
| # Paginate PR changed files to locate the single added/modified metadata.json | |
| - name: Find metadata.json from PR changed files | |
| id: find-metadata | |
| if: steps.preflight.outputs.skip != 'true' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| PR_NUMBER="${{ steps.pr.outputs.number }}" | |
| REPO="${{ github.repository }}" | |
| METADATA_FILES=() | |
| PAGE=1 | |
| while true; do | |
| RESP=$(gh api \ | |
| "/repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100&page=${PAGE}" \ | |
| --jq '[.[] | select(.status=="added" or .status=="modified" or .status=="renamed") | select(.filename == "metadata.json" or (.filename | endswith("/metadata.json"))) | .filename] | .[]') | |
| [[ -z "${RESP}" ]] && break | |
| while IFS= read -r f; do | |
| METADATA_FILES+=("$f") | |
| done <<< "${RESP}" | |
| (( PAGE++ )) | |
| done | |
| COUNT=${#METADATA_FILES[@]} | |
| if [[ "${COUNT}" -eq 0 ]]; then | |
| echo "ERROR: No metadata.json added/modified in this PR." >&2 | |
| exit 1 | |
| fi | |
| if [[ "${COUNT}" -gt 1 ]]; then | |
| echo "ERROR: More than one metadata.json found in PR changed files:" >&2 | |
| printf ' %s\n' "${METADATA_FILES[@]}" >&2 | |
| exit 1 | |
| fi | |
| METADATA_FILE="${METADATA_FILES[0]}" | |
| SAMPLE_PATH="$(dirname "${METADATA_FILE}")" | |
| echo "metadata_file=${METADATA_FILE}" >> "$GITHUB_OUTPUT" | |
| echo "sample_path=${SAMPLE_PATH}" >> "$GITHUB_OUTPUT" | |
| echo "Found metadata.json: ${METADATA_FILE}" | |
| - name: Azure login via OIDC (federated to UAMI) | |
| if: steps.preflight.outputs.skip != 'true' | |
| uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1 | |
| with: | |
| tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| client-id: ${{ secrets.AZURE_UAMI_CLIENT_ID }} | |
| # ── Ensure azuredeploy.json exists (compile Bicep if needed) ──────────── | |
| - name: Ensure azuredeploy.json exists (compile Bicep if needed) | |
| id: compile-main | |
| if: steps.preflight.outputs.skip != 'true' | |
| shell: bash | |
| env: | |
| SAMPLE_PATH: ${{ steps.find-metadata.outputs.sample_path }} | |
| run: | | |
| set -euo pipefail | |
| cd "${SAMPLE_PATH}" | |
| echo "──────────────────────────────────────────" | |
| echo " Sample folder: ${SAMPLE_PATH}" | |
| echo "──────────────────────────────────────────" | |
| if [[ -f "azuredeploy.json" ]]; then | |
| echo "azuredeploy.json already exists — no compilation needed." | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [[ ! -f "main.bicep" ]]; then | |
| echo "Neither azuredeploy.json nor main.bicep found — skipping (validation step will catch this)." | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "azuredeploy.json not found but main.bicep exists — compiling..." | |
| command -v bicep >/dev/null 2>&1 || az bicep install | |
| bicep build main.bicep --outfile azuredeploy.json | |
| echo "✅ Compiled main.bicep → azuredeploy.json" | |
| echo "generated=true" >> "$GITHUB_OUTPUT" | |
| - name: Validate deployments via ADX | |
| if: steps.preflight.outputs.skip != 'true' | |
| shell: bash | |
| env: | |
| # Deployment logs may live in any one of these regional ADX clusters. | |
| # Queries are tried in order; the first cluster returning a row wins. | |
| CLUSTER_URLS: | | |
| https://armprodeus.eastus.kusto.windows.net | |
| https://armprodweu.westeurope.kusto.windows.net | |
| https://armprodsea.southeastasia.kusto.windows.net | |
| DATABASE: ${{ secrets.ADX_DATABASE }} | |
| # Path resolved from PR changed files by the find-metadata step above | |
| METADATA_FILE: ${{ steps.find-metadata.outputs.metadata_file }} | |
| # Sample folder (where azuredeploy.json / main.bicep live) | |
| SAMPLE_PATH: ${{ steps.find-metadata.outputs.sample_path }} | |
| # 'true' if CI compiled azuredeploy.json from main.bicep; 'false' if the contributor | |
| # committed azuredeploy.json directly. Drives whether we pin Bicep before hashing. | |
| JSON_GENERATED: ${{ steps.compile-main.outputs.generated }} | |
| run: | | |
| set -euo pipefail | |
| # metadata.json path comes from PR changed files; no backslash normalization needed | |
| METADATA_PATH="${METADATA_FILE}" | |
| # ── Validate file exists ────────────────────────────────────────── | |
| if [[ ! -f "${METADATA_PATH}" ]]; then | |
| echo "ERROR: metadata.json not found at '${METADATA_PATH}'" >&2 | |
| exit 1 | |
| fi | |
| echo "== Parsing metadata: ${METADATA_PATH} ==" | |
| # ── Validate .testResult.deployments block is present ───────────── | |
| DEPLOYMENTS=$(jq -c '.testResult.deployments // empty' "${METADATA_PATH}") | |
| if [[ -z "${DEPLOYMENTS}" ]]; then | |
| echo "ERROR: '.testResult.deployments' not found in '${METADATA_PATH}'" >&2 | |
| exit 1 | |
| fi | |
| # ── Helper: POST a pre-built JSON body to a Kusto REST endpoint ─── | |
| run_kusto_query () { | |
| local cluster_url="$1" | |
| local body="$2" | |
| az rest \ | |
| --method POST \ | |
| --url "${cluster_url}/v1/rest/query" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "${body}" \ | |
| --resource "https://kusto.kusto.windows.net" | |
| } | |
| # File-based side channel so the matched cluster URL survives the | |
| # command-substitution subshell used to capture the function's stdout. | |
| MATCHED_CLUSTER_FILE="${RUNNER_TEMP:-/tmp}/matched_cluster.$$" | |
| MATCHED_CLUSTER="" | |
| # ── Helper: try each cluster in order; return first response with rows ─ | |
| # Echoes the JSON response on stdout; logs per-cluster attempts on stderr. | |
| # Writes the cluster URL that returned rows to MATCHED_CLUSTER_FILE (or | |
| # empty if none did) so the parent shell can read it after $(...) capture. | |
| # If all clusters return 0 rows, the last response is echoed so callers | |
| # can still parse it for diagnostics. | |
| run_kusto_query_with_fallback () { | |
| local body="$1" | |
| local response="" row_count=0 cluster="" | |
| : > "${MATCHED_CLUSTER_FILE}" | |
| while IFS= read -r cluster; do | |
| [[ -z "${cluster}" ]] && continue | |
| echo " → trying cluster: ${cluster}" >&2 | |
| response=$(run_kusto_query "${cluster}" "${body}") | |
| row_count=$(echo "${response}" | jq '(.Tables[0].Rows // []) | length') | |
| if [[ "${row_count}" -gt 0 ]]; then | |
| echo " matched (${row_count} row(s)) on ${cluster}" >&2 | |
| printf '%s' "${cluster}" > "${MATCHED_CLUSTER_FILE}" | |
| echo "${response}" | |
| return 0 | |
| fi | |
| echo " 0 rows on ${cluster}" >&2 | |
| done <<< "${CLUSTER_URLS}" | |
| # No cluster matched — return the last response (0 rows) for diagnostics | |
| echo "${response}" | |
| return 0 | |
| } | |
| # ── Source the shared KQL-input validator ───────────────────────── | |
| # Defines validate_kusto_inputs (cid, dn, label) — see | |
| # .github/scripts/validate-kusto-inputs.sh for the format contract. | |
| # Tested by test/scripts/validate-kusto-inputs.bats. | |
| # Security context: MSRC 117973 / ICM 31000000613310. | |
| # shellcheck disable=SC1091 | |
| source "${GITHUB_WORKSPACE}/.github/scripts/validate-kusto-inputs.sh" | |
| # ── If array, pick the non-prereqs entry; otherwise use the flat object ─── | |
| DEPLOYMENTS=$(echo "${DEPLOYMENTS}" | jq -c ' | |
| if type == "array" then | |
| map(select((.templateFileName // "") | startswith("prereqs/") | not)) | .[0] | |
| else | |
| . | |
| end // empty') | |
| if [[ -z "${DEPLOYMENTS}" ]]; then | |
| echo "ERROR: No main template deployment entry found in '.testResult.deployments'" >&2 | |
| exit 1 | |
| fi | |
| # ── Extract fields from the deployments entry ──────────────────── | |
| # Required: templateFileName, correlationId, deploymentName | |
| # Optional: TIMESTAMP (informational), templateHash (ignored — computed from template) | |
| TEMPLATE_FILE_NAME=$(echo "${DEPLOYMENTS}" | jq -r '.templateFileName // empty') | |
| CORRELATION_ID=$(echo "${DEPLOYMENTS}" | jq -r '.correlationId // empty') | |
| DEPLOYMENT_NAME=$(echo "${DEPLOYMENTS}" | jq -r '.deploymentName // empty') | |
| ENTRY_TIMESTAMP=$(echo "${DEPLOYMENTS}" | jq -r '.TIMESTAMP // "N/A"') | |
| echo "" | |
| echo "================================================================" | |
| echo " Template file : ${TEMPLATE_FILE_NAME:-<missing>}" | |
| echo " correlationId : ${CORRELATION_ID:-<missing>}" | |
| echo " deploymentName : ${DEPLOYMENT_NAME:-<missing>}" | |
| echo " timestamp : ${ENTRY_TIMESTAMP} (optional)" | |
| echo "================================================================" | |
| # ── Validate templateFileName is an allowed value ───────────────── | |
| if [[ -z "${TEMPLATE_FILE_NAME}" ]]; then | |
| echo "ERROR: 'templateFileName' is missing from '.testResult.deployments'" >&2 | |
| echo " Required fields: templateFileName, correlationId, deploymentName" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${TEMPLATE_FILE_NAME}" != "azuredeploy.json" && "${TEMPLATE_FILE_NAME}" != "main.bicep" ]]; then | |
| echo "ERROR: 'templateFileName' is '${TEMPLATE_FILE_NAME}' — must be 'azuredeploy.json' or 'main.bicep'" >&2 | |
| exit 1 | |
| fi | |
| # ── Validate remaining required fields ──────────────────────────── | |
| # Only templateFileName, correlationId, and deploymentName are required. | |
| # TIMESTAMP and templateHash are optional. | |
| if [[ -z "${CORRELATION_ID}" ]]; then | |
| echo "ERROR: 'correlationId' is missing from '.testResult.deployments'" >&2 | |
| echo " Required fields: templateFileName, correlationId, deploymentName" >&2 | |
| exit 1 | |
| fi | |
| if [[ -z "${DEPLOYMENT_NAME}" ]]; then | |
| echo "ERROR: 'deploymentName' is missing from '.testResult.deployments'" >&2 | |
| echo " Required fields: templateFileName, correlationId, deploymentName" >&2 | |
| exit 1 | |
| fi | |
| # ── Validate untrusted metadata before binding into KQL ─────────── | |
| # correlationId / deploymentName come from PR-controlled metadata.json. | |
| # Reject anything that does not match the strict format contract | |
| # before we hand the values to Kusto. | |
| if ! validate_kusto_inputs "${CORRELATION_ID}" "${DEPLOYMENT_NAME}" "${TEMPLATE_FILE_NAME}"; then | |
| exit 1 | |
| fi | |
| # ── Run Kusto query ─────────────────────────────────────────────── | |
| # Body shape (parameterized KQL) is built by the shared script — see | |
| # .github/scripts/build-adx-query-body.sh, tested by | |
| # test/scripts/build-adx-query-body.bats. | |
| REQUEST_BODY=$(DATABASE="${DATABASE}" CID="${CORRELATION_ID}" DN="${DEPLOYMENT_NAME}" \ | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/build-adx-query-body.sh") | |
| echo "Running Kusto query for '${TEMPLATE_FILE_NAME}' (with cluster fallback)..." | |
| RESPONSE=$(run_kusto_query_with_fallback "${REQUEST_BODY}") | |
| MATCHED_CLUSTER=$(cat "${MATCHED_CLUSTER_FILE}" 2>/dev/null || true) | |
| ROW_COUNT=$(echo "${RESPONSE}" | jq '(.Tables[0].Rows // []) | length') | |
| if [[ "${ROW_COUNT}" -eq 0 ]]; then | |
| echo "ERROR: No ADX record found in any cluster — correlationId='${CORRELATION_ID}' deploymentName='${DEPLOYMENT_NAME}'" >&2 | |
| echo " clusters tried (in order):" >&2 | |
| while IFS= read -r c; do [[ -n "$c" ]] && echo " - $c" >&2; done <<< "${CLUSTER_URLS}" | |
| exit 1 | |
| fi | |
| echo " matched cluster : ${MATCHED_CLUSTER}" | |
| # Extract field values by column name (robust against column-order changes) | |
| ACTUAL_STATUS=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("executionStatus")) as $i | $t.Rows[0][$i]') | |
| ACTUAL_HASH=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("templateHash")) as $i | $t.Rows[0][$i]') | |
| echo " actual status : ${ACTUAL_STATUS}" | |
| echo " actual hash : ${ACTUAL_HASH}" | |
| # ── Compute templateHash (with optional Bicep version pinning) ──── | |
| # When CI compiled azuredeploy.json from main.bicep, the runner's | |
| # bundled Bicep may differ from the contributor's local Bicep, | |
| # producing a different `_generator.version` and therefore a | |
| # different ARM templateHash. To make the comparison version- | |
| # invariant, we re-compile main.bicep here using the same Bicep | |
| # version the contributor used (recorded in ADX) and hash that. | |
| GEN_NAME=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("generatorName")) as $i | (if $i == null then "" else ($t.Rows[0][$i] // "") end)') | |
| GEN_VERSION=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("generatorVersion")) as $i | (if $i == null then "" else ($t.Rows[0][$i] // "") end)') | |
| cd "${SAMPLE_PATH}" | |
| if [[ ! -f "azuredeploy.json" ]]; then | |
| echo "ERROR: azuredeploy.json not found in '${SAMPLE_PATH}' — cannot compute templateHash." >&2 | |
| exit 1 | |
| fi | |
| JSON_TO_HASH="azuredeploy.json" | |
| USED_PIN=false | |
| RELEASE_TAG="" | |
| if [[ "${JSON_GENERATED}" == "true" && "${GEN_NAME}" == "bicep" && -n "${GEN_VERSION}" ]]; then | |
| # generatorVersion is "X.Y.Z.BUILD"; GitHub release tag is "vX.Y.Z" | |
| if [[ "${GEN_VERSION}" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then | |
| RELEASE_TAG="v${BASH_REMATCH[1]}" | |
| PINNED_BICEP="${RUNNER_TEMP:-/tmp}/bicep-pinned-${RELEASE_TAG}" | |
| echo "" | |
| echo "── Bicep version pinning ──" | |
| echo " ADX generatorVersion : ${GEN_VERSION}" | |
| echo " Resolved release tag : ${RELEASE_TAG}" | |
| if [[ ! -x "${PINNED_BICEP}" ]] && \ | |
| ! curl -fsSLo "${PINNED_BICEP}" "https://downloads.bicep.azure.com/${RELEASE_TAG}/bicep-linux-x64"; then | |
| echo " ⚠️ Could not download Bicep ${RELEASE_TAG} — falling back to runner-compiled JSON for hashing." >&2 | |
| rm -f "${PINNED_BICEP}" | |
| else | |
| chmod +x "${PINNED_BICEP}" | |
| if "${PINNED_BICEP}" build main.bicep --outfile azuredeploy.pinned.json; then | |
| JSON_TO_HASH="azuredeploy.pinned.json" | |
| USED_PIN=true | |
| echo " Recompiled JSON file : azuredeploy.pinned.json" | |
| else | |
| echo " ⚠️ Pinned Bicep ${RELEASE_TAG} failed to build main.bicep — falling back to runner-compiled JSON." >&2 | |
| fi | |
| fi | |
| else | |
| echo " ⚠️ Unrecognized generatorVersion format '${GEN_VERSION}' — skipping pin." >&2 | |
| fi | |
| fi | |
| COMPUTED_HASH=$(az rest --method post \ | |
| --url "https://management.azure.com/providers/Microsoft.Resources/calculateTemplateHash?api-version=2025-04-01" \ | |
| --body @"${JSON_TO_HASH}" | jq -r '.templateHash') | |
| echo " computed hash : ${COMPUTED_HASH}" | |
| if [[ "${USED_PIN}" == "true" ]]; then | |
| echo " → hash computed from pinned-Bicep recompile (azuredeploy.pinned.json)" | |
| fi | |
| # ── Compare results ─────────────────────────────────────────────── | |
| # Compare ADX log hash against the COMPUTED hash | |
| OVERALL_PASS=true | |
| if [[ "${ACTUAL_STATUS}" != "Succeeded" ]]; then | |
| echo "FAIL: executionStatus='${ACTUAL_STATUS}' (expected 'Succeeded') — correlationId='${CORRELATION_ID}' deploymentName='${DEPLOYMENT_NAME}'" >&2 | |
| OVERALL_PASS=false | |
| else | |
| echo "PASS: executionStatus = Succeeded" | |
| fi | |
| if [[ "${ACTUAL_HASH}" != "${COMPUTED_HASH}" ]]; then | |
| echo "FAIL: templateHash mismatch for '${TEMPLATE_FILE_NAME}'" >&2 | |
| echo " computed (from template file) : ${COMPUTED_HASH}" >&2 | |
| echo " actual (from ADX log) : ${ACTUAL_HASH}" >&2 | |
| if [[ "${USED_PIN}" == "true" ]]; then | |
| echo " Note: hash was computed after pinning Bicep to ${RELEASE_TAG}" >&2 | |
| echo " (the version ADX recorded for the deployment). A mismatch here" >&2 | |
| echo " indicates main.bicep has substantively changed since the" >&2 | |
| echo " deployment — please re-deploy and re-record correlationId" >&2 | |
| echo " and deploymentName in metadata.json." >&2 | |
| fi | |
| OVERALL_PASS=false | |
| else | |
| echo "PASS: templateHash matches (computed vs ADX log)" | |
| fi | |
| echo "" | |
| if [[ "${OVERALL_PASS}" != "true" ]]; then | |
| echo "VALIDATION FAILED: deployment did not pass." >&2 | |
| exit 1 | |
| fi | |
| echo "== Deployment validation PASSED ==" | |
| # ── Prereqs A: verify at least one recognized template exists ────────── | |
| - name: Validate prereqs template files | |
| id: check-prereqs | |
| if: steps.preflight.outputs.skip != 'true' | |
| shell: bash | |
| env: | |
| SAMPLE_PATH: ${{ steps.find-metadata.outputs.sample_path }} | |
| run: | | |
| set -euo pipefail | |
| PREREQS_DIR="${SAMPLE_PATH}/prereqs" | |
| if [[ ! -d "${PREREQS_DIR}" ]]; then | |
| echo "No prereqs/ folder found under '${SAMPLE_PATH}' — skipping prereqs validation." | |
| echo "has_prereqs=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "has_prereqs=true" >> "$GITHUB_OUTPUT" | |
| echo "== prereqs/ folder found: ${PREREQS_DIR} ==" | |
| EXPECTED_FILES=("prereq.azuredeploy.json" "prereq.main.bicep" "azuredeploy.json" "main.bicep") | |
| FOUND_FILES=() | |
| for f in "${EXPECTED_FILES[@]}"; do | |
| [[ -f "${PREREQS_DIR}/${f}" ]] && FOUND_FILES+=("$f") | |
| done | |
| if [[ ${#FOUND_FILES[@]} -eq 0 ]]; then | |
| echo "ERROR: No valid template file found in prereqs/ folder." >&2 | |
| echo " Sample folder : ${SAMPLE_PATH}" >&2 | |
| echo " Prereqs folder: ${PREREQS_DIR}" >&2 | |
| echo " Expected one of: ${EXPECTED_FILES[*]}" >&2 | |
| ACTUAL_CONTENTS=$(ls "${PREREQS_DIR}" 2>/dev/null || true) | |
| if [[ -z "${ACTUAL_CONTENTS}" ]]; then | |
| echo " Actual contents: (empty)" >&2 | |
| else | |
| echo " Actual contents:" >&2 | |
| while IFS= read -r entry; do | |
| echo " ${entry}" >&2 | |
| done <<< "${ACTUAL_CONTENTS}" | |
| fi | |
| exit 1 | |
| fi | |
| printf 'Found prereqs template(s): %s\n' "${FOUND_FILES[*]}" | |
| if [[ ${#FOUND_FILES[@]} -gt 1 ]]; then | |
| echo "NOTE: Multiple matching template files found. Deployment selection is external;" | |
| echo " the pipeline relies on metadata.json for validation." | |
| fi | |
| # ── Prereqs: ensure azuredeploy.json exists (compile Bicep if needed) ─── | |
| - name: Prereqs – ensure azuredeploy.json exists (compile Bicep if needed) | |
| id: compile-prereqs | |
| if: steps.preflight.outputs.skip != 'true' && steps.check-prereqs.outputs.has_prereqs == 'true' | |
| shell: bash | |
| env: | |
| SAMPLE_PATH: ${{ steps.find-metadata.outputs.sample_path }} | |
| run: | | |
| set -euo pipefail | |
| PREREQS_DIR="${SAMPLE_PATH}/prereqs" | |
| cd "${PREREQS_DIR}" | |
| echo "──────────────────────────────────────────" | |
| echo " Prereqs folder: ${PREREQS_DIR}" | |
| echo "──────────────────────────────────────────" | |
| # Determine which ARM template file to target | |
| # prereqs may use prereq.azuredeploy.json or azuredeploy.json | |
| ARM_FILE="" | |
| for candidate in prereq.azuredeploy.json azuredeploy.json; do | |
| if [[ -f "${candidate}" ]]; then | |
| ARM_FILE="${candidate}" | |
| break | |
| fi | |
| done | |
| if [[ -n "${ARM_FILE}" ]]; then | |
| echo "${ARM_FILE} already exists — no compilation needed." | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # No ARM file found — try to compile from Bicep | |
| BICEP_FILE="" | |
| for candidate in prereq.main.bicep main.bicep; do | |
| if [[ -f "${candidate}" ]]; then | |
| BICEP_FILE="${candidate}" | |
| break | |
| fi | |
| done | |
| if [[ -z "${BICEP_FILE}" ]]; then | |
| echo "No ARM or Bicep template found in prereqs/ — skipping (validation step will catch this)." | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Derive output name: prereq.main.bicep → prereq.azuredeploy.json, main.bicep → azuredeploy.json | |
| if [[ "${BICEP_FILE}" == prereq.* ]]; then | |
| OUT_FILE="prereq.azuredeploy.json" | |
| else | |
| OUT_FILE="azuredeploy.json" | |
| fi | |
| echo "${OUT_FILE} not found but ${BICEP_FILE} exists — compiling..." | |
| command -v bicep >/dev/null 2>&1 || az bicep install | |
| bicep build "${BICEP_FILE}" --outfile "${OUT_FILE}" | |
| echo "✅ Compiled ${BICEP_FILE} → ${OUT_FILE}" | |
| echo "generated=true" >> "$GITHUB_OUTPUT" | |
| # ── Prereqs B+C: validate metadata entries and run Kusto per entry ────── | |
| - name: Validate prereqs deployments via ADX | |
| if: steps.preflight.outputs.skip != 'true' && steps.check-prereqs.outputs.has_prereqs == 'true' | |
| shell: bash | |
| env: | |
| # Deployment logs may live in any one of these regional ADX clusters. | |
| # Queries are tried in order; the first cluster returning a row wins. | |
| CLUSTER_URLS: | | |
| https://armprodeus.eastus.kusto.windows.net | |
| https://armprodweu.westeurope.kusto.windows.net | |
| https://armprodsea.southeastasia.kusto.windows.net | |
| DATABASE: ${{ secrets.ADX_DATABASE }} | |
| METADATA_FILE: ${{ steps.find-metadata.outputs.metadata_file }} | |
| # Sample folder (where prereqs/ lives) | |
| SAMPLE_PATH: ${{ steps.find-metadata.outputs.sample_path }} | |
| # 'true' if CI compiled the prereqs ARM JSON from Bicep; 'false' if | |
| # the contributor committed it directly. Drives whether we pin Bicep | |
| # before hashing each entry. | |
| JSON_GENERATED: ${{ steps.compile-prereqs.outputs.generated }} | |
| run: | | |
| set -euo pipefail | |
| METADATA_PATH="${METADATA_FILE}" | |
| # ── Validate file exists ────────────────────────────────────────── | |
| if [[ ! -f "${METADATA_PATH}" ]]; then | |
| echo "ERROR: metadata.json not found at '${METADATA_PATH}'" >&2 | |
| exit 1 | |
| fi | |
| echo "== Validating prereqs deployments from: ${METADATA_PATH} ==" | |
| # ── Validate .testResult ────────────────────────────────────────── | |
| TEST_RESULT=$(jq -c '.testResult // empty' "${METADATA_PATH}") | |
| if [[ -z "${TEST_RESULT}" ]]; then | |
| echo "ERROR: '.testResult' not found in '${METADATA_PATH}'" >&2 | |
| echo " metadata file: ${METADATA_PATH}" >&2 | |
| exit 1 | |
| fi | |
| # ── Validate .testResult.deployments is an array ────────────────── | |
| DEPLOYMENTS_TYPE=$(jq -r '(.testResult.deployments // null) | type' "${METADATA_PATH}") | |
| if [[ "${DEPLOYMENTS_TYPE}" != "array" ]]; then | |
| echo "ERROR: '.testResult.deployments' must be an array (got '${DEPLOYMENTS_TYPE}') in '${METADATA_PATH}'" >&2 | |
| echo " metadata file : ${METADATA_PATH}" >&2 | |
| echo " offending value : $(jq -c '.testResult.deployments // "missing"' "${METADATA_PATH}")" >&2 | |
| exit 1 | |
| fi | |
| # ── Filter prereqs entries ──────────────────────────────────────── | |
| PREREQ_DEPLOYMENTS=$(jq -c \ | |
| '[.testResult.deployments[] | select(.templateFileName | startswith("prereqs/"))]' \ | |
| "${METADATA_PATH}") | |
| PREREQ_COUNT=$(echo "${PREREQ_DEPLOYMENTS}" | jq 'length') | |
| if [[ "${PREREQ_COUNT}" -eq 0 ]]; then | |
| echo "ERROR: prereqs/ folder exists but no prereqs entries found in '.testResult.deployments'" >&2 | |
| echo " metadata file: ${METADATA_PATH}" >&2 | |
| echo " (looked for entries where templateFileName starts with 'prereqs/')" >&2 | |
| exit 1 | |
| fi | |
| echo "Found ${PREREQ_COUNT} prereqs deployment entry/entries." | |
| # ── Helper: POST a pre-built JSON body to a Kusto REST endpoint ──── | |
| run_kusto_query () { | |
| local cluster_url="$1" | |
| local body="$2" | |
| az rest \ | |
| --method POST \ | |
| --url "${cluster_url}/v1/rest/query" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "${body}" \ | |
| --resource "https://kusto.kusto.windows.net" | |
| } | |
| # File-based side channel so the matched cluster URL survives the | |
| # command-substitution subshell used to capture the function's stdout. | |
| MATCHED_CLUSTER_FILE="${RUNNER_TEMP:-/tmp}/matched_cluster.$$" | |
| MATCHED_CLUSTER="" | |
| # ── Helper: try each cluster in order; return first response with rows ─ | |
| # Writes the matched cluster URL to MATCHED_CLUSTER_FILE (or empty) so | |
| # the parent shell can read it after $(...) capture. | |
| # If all clusters return 0 rows, the last response is echoed for diagnostics. | |
| run_kusto_query_with_fallback () { | |
| local body="$1" | |
| local response="" row_count=0 cluster="" | |
| : > "${MATCHED_CLUSTER_FILE}" | |
| while IFS= read -r cluster; do | |
| [[ -z "${cluster}" ]] && continue | |
| echo " → trying cluster: ${cluster}" >&2 | |
| response=$(run_kusto_query "${cluster}" "${body}") | |
| row_count=$(echo "${response}" | jq '(.Tables[0].Rows // []) | length') | |
| if [[ "${row_count}" -gt 0 ]]; then | |
| echo " matched (${row_count} row(s)) on ${cluster}" >&2 | |
| printf '%s' "${cluster}" > "${MATCHED_CLUSTER_FILE}" | |
| echo "${response}" | |
| return 0 | |
| fi | |
| echo " 0 rows on ${cluster}" >&2 | |
| done <<< "${CLUSTER_URLS}" | |
| echo "${response}" | |
| return 0 | |
| } | |
| # ── Source the shared KQL-input validator ───────────────────────── | |
| # Defines validate_kusto_inputs (cid, dn, label) — see | |
| # .github/scripts/validate-kusto-inputs.sh for the format contract. | |
| # Tested by test/scripts/validate-kusto-inputs.bats. | |
| # Security context: MSRC 117973 / ICM 31000000613310. | |
| # shellcheck disable=SC1091 | |
| source "${GITHUB_WORKSPACE}/.github/scripts/validate-kusto-inputs.sh" | |
| OVERALL_PASS=true | |
| PREREQS_DIR="${SAMPLE_PATH}/prereqs" | |
| for i in $(seq 0 $((PREREQ_COUNT - 1))); do | |
| ENTRY=$(echo "${PREREQ_DEPLOYMENTS}" | jq -c ".[$i]") | |
| ENTRY_LABEL="$((i + 1))/${PREREQ_COUNT}" | |
| # Required fields: templateFileName, correlationId, deploymentName | |
| # Optional fields: TIMESTAMP (informational), templateHash (ignored) | |
| TEMPLATE_FILE_NAME=$(echo "${ENTRY}" | jq -r '.templateFileName // empty') | |
| CORRELATION_ID=$(echo "${ENTRY}" | jq -r '.correlationId // empty') | |
| DEPLOYMENT_NAME=$(echo "${ENTRY}" | jq -r '.deploymentName // empty') | |
| ENTRY_TIMESTAMP=$(echo "${ENTRY}" | jq -r '.TIMESTAMP // "N/A"') | |
| echo "" | |
| echo "================================================================" | |
| echo " [prereqs entry ${ENTRY_LABEL}]" | |
| echo " Template file : ${TEMPLATE_FILE_NAME:-<missing>}" | |
| echo " correlationId : ${CORRELATION_ID:-<missing>}" | |
| echo " deploymentName : ${DEPLOYMENT_NAME:-<missing>}" | |
| echo " timestamp : ${ENTRY_TIMESTAMP} (optional)" | |
| echo "================================================================" | |
| # ── Validate required fields only ───────────────────────────── | |
| # Only templateFileName, correlationId, and deploymentName are required. | |
| ENTRY_VALID=true | |
| for FIELD_NAME in templateFileName correlationId deploymentName; do | |
| FIELD_VAL=$(echo "${ENTRY}" | jq -r --arg f "${FIELD_NAME}" '.[$f] // empty') | |
| if [[ -z "${FIELD_VAL}" ]]; then | |
| echo "ERROR [prereqs ${ENTRY_LABEL}]: '${FIELD_NAME}' is missing or empty" >&2 | |
| echo " metadata file : ${METADATA_PATH}" >&2 | |
| echo " offending entry : ${ENTRY}" >&2 | |
| echo " Required fields: templateFileName, correlationId, deploymentName" >&2 | |
| ENTRY_VALID=false | |
| fi | |
| done | |
| if [[ "${ENTRY_VALID}" != "true" ]]; then | |
| OVERALL_PASS=false | |
| continue | |
| fi | |
| # ── Resolve the on-disk ARM JSON file for this entry ────────── | |
| # templateFileName is e.g. "prereqs/main.bicep" or "prereqs/prereq.azuredeploy.json" | |
| BASENAME=$(basename "${TEMPLATE_FILE_NAME}") | |
| case "${BASENAME}" in | |
| prereq.main.bicep) ARM_FILE="${PREREQS_DIR}/prereq.azuredeploy.json"; SRC_BICEP="${PREREQS_DIR}/prereq.main.bicep" ;; | |
| main.bicep) ARM_FILE="${PREREQS_DIR}/azuredeploy.json"; SRC_BICEP="${PREREQS_DIR}/main.bicep" ;; | |
| prereq.azuredeploy.json) ARM_FILE="${PREREQS_DIR}/prereq.azuredeploy.json"; SRC_BICEP="" ;; | |
| azuredeploy.json) ARM_FILE="${PREREQS_DIR}/azuredeploy.json"; SRC_BICEP="" ;; | |
| *) ARM_FILE="${PREREQS_DIR}/${BASENAME}"; SRC_BICEP="" ;; | |
| esac | |
| if [[ ! -f "${ARM_FILE}" ]]; then | |
| echo "ERROR [prereqs ${ENTRY_LABEL}]: ARM template '${ARM_FILE}' not found (needed for hash computation)." >&2 | |
| OVERALL_PASS=false | |
| continue | |
| fi | |
| # ── Validate untrusted metadata before binding into KQL ─────── | |
| # correlationId / deploymentName come from PR-controlled | |
| # metadata.json. Reject anything that does not match the strict | |
| # format contract before we hand the values to Kusto. | |
| if ! validate_kusto_inputs "${CORRELATION_ID}" "${DEPLOYMENT_NAME}" "prereqs ${ENTRY_LABEL} (${TEMPLATE_FILE_NAME})"; then | |
| OVERALL_PASS=false | |
| continue | |
| fi | |
| # ── Run Kusto query ─────────────────────────────────────────── | |
| # Body shape (parameterized KQL) is built by the shared script — | |
| # see .github/scripts/build-adx-query-body.sh, tested by | |
| # test/scripts/build-adx-query-body.bats. | |
| REQUEST_BODY=$(DATABASE="${DATABASE}" CID="${CORRELATION_ID}" DN="${DEPLOYMENT_NAME}" \ | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/build-adx-query-body.sh") | |
| echo "Running Kusto query for prereqs entry '${TEMPLATE_FILE_NAME}' (with cluster fallback)..." | |
| RESPONSE=$(run_kusto_query_with_fallback "${REQUEST_BODY}") | |
| MATCHED_CLUSTER=$(cat "${MATCHED_CLUSTER_FILE}" 2>/dev/null || true) | |
| ROW_COUNT=$(echo "${RESPONSE}" | jq '(.Tables[0].Rows // []) | length') | |
| if [[ "${ROW_COUNT}" -eq 0 ]]; then | |
| echo "ERROR [prereqs ${ENTRY_LABEL}]: No ADX record found in any cluster" >&2 | |
| echo " templateFileName : ${TEMPLATE_FILE_NAME}" >&2 | |
| echo " correlationId : ${CORRELATION_ID}" >&2 | |
| echo " deploymentName : ${DEPLOYMENT_NAME}" >&2 | |
| echo " Kusto parameters : correlationId='${CORRELATION_ID}' deploymentName='${DEPLOYMENT_NAME}'" >&2 | |
| echo " clusters tried (in order):" >&2 | |
| while IFS= read -r c; do [[ -n "$c" ]] && echo " - $c" >&2; done <<< "${CLUSTER_URLS}" | |
| OVERALL_PASS=false | |
| continue | |
| fi | |
| echo " matched cluster : ${MATCHED_CLUSTER}" | |
| # Extract field values by column name (robust against column-order changes) | |
| ACTUAL_STATUS=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("executionStatus")) as $i | $t.Rows[0][$i]') | |
| ACTUAL_HASH=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("templateHash")) as $i | $t.Rows[0][$i]') | |
| echo " actual status : ${ACTUAL_STATUS}" | |
| echo " actual hash : ${ACTUAL_HASH}" | |
| # ── Compute templateHash (with optional Bicep version pinning) ── | |
| # When CI compiled the prereq ARM JSON from Bicep, the runner's | |
| # bundled Bicep may differ from the contributor's local Bicep. | |
| # Re-compile here using the same Bicep version the contributor used | |
| # (recorded in ADX) so the hash comparison is version-invariant. | |
| GEN_NAME=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("generatorName")) as $i | (if $i == null then "" else ($t.Rows[0][$i] // "") end)') | |
| GEN_VERSION=$(echo "${RESPONSE}" | jq -r \ | |
| '.Tables[0] as $t | ($t.Columns | map(.ColumnName) | index("generatorVersion")) as $i | (if $i == null then "" else ($t.Rows[0][$i] // "") end)') | |
| JSON_TO_HASH="${ARM_FILE}" | |
| USED_PIN=false | |
| RELEASE_TAG="" | |
| if [[ "${JSON_GENERATED}" == "true" && "${GEN_NAME}" == "bicep" && -n "${GEN_VERSION}" && -n "${SRC_BICEP}" && -f "${SRC_BICEP}" ]]; then | |
| if [[ "${GEN_VERSION}" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then | |
| RELEASE_TAG="v${BASH_REMATCH[1]}" | |
| PINNED_BICEP="${RUNNER_TEMP:-/tmp}/bicep-pinned-${RELEASE_TAG}" | |
| PINNED_OUT="${ARM_FILE%.json}.pinned.json" | |
| echo "" | |
| echo " ── Bicep version pinning ──" | |
| echo " ADX generatorVersion : ${GEN_VERSION}" | |
| echo " Resolved release tag : ${RELEASE_TAG}" | |
| if [[ ! -x "${PINNED_BICEP}" ]] && \ | |
| ! curl -fsSLo "${PINNED_BICEP}" "https://downloads.bicep.azure.com/${RELEASE_TAG}/bicep-linux-x64"; then | |
| echo " ⚠️ Could not download Bicep ${RELEASE_TAG} — falling back to runner-compiled JSON for hashing." >&2 | |
| rm -f "${PINNED_BICEP}" | |
| else | |
| chmod +x "${PINNED_BICEP}" | |
| if "${PINNED_BICEP}" build "${SRC_BICEP}" --outfile "${PINNED_OUT}"; then | |
| JSON_TO_HASH="${PINNED_OUT}" | |
| USED_PIN=true | |
| echo " Recompiled JSON file : ${PINNED_OUT}" | |
| else | |
| echo " ⚠️ Pinned Bicep ${RELEASE_TAG} failed to build ${SRC_BICEP} — falling back to runner-compiled JSON." >&2 | |
| fi | |
| fi | |
| else | |
| echo " ⚠️ Unrecognized generatorVersion format '${GEN_VERSION}' — skipping pin." >&2 | |
| fi | |
| fi | |
| COMPUTED_HASH=$(az rest --method post \ | |
| --url "https://management.azure.com/providers/Microsoft.Resources/calculateTemplateHash?api-version=2025-04-01" \ | |
| --body @"${JSON_TO_HASH}" | jq -r '.templateHash') | |
| echo " computed hash : ${COMPUTED_HASH}" | |
| if [[ "${USED_PIN}" == "true" ]]; then | |
| echo " → hash computed from pinned-Bicep recompile (${PINNED_OUT})" | |
| fi | |
| # ── Compare results ─────────────────────────────────────────── | |
| ENTRY_PASS=true | |
| if [[ "${ACTUAL_STATUS}" != "Succeeded" ]]; then | |
| echo "FAIL [prereqs ${ENTRY_LABEL}]: executionStatus='${ACTUAL_STATUS}' (expected 'Succeeded')" >&2 | |
| echo " templateFileName : ${TEMPLATE_FILE_NAME}" >&2 | |
| echo " correlationId : ${CORRELATION_ID}" >&2 | |
| echo " deploymentName : ${DEPLOYMENT_NAME}" >&2 | |
| echo " Kusto parameters : correlationId='${CORRELATION_ID}' deploymentName='${DEPLOYMENT_NAME}'" >&2 | |
| ENTRY_PASS=false | |
| else | |
| echo "PASS: executionStatus = Succeeded" | |
| fi | |
| if [[ "${ACTUAL_HASH}" != "${COMPUTED_HASH}" ]]; then | |
| echo "FAIL [prereqs ${ENTRY_LABEL}]: templateHash mismatch" >&2 | |
| echo " templateFileName : ${TEMPLATE_FILE_NAME}" >&2 | |
| echo " computed (from template file) : ${COMPUTED_HASH}" >&2 | |
| echo " actual (from ADX log) : ${ACTUAL_HASH}" >&2 | |
| echo " correlationId : ${CORRELATION_ID}" >&2 | |
| echo " deploymentName : ${DEPLOYMENT_NAME}" >&2 | |
| if [[ "${USED_PIN}" == "true" ]]; then | |
| echo " Note: hash was computed after pinning Bicep to ${RELEASE_TAG}" >&2 | |
| echo " (the version ADX recorded for the deployment). A mismatch here" >&2 | |
| echo " indicates ${SRC_BICEP##*/} has substantively changed since the" >&2 | |
| echo " deployment — please re-deploy and re-record correlationId" >&2 | |
| echo " and deploymentName in metadata.json." >&2 | |
| fi | |
| ENTRY_PASS=false | |
| else | |
| echo "PASS: templateHash matches (computed vs ADX log)" | |
| fi | |
| if [[ "${ENTRY_PASS}" != "true" ]]; then | |
| OVERALL_PASS=false | |
| fi | |
| done | |
| echo "" | |
| if [[ "${OVERALL_PASS}" != "true" ]]; then | |
| echo "PREREQS VALIDATION FAILED: one or more prereqs deployments did not pass." >&2 | |
| exit 1 | |
| fi | |
| echo "== Prereqs deployment validation PASSED ==" | |
| # ── Stage generated azuredeploy.json into a workspace-rooted tree ─────── | |
| # actions/upload-artifact@v4 rebases uploaded files to the *least common | |
| # ancestor* of the matched paths. For a typical sample with no prereqs/ | |
| # folder only one file matches (<sample>/azuredeploy.json) and the LCA | |
| # collapses to the file itself, so the artifact would contain a bare | |
| # `azuredeploy.json` at its root — which the merge job would then | |
| # extract into the repository root instead of the sample folder. | |
| # | |
| # To preserve the full repo-relative path, copy the generated files | |
| # into ${RUNNER_TEMP}/generated/<sample_path>/... and upload that | |
| # directory. The merge job extracts it into the checked-out default | |
| # branch with `path: .`, so the files land in the correct sample | |
| # folders. | |
| - name: Stage generated azuredeploy.json files for artifact upload | |
| id: stage-generated | |
| if: >- | |
| steps.preflight.outputs.skip != 'true' && | |
| (steps.compile-main.outputs.generated == 'true' || | |
| steps.compile-prereqs.outputs.generated == 'true') | |
| shell: bash | |
| env: | |
| SAMPLE_PATH: ${{ steps.find-metadata.outputs.sample_path }} | |
| run: | | |
| set -euo pipefail | |
| STAGE_DIR="${RUNNER_TEMP}/generated" | |
| DEST_DIR="${STAGE_DIR}/${SAMPLE_PATH}" | |
| mkdir -p "${DEST_DIR}/prereqs" | |
| copied=0 | |
| for rel in azuredeploy.json prereqs/azuredeploy.json prereqs/prereq.azuredeploy.json; do | |
| src="${SAMPLE_PATH}/${rel}" | |
| if [[ -f "${src}" ]]; then | |
| cp "${src}" "${DEST_DIR}/${rel}" | |
| echo "Staged ${src} → ${DEST_DIR}/${rel}" | |
| copied=$((copied + 1)) | |
| fi | |
| done | |
| # Drop the empty prereqs/ dir if nothing landed there, so the | |
| # artifact tree mirrors only files that actually exist. | |
| rmdir "${DEST_DIR}/prereqs" 2>/dev/null || true | |
| echo "stage_dir=${STAGE_DIR}" >> "$GITHUB_OUTPUT" | |
| echo "copied=${copied}" >> "$GITHUB_OUTPUT" | |
| # ── Upload generated azuredeploy.json artifact ────────────────────────── | |
| # If either compile step actually generated a file (and templateHash | |
| # validation succeeded above), upload it as a workflow artifact keyed by | |
| # PR number + head SHA. The merge-time `commit-generated-on-merge` job | |
| # downloads this artifact and commits the JSON to master, so the merged | |
| # commit contains the generated ARM template. | |
| # | |
| # Uploading the staged directory (rather than the original files | |
| # directly) preserves the repo-relative paths end-to-end — see the | |
| # stage-generated step above for the rationale. | |
| - name: Upload generated azuredeploy.json artifact | |
| if: >- | |
| steps.preflight.outputs.skip != 'true' && | |
| steps.stage-generated.outputs.copied != '0' && | |
| (steps.compile-main.outputs.generated == 'true' || | |
| steps.compile-prereqs.outputs.generated == 'true') | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: generated-azuredeploy-${{ steps.pr.outputs.number }}-${{ steps.pr.outputs.head_sha }} | |
| path: ${{ steps.stage-generated.outputs.stage_dir }} | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| include-hidden-files: false | |
| # ── Path A: push generated JSON to the PR's head branch ───────────────── | |
| # When the PR head is in this same repo (i.e. the contributor pushed | |
| # their branch directly to Azure/azure-quickstart-templates rather than | |
| # opening from a fork), GITHUB_TOKEN with `contents: write` can push to | |
| # the head branch. Doing so here means the generated azuredeploy.json | |
| # appears in the PR's file list and is naturally carried into master by | |
| # the merge — sidestepping the protected-branch push that previously | |
| # failed in `commit-generated-on-merge`. | |
| # | |
| # For fork PRs (head_repo != github.repository) GITHUB_TOKEN cannot push | |
| # to the fork — even with "Allow edits by maintainers" enabled, that | |
| # setting only grants human maintainers push access, not the Actions | |
| # token. Those PRs are handled by the post-merge auto-PR fallback in | |
| # `commit-generated-on-merge`. | |
| # | |
| # The step is idempotent: if the PR's head already contains the | |
| # generated JSON (e.g. /validate run a second time with no changes), the | |
| # diff check exits cleanly without producing an empty commit. | |
| - name: Commit generated azuredeploy.json to PR head branch | |
| id: push-to-pr-head | |
| if: >- | |
| steps.preflight.outputs.skip != 'true' && | |
| steps.stage-generated.outputs.copied != '0' && | |
| steps.pr.outputs.head_repo == github.repository && | |
| (steps.compile-main.outputs.generated == 'true' || | |
| steps.compile-prereqs.outputs.generated == 'true') | |
| shell: bash | |
| env: | |
| HEAD_REF: ${{ steps.pr.outputs.head_ref }} | |
| PR_NUM: ${{ steps.pr.outputs.number }} | |
| STAGE_DIR: ${{ steps.stage-generated.outputs.stage_dir }} | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| # Use a sibling working tree to avoid mutating the read-only | |
| # refs/pull/<N>/head checkout the validation steps used. | |
| WORKDIR="${RUNNER_TEMP}/pr-head-checkout" | |
| rm -rf "${WORKDIR}" | |
| git clone --depth 1 --branch "${HEAD_REF}" \ | |
| "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" \ | |
| "${WORKDIR}" | |
| cd "${WORKDIR}" | |
| git config user.email "azure-quickstart-templates@noreply.github.com" | |
| git config user.name "azure-quickstart-templates Automation" | |
| # Overlay the staged generated files onto the working tree. STAGE_DIR | |
| # mirrors the repo-relative paths (see `stage-generated` step). | |
| cp -R "${STAGE_DIR}/." . | |
| # Stage only generated ARM JSON files anywhere in the tree, then | |
| # unstage any that may have landed at the repo root (defense in | |
| # depth — none of those files legitimately live there). | |
| git add -A -- '**/azuredeploy.json' '**/prereq.azuredeploy.json' || true | |
| git reset -q -- azuredeploy.json prereq.azuredeploy.json 2>/dev/null || true | |
| if git diff --cached --quiet; then | |
| echo "::notice::PR head branch already contains the generated JSON — nothing to push." | |
| exit 0 | |
| fi | |
| echo "Files staged for commit:" | |
| git diff --cached --name-only | sed 's/^/ /' | |
| git commit -m "Auto-add generated azuredeploy.json (validation run for PR #${PR_NUM})" | |
| # Push with one rebase-retry to tolerate a concurrent push to the | |
| # PR branch. Workflow concurrency (validate-pr-${PR_NUM}) already | |
| # serializes our own runs, so retries here only cover external | |
| # pushes by the contributor. | |
| for attempt in 1 2; do | |
| if git push origin "HEAD:${HEAD_REF}"; then | |
| echo "✅ Pushed generated azuredeploy.json to ${HEAD_REF}" | |
| exit 0 | |
| fi | |
| echo "Push attempt ${attempt} rejected — pulling --rebase and retrying..." | |
| git pull --rebase origin "${HEAD_REF}" | |
| done | |
| echo "ERROR: Failed to push generated azuredeploy.json to PR head branch." >&2 | |
| exit 1 | |
| # ============================================================================ | |
| # JOB 4 – report-check | |
| # | |
| # Always-runs final job that publishes the `adx-deployment-validation` GitHub | |
| # Check Run on the PR head SHA. The repository ruleset on master requires | |
| # this check, so the conclusion written here is what gates merge: | |
| # | |
| # gate.result == 'failure' -> check failure | |
| # ("validate-samples.yml did not pass; fix and re-run /validate") | |
| # | |
| # selected-pipeline.result == 'success' AND skip='true' -> check success | |
| # ("Auto-passed: no deployment-affecting changes") | |
| # | |
| # selected-pipeline.result == 'success' -> check success | |
| # ("ADX deployment validation passed") | |
| # | |
| # selected-pipeline.result == 'failure' -> check failure | |
| # (link to this run for details) | |
| # | |
| # selected-pipeline.result == 'cancelled' -> check failure | |
| # ("Validation cancelled; re-run /validate") | |
| # | |
| # selected-pipeline.result == 'skipped' AND | |
| # gate.result == 'success' -> check failure | |
| # ("ADX validation did not run; comment /validate again") | |
| # | |
| # Pushing a new commit to the PR moves the head SHA. No check is published | |
| # on the new SHA until /validate is re-run, so the ruleset blocks merge — | |
| # this is the stale-on-push behavior, with no seed workflow required. | |
| # ============================================================================ | |
| report-check: | |
| name: Report adx-deployment-validation check | |
| runs-on: ubuntu-latest | |
| needs: [gate, summarize, selected-pipeline] | |
| if: >- | |
| always() && | |
| github.event.issue.pull_request && | |
| startsWith(github.event.comment.body, '/validate') | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| checks: write | |
| steps: | |
| # Sparse checkout just the helper script directory. | |
| - name: Checkout helper scripts | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| sparse-checkout: | | |
| .github/scripts | |
| sparse-checkout-cone-mode: false | |
| # The gate job exposes head_sha as an output, but only if it ran far | |
| # enough to resolve it. Resolve here independently so this step works | |
| # even if gate failed before its first step (or never ran). | |
| - name: Resolve PR HEAD SHA | |
| id: pr-head | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.issue.number }} | |
| GATE_SHA: ${{ needs.gate.outputs.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "${GATE_SHA}" ]]; then | |
| HEAD_SHA="${GATE_SHA}" | |
| else | |
| HEAD_SHA=$(gh api "/repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha') | |
| fi | |
| if [[ -z "${HEAD_SHA}" ]]; then | |
| echo "ERROR: could not resolve PR head SHA" >&2 | |
| exit 1 | |
| fi | |
| echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" | |
| - name: Determine final check conclusion | |
| id: outcome | |
| shell: bash | |
| env: | |
| GATE_RESULT: ${{ needs.gate.result }} | |
| PIPELINE_RESULT: ${{ needs.selected-pipeline.result }} | |
| PIPELINE_SKIP: ${{ needs.selected-pipeline.outputs.skip }} | |
| PIPELINE_COUNT: ${{ needs.selected-pipeline.outputs.sample_count }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| echo "gate.result=${GATE_RESULT}" | |
| echo "selected-pipeline.result=${PIPELINE_RESULT}" | |
| echo "selected-pipeline.outputs.skip=${PIPELINE_SKIP}" | |
| echo "selected-pipeline.outputs.sample_count=${PIPELINE_COUNT}" | |
| conclusion="failure" | |
| title="" | |
| summary="" | |
| if [[ "${GATE_RESULT}" == "failure" ]]; then | |
| conclusion="failure" | |
| title="validate-samples.yml did not pass for this commit" | |
| summary=$'`validate-samples.yml` did not reach `success` for this PR head SHA, or timed out waiting (10-minute poll). Fix the failures, push a new commit, then comment `/validate` again.\n\nRun: '"${RUN_URL}" | |
| elif [[ "${GATE_RESULT}" == "cancelled" ]]; then | |
| conclusion="failure" | |
| title="Validation was cancelled" | |
| summary=$'The gate job was cancelled (likely a newer `/validate` superseded this one). Re-run `/validate` to publish a fresh check.\n\nRun: '"${RUN_URL}" | |
| elif [[ "${PIPELINE_RESULT}" == "success" && "${PIPELINE_SKIP}" == "true" ]]; then | |
| conclusion="success" | |
| title="Auto-passed: no deployment-affecting changes" | |
| summary=$'No files in this PR affect an actual deployment (only docs / images / metadata / non-sample changes), so ADX validation was skipped. The required check passes automatically.\n\nRun: '"${RUN_URL}" | |
| elif [[ "${PIPELINE_RESULT}" == "success" ]]; then | |
| conclusion="success" | |
| title="ADX deployment validation passed" | |
| summary=$'ADX confirmed a successful deployment of this sample with a matching `templateHash`.\n\nRun: '"${RUN_URL}" | |
| elif [[ "${PIPELINE_RESULT}" == "failure" ]]; then | |
| conclusion="failure" | |
| title="ADX deployment validation failed" | |
| if [[ -n "${PIPELINE_COUNT}" && "${PIPELINE_COUNT}" -gt 1 ]]; then | |
| summary=$'This PR touches '"${PIPELINE_COUNT}"$' sample folders. The ADX validation pipeline only supports a single changed sample per PR. Please split deployable changes across multiple PRs (one sample per PR).\n\nRun: '"${RUN_URL}" | |
| else | |
| summary=$'ADX could not confirm a successful deployment for this commit. See the workflow run for the failing check.\n\nRun: '"${RUN_URL}" | |
| fi | |
| elif [[ "${PIPELINE_RESULT}" == "cancelled" ]]; then | |
| conclusion="failure" | |
| title="ADX deployment validation was cancelled" | |
| summary=$'Validation was cancelled mid-run. Re-run `/validate` once any in-progress runs finish.\n\nRun: '"${RUN_URL}" | |
| elif [[ "${PIPELINE_RESULT}" == "skipped" && "${GATE_RESULT}" == "success" ]]; then | |
| conclusion="failure" | |
| title="ADX deployment validation did not run" | |
| summary=$'The ADX validation job was skipped despite the gate succeeding. This usually means the commenter is not MEMBER/OWNER/COLLABORATOR. Comment `/validate` again from a maintainer account.\n\nRun: '"${RUN_URL}" | |
| else | |
| conclusion="failure" | |
| title="Validation did not complete" | |
| summary=$'gate='"${GATE_RESULT}"$', selected-pipeline='"${PIPELINE_RESULT}"$'. Re-run `/validate` to publish a fresh check.\n\nRun: '"${RUN_URL}" | |
| fi | |
| echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "title<<__EOF__" | |
| echo "${title}" | |
| echo "__EOF__" | |
| echo "summary<<__EOF__" | |
| echo "${summary}" | |
| echo "__EOF__" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Upsert adx-deployment-validation check | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| CHECK_NAME: adx-deployment-validation | |
| HEAD_SHA: ${{ steps.pr-head.outputs.head_sha }} | |
| STATUS: completed | |
| CONCLUSION: ${{ steps.outcome.outputs.conclusion }} | |
| TITLE: ${{ steps.outcome.outputs.title }} | |
| SUMMARY: ${{ steps.outcome.outputs.summary }} | |
| DETAILS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: bash .github/scripts/upsert-check-run.sh | |
| # ============================================================================ | |
| # JOB 5 – commit-generated-on-merge | |
| # | |
| # Triggered by `pull_request: closed` (the second `on:` trigger). When a PR | |
| # is merged into the default branch AND the ADX validation succeeded for | |
| # that PR head SHA, this job downloads the `generated-azuredeploy-*` | |
| # artifact uploaded by the `selected-pipeline` job during /validate. | |
| # | |
| # Two delivery paths cooperate to land the generated ARM JSON on the default | |
| # branch: | |
| # | |
| # Path A — same-repo PRs (head.repo == github.repository): | |
| # The `selected-pipeline` job already pushed the generated JSON onto | |
| # the PR's head branch during /validate, so the merge naturally carries | |
| # it into the default branch. By the time this job runs, the merged | |
| # tree already contains the file and the `git diff --cached --quiet` | |
| # check below exits cleanly with no auto-PR created. | |
| # | |
| # Path B — fork PRs (head.repo != github.repository): | |
| # GITHUB_TOKEN cannot push to forks (the "Allow edits by maintainers" | |
| # setting only grants human maintainers push access, not Actions | |
| # tokens), so path A is impossible. Instead, this job commits the | |
| # generated JSON onto an unprotected `auto/generated-azuredeploy-pr-N` | |
| # branch and opens a follow-up PR for a maintainer to merge. This | |
| # avoids needing branch-protection bypass on the default branch for | |
| # github-actions[bot]. | |
| # | |
| # Both paths reuse the JSON that /validate already validated — no second | |
| # Bicep build is performed — so downstream tooling | |
| # (raw.githubusercontent.com links, deployment buttons) eventually sees a | |
| # JSON file that matches the merged main.bicep. | |
| # | |
| # Trust boundary: maintainer review of the `main.bicep` change before merge | |
| # (path A) and maintainer review of the auto-PR before merge (path B). | |
| # ============================================================================ | |
| commit-generated-on-merge: | |
| name: Commit generated azuredeploy.json on merge | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event_name == 'pull_request_target' && | |
| github.event.action == 'closed' && | |
| github.event.pull_request.merged == true && | |
| github.event.pull_request.base.ref == github.event.repository.default_branch | |
| permissions: | |
| contents: write | |
| actions: read | |
| pull-requests: write | |
| steps: | |
| # ── 1. Verify ADX validation succeeded for the merged PR head SHA ────── | |
| - name: Verify adx-deployment-validation succeeded | |
| id: verify | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| echo "Looking up adx-deployment-validation for ${HEAD_SHA}" | |
| CONCLUSION=$(gh api \ | |
| "/repos/${REPO}/commits/${HEAD_SHA}/check-runs?check_name=adx-deployment-validation" \ | |
| --jq '.check_runs | sort_by(.completed_at) | last | .conclusion // ""') | |
| echo "adx-deployment-validation conclusion: '${CONCLUSION:-<none>}'" | |
| if [[ "${CONCLUSION}" != "success" ]]; then | |
| echo "::notice::Skipping commit-back: adx-deployment-validation did not succeed for PR head SHA." | |
| echo "should_commit=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "should_commit=true" >> "$GITHUB_OUTPUT" | |
| # ── 2. Locate the artifact by name (and derive its source run) ───────── | |
| # The artifact name encodes both the PR number and the PR head SHA, so it | |
| # uniquely identifies what we want. We deliberately do NOT search the | |
| # workflow_runs API by `head_sha=${PR_HEAD_SHA}&event=issue_comment` | |
| # because issue_comment-triggered runs are registered against the | |
| # default-branch HEAD at comment time, not against the PR head — and | |
| # their `pull_requests[]` array is populated by an unrelated SHA | |
| # heuristic that does not include the actual triggering PR. See | |
| # Azure/azure-quickstart-templates#14765 for the post-mortem. | |
| # | |
| # Step 1 (`verify`) already confirmed `adx-deployment-validation` | |
| # concluded `success` for this exact PR head SHA, which guarantees the | |
| # validation job that produced the artifact succeeded — so the bare | |
| # presence of a non-expired artifact with the expected name is | |
| # sufficient proof of a usable upload. We pull `workflow_run.id` out of | |
| # the artifact's own metadata to feed the cross-run download below. | |
| - name: Locate generated-azuredeploy artifact | |
| id: locate-artifact | |
| if: steps.verify.outputs.should_commit == 'true' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUM: ${{ github.event.pull_request.number }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| EXPECTED="generated-azuredeploy-${PR_NUM}-${HEAD_SHA}" | |
| RESPONSE=$(gh api \ | |
| "/repos/${REPO}/actions/artifacts?name=${EXPECTED}&per_page=100") | |
| ARTIFACT=$(echo "${RESPONSE}" | jq -c \ | |
| '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last // empty') | |
| if [[ -z "${ARTIFACT}" || "${ARTIFACT}" == "null" ]]; then | |
| echo "::notice::Artifact '${EXPECTED}' not found (or all matching artifacts expired) — sample had no generated JSON, nothing to commit." | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| NAME=$(echo "${ARTIFACT}" | jq -r '.name') | |
| RUN_ID=$(echo "${ARTIFACT}" | jq -r '.workflow_run.id') | |
| if [[ -z "${RUN_ID}" || "${RUN_ID}" == "null" ]]; then | |
| echo "::error::Artifact '${EXPECTED}' has no workflow_run.id — cannot download cross-run." >&2 | |
| exit 1 | |
| fi | |
| echo "Artifact: ${NAME} (run ${RUN_ID})" | |
| echo "name=${NAME}" >> "$GITHUB_OUTPUT" | |
| echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" | |
| echo "found=true" >> "$GITHUB_OUTPUT" | |
| # ── 3. Checkout the default branch with push credentials ─────────────── | |
| - name: Checkout default branch | |
| if: steps.locate-artifact.outputs.found == 'true' | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| token: ${{ github.token }} | |
| persist-credentials: true | |
| fetch-depth: 1 | |
| # ── 4. Download the artifact into the working tree ───────────────────── | |
| - name: Download generated azuredeploy.json | |
| if: steps.locate-artifact.outputs.found == 'true' | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: ${{ steps.locate-artifact.outputs.name }} | |
| path: . | |
| github-token: ${{ github.token }} | |
| repository: ${{ github.repository }} | |
| run-id: ${{ steps.locate-artifact.outputs.run_id }} | |
| # ── 5. Commit to a bot branch and open an auto-PR (if anything changed) ── | |
| # Background: master is protected and GITHUB_TOKEN (acting as | |
| # github-actions[bot]) is not on the bypass list, so a direct push to | |
| # master 403s. Instead, we push to an unprotected `auto/...` branch and | |
| # open a PR for a maintainer to merge. | |
| # | |
| # For same-repo PRs the path-A step in `selected-pipeline` will already | |
| # have pushed the generated JSON onto the PR's head branch, so the | |
| # merged tree matches and the diff check below exits cleanly with no | |
| # auto-PR created. The auto-PR path is therefore primarily exercised | |
| # for fork PRs (where path A is impossible because GITHUB_TOKEN cannot | |
| # push to forks), and as a safety net if path A was skipped/failed. | |
| - name: Commit generated azuredeploy.json and open auto-PR | |
| if: steps.locate-artifact.outputs.found == 'true' | |
| shell: bash | |
| env: | |
| PR_NUM: ${{ github.event.pull_request.number }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| git config user.email "azure-quickstart-templates@noreply.github.com" | |
| git config user.name "azure-quickstart-templates Automation" | |
| # Stage only generated ARM JSON files anywhere in the tree. The | |
| # artifact only ever contains these three filenames, so the glob | |
| # is safe and cannot pick up unrelated changes. | |
| git add -A -- '**/azuredeploy.json' '**/prereq.azuredeploy.json' || true | |
| # Safety net: a buggy artifact (e.g. one whose paths got collapsed | |
| # to the artifact root) could land a bare azuredeploy.json or | |
| # prereq.azuredeploy.json at the repository root. Those two files | |
| # never legitimately live at the repo root, so unstage them if | |
| # they somehow got picked up. | |
| git reset -q -- azuredeploy.json prereq.azuredeploy.json 2>/dev/null || true | |
| if git diff --cached --quiet; then | |
| echo "No changes to commit — generated JSON already matches the merged tree (path A handled it, or artifact already in tree)." | |
| exit 0 | |
| fi | |
| echo "Files staged for commit:" | |
| git diff --cached --name-only | sed 's/^/ /' | |
| BRANCH="auto/generated-azuredeploy-pr-${PR_NUM}" | |
| # If a stale auto-PR branch from a previous run still exists, | |
| # overwrite it so we don't accumulate orphaned branches and so the | |
| # existing PR (if any) gets refreshed with the latest generated JSON. | |
| git checkout -B "${BRANCH}" | |
| git commit -m "Auto-add generated azuredeploy.json from PR #${PR_NUM}" | |
| git push --force-with-lease origin "${BRANCH}" | |
| # Reuse an existing open auto-PR for the same source PR if present; | |
| # otherwise create a new one. `gh pr list --head` matches by branch | |
| # name (head ref) within this repo. | |
| EXISTING_PR=$(gh pr list \ | |
| --repo "${REPO}" \ | |
| --head "${BRANCH}" \ | |
| --base "${DEFAULT_BRANCH}" \ | |
| --state open \ | |
| --json number \ | |
| --jq '.[0].number // empty') | |
| if [[ -n "${EXISTING_PR}" ]]; then | |
| echo "✅ Refreshed existing auto-PR #${EXISTING_PR} on branch ${BRANCH}" | |
| exit 0 | |
| fi | |
| gh pr create \ | |
| --repo "${REPO}" \ | |
| --base "${DEFAULT_BRANCH}" \ | |
| --head "${BRANCH}" \ | |
| --title "Auto-add generated azuredeploy.json from PR #${PR_NUM}" \ | |
| --body "$(printf 'This PR was generated automatically by the **commit-generated-on-merge** job in `ValidateSampleDeployments.yml` after PR #%s was merged.\n\nIt contains the compiled `azuredeploy.json` (and any `prereq.azuredeploy.json`) produced from the Bicep sources in the merged PR. A maintainer should review and merge this PR to land the generated ARM template on `%s`.\n\nNo human edits were made; the contents come directly from the `generated-azuredeploy-%s-%s` artifact uploaded during ADX validation.' \ | |
| "${PR_NUM}" "${DEFAULT_BRANCH}" "${PR_NUM}" "${{ github.event.pull_request.head.sha }}")" | |
| echo "✅ Opened auto-PR for branch ${BRANCH}" |