Skip to content

refactor(scripts): unify asset-doc section contract #6098

refactor(scripts): unify asset-doc section contract

refactor(scripts): unify asset-doc section contract #6098

Workflow file for this run

# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
name: PR Validation
on:
pull_request:
types: [opened, synchronize, reopened]
branches:
- main
- develop
- release/prerelease
- release/stable
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
# Minimal permissions for security
permissions:
contents: read
jobs:
spell-check:
name: Spell Check
uses: ./.github/workflows/spell-check.yml
permissions:
contents: read
with:
soft-fail: false
markdown-lint:
name: Markdown Lint
uses: ./.github/workflows/markdown-lint.yml
permissions:
contents: read
with:
soft-fail: false
table-format:
name: Table Format Check
uses: ./.github/workflows/table-format.yml
permissions:
contents: read
with:
soft-fail: false
psscriptanalyzer:
name: PowerShell Lint
uses: ./.github/workflows/ps-script-analyzer.yml
permissions:
contents: read
with:
soft-fail: false
changed-files-only: true
discover-python-projects:
name: Discover Python Projects
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
directories: ${{ steps.find.outputs.directories }}
has-projects: ${{ steps.find.outputs.has-projects }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Find Python projects
id: find
shell: pwsh
run: |
# The moderation eval (scripts/evals/moderation) carries a heavy
# torch/detoxify stack and runs weekly via weekly-validation.yml,
# so it is excluded from per-PR Python matrix jobs here.
$projectList = @(Get-ChildItem -Recurse -Force -Filter pyproject.toml |
Where-Object { $_.FullName -notmatch 'node_modules' -and $_.FullName -notmatch 'evals[\\/]+moderation' } |
ForEach-Object { Resolve-Path -Relative $_.DirectoryName } |
ForEach-Object { $_ -replace '^\.[\\/]', '' } |
Sort-Object)
# Reject repository-derived paths that could alter command structure downstream.
. ./scripts/security/Assert-WorkflowProjectDirectory.ps1
$projectList = @(Assert-WorkflowProjectDirectory -Path $projectList)
$jsonItems = $projectList | ForEach-Object { $_ | ConvertTo-Json -Compress }
$dirs = '[' + (($jsonItems) -join ',') + ']'
"directories=$dirs" >> $env:GITHUB_OUTPUT
if ($projectList.Count -eq 0) {
"has-projects=false" >> $env:GITHUB_OUTPUT
Write-Output 'No Python projects found'
} else {
"has-projects=true" >> $env:GITHUB_OUTPUT
Write-Output "Found Python projects: $dirs"
}
discover-node-projects:
name: Discover Node Skills
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
directories: ${{ steps.find.outputs.directories }}
has-projects: ${{ steps.find.outputs.has-projects }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Find Node skills
id: find
shell: pwsh
run: |
# A Node skill is any skill (dir containing SKILL.md) that ships at
# least one *.test.* / *.spec.* (mjs/cjs/js). The skill root is the
# node --test working dir.
$skills = @(Get-ChildItem -Path .github/skills -Recurse -Force -Filter SKILL.md -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch 'node_modules' } |
ForEach-Object { $_.DirectoryName } |
Where-Object { @(Get-ChildItem -Path $_ -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '\.(test|spec)\.(mjs|cjs|js)$' -and $_.FullName -notmatch 'node_modules' }).Count -gt 0 } |
ForEach-Object { (Resolve-Path -Relative $_) -replace '^\.[\\/]', '' -replace '\\', '/' } |
Sort-Object -Unique)
# Reject repository-derived paths that could alter command structure downstream.
. ./scripts/security/Assert-WorkflowProjectDirectory.ps1
$skills = @(Assert-WorkflowProjectDirectory -Path $skills)
$jsonItems = $skills | ForEach-Object { $_ | ConvertTo-Json -Compress }
$dirs = '[' + (($jsonItems) -join ',') + ']'
"directories=$dirs" >> $env:GITHUB_OUTPUT
if ($skills.Count -eq 0) {
"has-projects=false" >> $env:GITHUB_OUTPUT
Write-Output 'No Node skills found'
} else {
"has-projects=true" >> $env:GITHUB_OUTPUT
Write-Output "Found Node skills: $dirs"
}
python-lint:
name: "Python Lint (${{ matrix.directory }})"
needs: discover-python-projects
if: needs.discover-python-projects.outputs.has-projects == 'true'
strategy:
fail-fast: false
matrix:
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
uses: ./.github/workflows/python-lint.yml
permissions:
contents: read
with:
soft-fail: false
changed-files-only: true
working-directory: ${{ matrix.directory }}
copyright-headers:
name: Copyright Headers
uses: ./.github/workflows/copyright-headers.yml
permissions:
contents: read
with:
soft-fail: false
yaml-lint:
name: YAML Lint
uses: ./.github/workflows/yaml-lint.yml
permissions:
contents: read
with:
soft-fail: false
changed-files-only: true
pester-tests:
name: PowerShell Tests
uses: ./.github/workflows/pester-tests.yml
permissions:
contents: read
id-token: write
with:
soft-fail: false
changed-files-only: false
code-coverage: true
pytest:
name: "Python Tests (${{ matrix.directory }})"
needs: discover-python-projects
if: needs.discover-python-projects.outputs.has-projects == 'true'
uses: ./.github/workflows/pytest-tests.yml
permissions:
contents: read
id-token: write
with:
working-directory: ${{ matrix.directory }}
soft-fail: false
changed-files-only: true
strategy:
fail-fast: false
matrix:
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
node-tests:
name: "Node Tests (${{ matrix.directory }})"
needs: discover-node-projects
if: needs.discover-node-projects.outputs.has-projects == 'true'
uses: ./.github/workflows/node-tests.yml
permissions:
contents: read
id-token: write
with:
working-directory: ${{ matrix.directory }}
soft-fail: false
changed-files-only: true
strategy:
fail-fast: false
matrix:
directory: ${{ fromJson(needs.discover-node-projects.outputs.directories) }}
accessibility-browser-smoke:
name: Accessibility Browser Smoke
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Install accessibility skill Node dependencies
shell: bash
env:
SEARCH_ROOT: .github/skills/accessibility/accessibility
run: ./scripts/ci/install-skill-node-deps.sh
- name: Verify system Chrome
shell: bash
run: |
set -euo pipefail
chrome_bin="$(command -v google-chrome || command -v google-chrome-stable || true)"
if [ -z "$chrome_bin" ]; then
echo "::error::No system Google Chrome found on the runner; accessibility smoke tests require it"
exit 1
fi
echo "Using system Chrome at: $chrome_bin"
"$chrome_bin" --version
- name: Run accessibility browser smoke tests
run: npm run ci:test:a11y:smoke
fuzz-tests:
name: "Fuzz Tests (${{ matrix.directory }})"
needs: discover-python-projects
if: needs.discover-python-projects.outputs.has-projects == 'true'
uses: ./.github/workflows/fuzz-tests.yml
permissions:
contents: read
with:
working-directory: ${{ matrix.directory }}
soft-fail: false
changed-files-only: true
strategy:
fail-fast: false
matrix:
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
pip-audit:
name: "pip-audit (${{ matrix.directory }})"
needs: discover-python-projects
if: needs.discover-python-projects.outputs.has-projects == 'true'
strategy:
fail-fast: false
matrix:
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
uses: ./.github/workflows/pip-audit.yml
permissions:
contents: read
with:
working-directory: ${{ matrix.directory }}
soft-fail: false
changed-files-only: true
docusaurus-tests:
name: Docusaurus Tests
uses: ./.github/workflows/docusaurus-tests.yml
permissions:
contents: read
id-token: write # Required for Codecov OIDC in the reusable workflow
with:
soft-fail: false
changed-files-only: true
frontmatter-validation:
name: Frontmatter Validation
uses: ./.github/workflows/frontmatter-validation.yml
permissions:
contents: read
with:
soft-fail: false
changed-files-only: true
skip-footer-validation: false
warnings-as-errors: true
adr-consistency-validation:
name: ADR Consistency Validation
uses: ./.github/workflows/adr-consistency-validation.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
changed-files-only: true
upload-sarif: true
upload-artifact: false
ai-artifact-validation:
name: AI Artifact Validation
uses: ./.github/workflows/ai-artifact-validation.yml
permissions:
contents: read
with:
soft-fail: false
asset-docs-validation:
name: Asset Docs Validation
uses: ./.github/workflows/asset-docs-validation.yml
permissions:
contents: read
with:
soft-fail: false
changed-files-only: true
base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }}
msdate-freshness:
name: ms.date Freshness Check
uses: ./.github/workflows/msdate-freshness-check.yml
permissions:
contents: read
with:
staleness-threshold-days: 90
changed-files-only: true
soft-fail: false
plugin-validation:
name: Plugin Validation
uses: ./.github/workflows/plugin-validation.yml
permissions:
contents: read
with:
soft-fail: false
skill-validation:
name: Skill Validation
uses: ./.github/workflows/skill-validation.yml
permissions:
contents: read
with:
soft-fail: false
changed-files-only: true
base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }}
eval-validation:
name: Eval Validation
uses: ./.github/workflows/eval-validation.yml
permissions:
contents: read
pull-requests: write
with:
soft-fail: false
changed-files-only: true
base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }}
secrets:
copilot-github-token: ${{ secrets.COPILOT_GITHUB_TOKEN }}
link-lang-check:
name: Link Language Check
uses: ./.github/workflows/link-lang-check.yml
permissions:
contents: read
with:
soft-fail: false
markdown-link-check:
name: Markdown Link Check
uses: ./.github/workflows/markdown-link-check.yml
permissions:
contents: read
with:
soft-fail: true
dependency-pinning-check:
name: Validate Dependency Pinning
uses: ./.github/workflows/dependency-pinning-scan.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
upload-sarif: true
upload-artifact: false
devcontainer-lockfile-check:
name: Devcontainer Lockfile Integrity
uses: ./.github/workflows/devcontainer-lockfile-check.yml
permissions:
contents: read
with:
soft-fail: false
workflow-permissions-check:
name: Workflow Permissions Check
uses: ./.github/workflows/workflow-permissions-scan.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
upload-sarif: true
upload-artifact: false
workflow-runner-check:
name: Workflow Runner Check
uses: ./.github/workflows/workflow-runner-scan.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
upload-sarif: true
upload-artifact: false
dangerous-workflow-check:
name: Dangerous Workflow Check
uses: ./.github/workflows/dangerous-workflow-scan.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
upload-sarif: true
upload-artifact: false
poutine-soft-fail: true # Poutine runs advisory; homegrown template-injection check is the hard gate
action-version-consistency-scan:
name: Action Version Consistency Scan
uses: ./.github/workflows/action-version-consistency-scan.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
upload-sarif: true
upload-artifact: false
gitleaks-scan:
name: Gitleaks Secret Scan
uses: ./.github/workflows/gitleaks-scan.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
with:
soft-fail: false
upload-sarif: true
upload-artifact: false
log-opts: "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
npm-audit:
name: npm Security Audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
- name: Validate public dependency feeds
run: npm run lint:public-dependency-feeds
- name: Install dependencies
run: npm ci
# Uses audit-ci with an allowlist for advisories that have no installable
# upstream fix. See audit-ci.json for the current allowlist.
- name: Audit all npm projects
run: npm run audit:npm
codeql:
name: CodeQL Security Analysis
uses: ./.github/workflows/codeql-analysis.yml
permissions:
contents: read
security-events: write # Required for SARIF upload to Security tab
actions: read
gate-completeness-check:
name: PR Gate Completeness
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PowerShell modules
uses: ./.github/actions/setup-ps-modules
- name: Validate PR gate completeness
shell: pwsh
run: ./scripts/security/Test-PrValidationGate.ps1 -FailOnViolation
# The promotion head is the last reviewable state before release-please
# can consume its exact release intent, so grammar, parity, advancement,
# and the retained candidate record are proved while the pull request is
# still open. Branch rulesets decide whether this failing check blocks the
# merge; release-prerelease.yml independently revalidates the same intent
# before any tag can be created.
- name: Validate PreRelease promotion intent advances release/prerelease
if: >
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
&& github.event.pull_request.base.ref == 'release/prerelease'
&& github.event.pull_request.head.ref == 'release-promotion--main--to--release-prerelease'
env:
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PROMOTION_HEAD: release-promotion--main--to--release-prerelease
run: |
set -euo pipefail
if [ "$HEAD_BRANCH" != "$PROMOTION_HEAD" ]; then
echo "::error::PreRelease promotion head $HEAD_BRANCH is not the canonical promotion head"
exit 1
fi
if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::PreRelease promotion event carries an invalid pull request number"
exit 1
fi
if [[ ! "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::PreRelease promotion event carries an invalid head SHA"
exit 1
fi
# hve-core is public, so this origin access is intentionally
# credential free; the fetch and ls-remote below stop working if the
# repository ever becomes private.
# The verified commit is the exact event head, so a head that moved
# after the event fails rather than being proved at a later tip.
git fetch --no-tags origin \
"+refs/pull/$PR_NUMBER/head:refs/remotes/pull/$PR_NUMBER/head" \
"+refs/heads/release/prerelease:refs/remotes/origin/release/prerelease"
if [ "$(git rev-parse "refs/remotes/pull/$PR_NUMBER/head")" != "$HEAD_SHA" ]; then
echo "::error::Fetched PreRelease promotion head does not match event head $HEAD_SHA"
exit 1
fi
CANDIDATE=$(git show "refs/remotes/pull/$PR_NUMBER/head:release-please-prerelease-config.json" | jq -r '.packages["."]["release-as"] // ""')
BASELINE=$(git show 'refs/remotes/origin/release/prerelease:.release-please-prerelease-manifest.json' | jq -r '.["."] // ""')
for entry in "candidate:$CANDIDATE" "baseline:$BASELINE"; do
value=${entry#*:}
if [[ "$value" == *$'\n'* || "$value" == *$'\r'* || ! "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::${entry%%:*} PreRelease version is not canonical MAJOR.MINOR.PATCH"
exit 1
fi
done
CANDIDATE_MINOR=${CANDIDATE#*.}
CANDIDATE_MINOR=${CANDIDATE_MINOR%%.*}
if (( 10#$CANDIDATE_MINOR % 2 == 0 )); then
echo "::error::PreRelease candidate $CANDIDATE has an even minor"
exit 1
fi
if [ "$CANDIDATE" = "$BASELINE" ] || [ "$(printf '%s\n%s\n' "$CANDIDATE" "$BASELINE" | sort -V | tail -1)" = "$BASELINE" ]; then
echo "::error::$HEAD_BRANCH proposes $CANDIDATE but release/prerelease already carries $BASELINE"
exit 1
fi
# A tag is immutable, so a transport failure aborts under set -e
# instead of reporting the identity as available.
CANDIDATE_REF="refs/tags/prerelease-v$CANDIDATE"
OCCUPIED=$(git ls-remote origin "$CANDIDATE_REF")
if [ -n "$OCCUPIED" ]; then
echo "::error::$CANDIDATE_REF already exists; the PreRelease release identity is occupied"
exit 1
fi
echo "$HEAD_BRANCH advances release/prerelease from $BASELINE to $CANDIDATE"
- name: Validate Stable promotion intent advances release/stable
if: >
github.event_name == 'pull_request'
&& github.event.pull_request.base.ref == 'release/stable'
&& startsWith(github.head_ref, 'release-promotion--release-prerelease--to--release-stable--prerelease-v')
env:
HEAD_BRANCH: ${{ github.head_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [[ ! "$HEAD_BRANCH" =~ ^release-promotion--release-prerelease--to--release-stable--(prerelease-v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::Stable promotion head $HEAD_BRANCH does not match the canonical selected-tag grammar"
exit 1
fi
SOURCE_TAG=${BASH_REMATCH[1]}
SOURCE_VERSION=${SOURCE_TAG#prerelease-v}
SOURCE_MINOR=${SOURCE_VERSION#*.}
SOURCE_MINOR=${SOURCE_MINOR%%.*}
if (( 10#$SOURCE_MINOR % 2 == 0 )); then
echo "::error::Stable promotion source $SOURCE_TAG has an even minor"
exit 1
fi
if [[ ! "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Stable promotion event carries an invalid head SHA"
exit 1
fi
git fetch --no-tags origin \
"+refs/pull/$PR_NUMBER/head:refs/remotes/pull/$PR_NUMBER/head" \
"+refs/heads/release/stable:refs/remotes/origin/release/stable" \
"+refs/tags/$SOURCE_TAG:refs/tags/$SOURCE_TAG"
if [ "$(git rev-parse "refs/remotes/pull/$PR_NUMBER/head")" != "$HEAD_SHA" ]; then
echo "::error::Fetched Stable promotion head does not match event head $HEAD_SHA"
exit 1
fi
CANDIDATE=$(git show "refs/remotes/pull/$PR_NUMBER/head:release-please-config.json" | jq -r '.packages["."]["release-as"] // ""')
BASELINE=$(git show 'refs/remotes/origin/release/stable:.release-please-manifest.json' | jq -r '.["."] // ""')
for entry in "candidate:$CANDIDATE" "baseline:$BASELINE"; do
value=${entry#*:}
if [[ "$value" == *$'\n'* || "$value" == *$'\r'* || ! "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::${entry%%:*} Stable version is not canonical MAJOR.MINOR.PATCH"
exit 1
fi
done
CANDIDATE_MINOR=${CANDIDATE#*.}
CANDIDATE_MINOR=${CANDIDATE_MINOR%%.*}
if (( 10#$CANDIDATE_MINOR % 2 != 0 )); then
echo "::error::Stable candidate $CANDIDATE has an odd minor"
exit 1
fi
if [ "$CANDIDATE" = "$BASELINE" ] || [ "$(printf '%s\n%s\n' "$CANDIDATE" "$BASELINE" | sort -V | tail -1)" = "$BASELINE" ]; then
echo "::error::$HEAD_BRANCH proposes $CANDIDATE but release/stable already carries $BASELINE"
exit 1
fi
SOURCE_SHA=$(git rev-parse --verify --end-of-options "refs/tags/$SOURCE_TAG^{commit}")
if git merge-base --is-ancestor "$SOURCE_SHA" refs/remotes/origin/release/stable; then
echo "::error::$SOURCE_TAG at $SOURCE_SHA is already contained in release/stable"
exit 1
fi
echo "$HEAD_BRANCH advances release/stable from $BASELINE to $CANDIDATE using $SOURCE_TAG"
pr-validation-success:
name: PR Validation Success
runs-on: ubuntu-latest
permissions:
contents: read
if: always()
needs:
- spell-check
- markdown-lint
- table-format
- psscriptanalyzer
- discover-python-projects
- python-lint
- copyright-headers
- yaml-lint
- pester-tests
- pytest
- discover-node-projects
- node-tests
- accessibility-browser-smoke
- fuzz-tests
- pip-audit
- docusaurus-tests
- frontmatter-validation
- adr-consistency-validation
- ai-artifact-validation
- asset-docs-validation
- msdate-freshness
- plugin-validation
- skill-validation
- eval-validation
- link-lang-check
- markdown-link-check
- dependency-pinning-check
- devcontainer-lockfile-check
- workflow-permissions-check
- workflow-runner-check
- dangerous-workflow-check
- action-version-consistency-scan
- gitleaks-scan
- npm-audit
- codeql
- gate-completeness-check
steps:
- name: Verify all jobs succeeded
env:
NEEDS_JSON: ${{ toJSON(needs) }}
shell: bash
run: |
set -euo pipefail
failed=$(echo "$NEEDS_JSON" | jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "skipped") | .key')
if [ -n "$failed" ]; then
echo "The following jobs did not pass:"
echo "$failed"
exit 1
fi
echo "All PR validation jobs passed."