From 1beb25c8136a12c042e1f7d787f24abc6810962b Mon Sep 17 00:00:00 2001 From: Katrien De Graeve Date: Mon, 8 Jun 2026 09:22:22 +0000 Subject: [PATCH 1/5] feat(workflows): add devcontainer lockfile integrity check (#1873) - Commit devcontainer-lock.json with SHA-256 feature pinning - Add reusable devcontainer-lockfile-check.yml workflow - Wire lockfile check into pr-validation.yml - Categorize lockfile in devcontainer-change-log.yml - Document in workflows, environment, and dependency-pinning docs --- .devcontainer/devcontainer-lock.json | 34 ++++++++ .github/workflows/devcontainer-change-log.yml | 3 + .../workflows/devcontainer-lockfile-check.yml | 80 +++++++++++++++++++ .github/workflows/pr-validation.yml | 8 ++ docs/architecture/workflows.md | 8 +- docs/customization/environment.md | 12 +++ docs/security/dependency-pinning.md | 34 ++++++++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .github/workflows/devcontainer-lockfile-check.yml diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 000000000..0afb6eb2e --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,34 @@ +{ + "features": { + "ghcr.io/devcontainers/features/azure-cli:1": { + "version": "1.3.0", + "resolved": "ghcr.io/devcontainers/features/azure-cli@sha256:d98f1066c077be0fa9d115b718f458bd803e415181b4a96f82a6f5d9f77241ac", + "integrity": "sha256:d98f1066c077be0fa9d115b718f458bd803e415181b4a96f82a6f5d9f77241ac" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.5", + "resolved": "ghcr.io/devcontainers/features/git@sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251", + "integrity": "sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + }, + "ghcr.io/devcontainers/features/powershell:1": { + "version": "1.5.1", + "resolved": "ghcr.io/devcontainers/features/powershell@sha256:df7baa89598c93bfd15808641d9ec9eb03e0ccdf52e5de4cbbce9ab2d9755d18", + "integrity": "sha256:df7baa89598c93bfd15808641d9ec9eb03e0ccdf52e5de4cbbce9ab2d9755d18" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511", + "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511" + } + } +} diff --git a/.github/workflows/devcontainer-change-log.yml b/.github/workflows/devcontainer-change-log.yml index a194c8502..f243e5242 100644 --- a/.github/workflows/devcontainer-change-log.yml +++ b/.github/workflows/devcontainer-change-log.yml @@ -79,6 +79,9 @@ jobs: .devcontainer/devcontainer.json) echo "| \`$file\` | Config | High |" ;; + .devcontainer/devcontainer-lock.json) + echo "| \`$file\` | Lockfile | Medium |" + ;; .github/workflows/copilot-setup-steps.yml) echo "| \`$file\` | Setup Steps | Medium |" ;; diff --git a/.github/workflows/devcontainer-lockfile-check.yml b/.github/workflows/devcontainer-lockfile-check.yml new file mode 100644 index 000000000..03c44984b --- /dev/null +++ b/.github/workflows/devcontainer-lockfile-check.yml @@ -0,0 +1,80 @@ +name: Devcontainer Lockfile Integrity + +on: + workflow_call: + inputs: + soft-fail: + description: 'Whether to continue on lockfile integrity violations' + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + check-lockfile: + name: Validate Devcontainer Lockfile + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate lockfile integrity + id: validate + shell: bash + run: | + set -euo pipefail + + LOCK=".devcontainer/devcontainer-lock.json" + CONFIG=".devcontainer/devcontainer.json" + ERRORS=0 + + # 1. Lockfile must exist + if [[ ! -f "$LOCK" ]]; then + echo "::error file=$CONFIG::devcontainer-lock.json is missing — rebuild the dev container to generate it" + exit 1 + fi + + # 2. Every feature must have resolved + integrity with sha256 + MISSING_SHA=$(jq -r '.features | to_entries[] + | select(.value.resolved == null or .value.integrity == null + or (.value.integrity | startswith("sha256:") | not)) + | .key' "$LOCK") + if [[ -n "$MISSING_SHA" ]]; then + while IFS= read -r key; do + echo "::error file=$LOCK::Feature missing SHA-256 integrity: $key" + done <<< "$MISSING_SHA" + ERRORS=$((ERRORS + 1)) + fi + + # 3. Feature keys in lockfile must cover devcontainer.json features + CONFIG_KEYS=$(jq -r '.features | keys[] | ascii_downcase' "$CONFIG" | sort) + LOCK_KEYS=$(jq -r '.features | keys[] | ascii_downcase' "$LOCK" | sort) + MISSING_KEYS=$(comm -23 <(echo "$CONFIG_KEYS") <(echo "$LOCK_KEYS")) + if [[ -n "$MISSING_KEYS" ]]; then + while IFS= read -r key; do + echo "::error file=$LOCK::Lockfile missing entry for feature: $key" + done <<< "$MISSING_KEYS" + ERRORS=$((ERRORS + 1)) + fi + + if [[ "$ERRORS" -gt 0 ]]; then + echo "LOCKFILE_FAILED=true" >> "$GITHUB_ENV" + else + echo "✓ Lockfile covers all features with SHA-256 integrity" + fi + continue-on-error: ${{ inputs.soft-fail }} + + - name: Check results + if: ${{ !inputs.soft-fail }} + shell: bash + run: | + if [[ "${LOCKFILE_FAILED:-}" == "true" ]]; then + echo "::error::Devcontainer lockfile integrity check failed" + exit 1 + fi diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 9bb949c12..7e109ab1b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -271,6 +271,14 @@ jobs: 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 diff --git a/docs/architecture/workflows.md b/docs/architecture/workflows.md index 06602f5fb..365ae1e71 100644 --- a/docs/architecture/workflows.md +++ b/docs/architecture/workflows.md @@ -55,6 +55,7 @@ flowchart TD | `release-marketplace-prerelease.yml` | Manual | VS Code extension pre-release publishing | | `copilot-setup-steps.yml` | Manual | Coding agent environment setup | | `devcontainer-change-log.yml` | Push to main/develop | Logs devcontainer infrastructure file changes to the step summary | +| `devcontainer-lockfile-check.yml` | Reusable | Validates devcontainer lockfile integrity and SHA-256 pinning | | `release-prerelease.yml` | PR closed | Pre-release tag and publish on merge to main | | `release-prerelease-pr.yml` | Push to main | Pre-release companion PR management | | `scorecard.yml` | Schedule, push | OpenSSF Scorecard security analysis | @@ -100,6 +101,7 @@ Individual validation workflows called by orchestration workflows: | `docusaurus-tests.yml` | Docusaurus test suite | N/A (npm test) | | `model-validation.yml` | Model reference validation | `npm run lint:models` | | `ai-artifact-validation.yml` | AI artifact structure validation | `npm run lint:ai-artifacts` | +| `devcontainer-lockfile-check.yml` | Devcontainer lockfile integrity | N/A (bash + jq direct) | | `action-version-consistency-scan.yml` | Action version consistency | `npm run lint:version-consistency` | Workflows marked with `*` are dual-purpose: they accept `workflow_call` for reuse by orchestration workflows and also run independently via their own triggers. @@ -130,6 +132,7 @@ flowchart LR subgraph "Security" DPC[dependency-pinning-check] + DCL[devcontainer-lockfile-check] NA[npm-audit] CQL[codeql] GLS[gitleaks-scan] @@ -150,8 +153,9 @@ flowchart LR | skill-validation | `skill-validation.yml` | Skill directory structure | | link-lang-check | `link-lang-check.yml` | Link accessibility | | markdown-link-check | `markdown-link-check.yml` | Broken links | -| dependency-pinning-check | `dependency-pinning-scan.yml` | Dependency pinning | -| npm-audit | Inline | npm dependency vulnerabilities | +| dependency-pinning-check | `dependency-pinning-scan.yml` | Dependency pinning | +| devcontainer-lockfile-check | `devcontainer-lockfile-check.yml` | Devcontainer lockfile integrity| +| npm-audit | Inline | npm dependency vulnerabilities | | codeql | `codeql-analysis.yml` | Code security patterns | | copyright-headers | `copyright-headers.yml` | Copyright header compliance | | plugin-validation | `plugin-validation.yml` | Plugin and collection metadata | diff --git a/docs/customization/environment.md b/docs/customization/environment.md index e5ca1b62f..2d9c3a392 100644 --- a/docs/customization/environment.md +++ b/docs/customization/environment.md @@ -65,6 +65,18 @@ to add Terraform: } ``` +### Lockfile + +When the dev container builds, it generates a `devcontainer-lock.json` file in +the same directory as `devcontainer.json`. This lockfile pins each feature to an +exact version and OCI SHA-256 digest, providing reproducible builds and +supply-chain integrity verification. The lockfile is committed to source control +and validated by CI. + +After modifying features in `devcontainer.json`, rebuild the dev container to +regenerate `devcontainer-lock.json` and commit both files together. PR validation +fails if the lockfile is missing or out of sync with `devcontainer.json`. + ### Adding VS Code Extensions Include team-specific extensions in the `customizations.vscode.extensions` diff --git a/docs/security/dependency-pinning.md b/docs/security/dependency-pinning.md index e335c9589..bed79c1f2 100644 --- a/docs/security/dependency-pinning.md +++ b/docs/security/dependency-pinning.md @@ -79,6 +79,40 @@ GitHub Actions references must use full 40-character commit SHAs because action The scanner validates that the SHA is a real 40-character hexadecimal string and optionally checks staleness against the GitHub API. +## DevContainer Features: Lockfile Integrity + +DevContainer features declared in `.devcontainer/devcontainer.json` are pinned through a lockfile (`devcontainer-lock.json`) that records the exact version, OCI digest, and SHA-256 integrity hash for each feature. This follows the [devcontainer lockfile spec](https://github.com/devcontainers/spec/blob/main/docs/specs/devcontainer-lockfile.md), modeled after `package-lock.json`. + +### What Is Validated + +The `devcontainer-lockfile-check.yml` workflow enforces three checks during PR validation: + +| Check | Failure Condition | +|--------------------|--------------------------------------------------------------------------| +| Lockfile existence | `devcontainer-lock.json` is absent from the repository | +| SHA-256 integrity | A feature entry is missing `resolved` or `integrity` with `sha256:` prefix | +| Feature coverage | A feature in `devcontainer.json` has no corresponding lockfile entry | + +### Lockfile Format + +Each feature entry records the resolved OCI reference and integrity hash: + +```json +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46...", + "integrity": "sha256:8c0de46..." + } + } +} +``` + +### Regenerating the Lockfile + +Rebuild the dev container to regenerate the lockfile. In VS Code, use the **Dev Containers: Rebuild Container** command. Commit the updated `devcontainer-lock.json` alongside any changes to `devcontainer.json`. + ## pip: Exact-Version Pinning Python dependencies must use the `==` operator for exact version pinning. The scanner excludes virtual environment directories (`.venv`, `venv`, `.tox`, `.nox`, `__pypackages__`) to avoid false positives from installed package metadata. From 996d1418b3db42faa17b18a9d86587409078512c Mon Sep 17 00:00:00 2001 From: Katrien De Graeve Date: Mon, 8 Jun 2026 13:59:57 +0200 Subject: [PATCH 2/5] docs(workflows): update workflow documentation dates for accuracy --- docs/architecture/workflows.md | 40 ++++++++++++++--------------- docs/customization/environment.md | 2 +- docs/security/dependency-pinning.md | 10 ++++---- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/architecture/workflows.md b/docs/architecture/workflows.md index 365ae1e71..d25f867a6 100644 --- a/docs/architecture/workflows.md +++ b/docs/architecture/workflows.md @@ -3,7 +3,7 @@ title: Build Workflows description: GitHub Actions CI/CD pipeline architecture for validation, security, and release automation sidebar_position: 3 author: WilliamBerryiii -ms.date: 2026-05-20 +ms.date: 2026-06-08 ms.topic: overview --- @@ -141,25 +141,25 @@ flowchart LR ### Jobs -| Job | Reusable Workflow | Validates | -|--------------------------|-------------------------------|--------------------------------| -| spell-check | `spell-check.yml` | Spelling across all files | -| markdown-lint | `markdown-lint.yml` | Markdown formatting rules | -| table-format | `table-format.yml` | Markdown table structure | -| psscriptanalyzer | `ps-script-analyzer.yml` | PowerShell code quality | -| yaml-lint | `yaml-lint.yml` | YAML syntax | -| pester-tests | `pester-tests.yml` | PowerShell unit tests | -| frontmatter-validation | `frontmatter-validation.yml` | AI artifact metadata | -| skill-validation | `skill-validation.yml` | Skill directory structure | -| link-lang-check | `link-lang-check.yml` | Link accessibility | -| markdown-link-check | `markdown-link-check.yml` | Broken links | -| dependency-pinning-check | `dependency-pinning-scan.yml` | Dependency pinning | -| devcontainer-lockfile-check | `devcontainer-lockfile-check.yml` | Devcontainer lockfile integrity| -| npm-audit | Inline | npm dependency vulnerabilities | -| codeql | `codeql-analysis.yml` | Code security patterns | -| copyright-headers | `copyright-headers.yml` | Copyright header compliance | -| plugin-validation | `plugin-validation.yml` | Plugin and collection metadata | -| gitleaks-scan | `gitleaks-scan.yml` | Secret detection | +| Job | Reusable Workflow | Validates | +|-----------------------------|-----------------------------------|---------------------------------| +| spell-check | `spell-check.yml` | Spelling across all files | +| markdown-lint | `markdown-lint.yml` | Markdown formatting rules | +| table-format | `table-format.yml` | Markdown table structure | +| psscriptanalyzer | `ps-script-analyzer.yml` | PowerShell code quality | +| yaml-lint | `yaml-lint.yml` | YAML syntax | +| pester-tests | `pester-tests.yml` | PowerShell unit tests | +| frontmatter-validation | `frontmatter-validation.yml` | AI artifact metadata | +| skill-validation | `skill-validation.yml` | Skill directory structure | +| link-lang-check | `link-lang-check.yml` | Link accessibility | +| markdown-link-check | `markdown-link-check.yml` | Broken links | +| dependency-pinning-check | `dependency-pinning-scan.yml` | Dependency pinning | +| devcontainer-lockfile-check | `devcontainer-lockfile-check.yml` | Devcontainer lockfile integrity | +| npm-audit | Inline | npm dependency vulnerabilities | +| codeql | `codeql-analysis.yml` | Code security patterns | +| copyright-headers | `copyright-headers.yml` | Copyright header compliance | +| plugin-validation | `plugin-validation.yml` | Plugin and collection metadata | +| gitleaks-scan | `gitleaks-scan.yml` | Secret detection | All jobs run in parallel with no dependencies, enabling fast feedback (typically under 3 minutes). diff --git a/docs/customization/environment.md b/docs/customization/environment.md index 2d9c3a392..80fddc3fc 100644 --- a/docs/customization/environment.md +++ b/docs/customization/environment.md @@ -2,7 +2,7 @@ title: Environment Customization description: Configure DevContainers, VS Code settings, MCP servers, and coding agent environments for your team author: Microsoft -ms.date: 2026-02-24 +ms.date: 2026-06-08 ms.topic: how-to keywords: - devcontainer diff --git a/docs/security/dependency-pinning.md b/docs/security/dependency-pinning.md index bed79c1f2..b39bf3ccb 100644 --- a/docs/security/dependency-pinning.md +++ b/docs/security/dependency-pinning.md @@ -3,7 +3,7 @@ title: Dependency Pinning description: How HVE Core enforces dependency pinning across GitHub Actions, npm, pip, and shell downloads with automated CI validation sidebar_position: 3 author: Microsoft -ms.date: 2026-03-02 +ms.date: 2026-06-08 ms.topic: concept keywords: - dependency pinning @@ -87,11 +87,11 @@ DevContainer features declared in `.devcontainer/devcontainer.json` are pinned t The `devcontainer-lockfile-check.yml` workflow enforces three checks during PR validation: -| Check | Failure Condition | -|--------------------|--------------------------------------------------------------------------| -| Lockfile existence | `devcontainer-lock.json` is absent from the repository | +| Check | Failure Condition | +|--------------------|----------------------------------------------------------------------------| +| Lockfile existence | `devcontainer-lock.json` is absent from the repository | | SHA-256 integrity | A feature entry is missing `resolved` or `integrity` with `sha256:` prefix | -| Feature coverage | A feature in `devcontainer.json` has no corresponding lockfile entry | +| Feature coverage | A feature in `devcontainer.json` has no corresponding lockfile entry | ### Lockfile Format From 67bb830784cb579d41755a2b3be4f2ecaa9dfd01 Mon Sep 17 00:00:00 2001 From: Katrien De Graeve Date: Mon, 15 Jun 2026 06:36:07 +0000 Subject: [PATCH 3/5] refactor(scripts): extract devcontainer workflows into PowerShell scripts - Add Test-DevcontainerLockfile.ps1 and Write-DevcontainerChangeLog.ps1 - Add Pester tests with 30 passing tests and JSON fixtures - Rewrite both workflows to delegate via parameter splatting - Add npm validate:devcontainer-lockfile and validate:devcontainer-changelog - Update docs --- .github/copilot-instructions.md | 5 +- .github/workflows/devcontainer-change-log.yml | 78 ++---- .../workflows/devcontainer-lockfile-check.yml | 59 +---- package.json | 4 +- scripts/README.md | 40 ++- .../Test-DevcontainerLockfile.ps1 | 237 ++++++++++++++++++ .../Write-DevcontainerChangeLog.ps1 | 198 +++++++++++++++ .../Test-DevcontainerLockfile.Tests.ps1 | 207 +++++++++++++++ .../Write-DevcontainerChangeLog.Tests.ps1 | 97 +++++++ .../Devcontainer/empty-features-config.json | 3 + .../Devcontainer/empty-features-lock.json | 3 + .../Devcontainer/extra-config-features.json | 7 + .../Devcontainer/missing-integrity-lock.json | 8 + .../Devcontainer/missing-resolved-lock.json | 8 + .../fixtures/Devcontainer/valid-config.json | 6 + .../fixtures/Devcontainer/valid-lock.json | 14 ++ .../Devcontainer/wrong-hash-lock.json | 9 + 17 files changed, 855 insertions(+), 128 deletions(-) create mode 100644 scripts/devcontainer/Test-DevcontainerLockfile.ps1 create mode 100644 scripts/devcontainer/Write-DevcontainerChangeLog.ps1 create mode 100644 scripts/tests/devcontainer/Test-DevcontainerLockfile.Tests.ps1 create mode 100644 scripts/tests/devcontainer/Write-DevcontainerChangeLog.Tests.ps1 create mode 100644 scripts/tests/fixtures/Devcontainer/empty-features-config.json create mode 100644 scripts/tests/fixtures/Devcontainer/empty-features-lock.json create mode 100644 scripts/tests/fixtures/Devcontainer/extra-config-features.json create mode 100644 scripts/tests/fixtures/Devcontainer/missing-integrity-lock.json create mode 100644 scripts/tests/fixtures/Devcontainer/missing-resolved-lock.json create mode 100644 scripts/tests/fixtures/Devcontainer/valid-config.json create mode 100644 scripts/tests/fixtures/Devcontainer/valid-lock.json create mode 100644 scripts/tests/fixtures/Devcontainer/wrong-hash-lock.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 17e5b8726..97c8a9f3e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -58,6 +58,7 @@ Scripts are organized by function: * Collections (`scripts/collections/`) - Collection validation and shared helper modules. * Extension (`scripts/extension/`) - Extension packaging and preparation. * Linting (`scripts/linting/`) - Markdown validation, link checking, frontmatter validation, model reference validation, and PowerShell analysis. +* Devcontainer (`scripts/devcontainer/`) - Lockfile integrity validation and infrastructure change log generation. * Security (`scripts/security/`) - Dependency pinning validation, SHA staleness checks, and action version consistency. * Library (`scripts/lib/`) - Shared utilities such as verified downloads. * Plugins (`scripts/plugins/`) - Plugin generation and marketplace validation. @@ -215,8 +216,10 @@ Agents should use npm scripts for all validation: * `npm run lint:py` - Python linting via ruff * `npm run lint:models` - Model reference validation against catalog * `npm run lint:models:refresh` - Refresh model catalog from upstream documentation -* `npm run lint:all` - Run all linters (chains `format:tables`, `lint:md`, `lint:ps`, `lint:yaml`, `lint:links`, `lint:frontmatter`, `lint:collections-metadata`, `lint:marketplace`, `lint:version-consistency`, `lint:permissions`, `lint:dependency-pinning`, `lint:py`, `validate:skills`, `lint:ai-artifacts`, and `lint:models`) +* `npm run lint:all` - Run all linters (chains `format:tables`, `lint:md`, `lint:ps`, `lint:yaml`, `lint:links`, `lint:frontmatter`, `lint:collections-metadata`, `lint:marketplace`, `lint:version-consistency`, `lint:permissions`, `lint:dependency-pinning`, `lint:py`, `validate:skills`, `lint:ai-artifacts`, `lint:models`, and `validate:devcontainer-lockfile`) * `npm run validate:copyright` - Copyright header validation +* `npm run validate:devcontainer-lockfile` - Devcontainer lockfile integrity validation +* `npm run validate:devcontainer-changelog` - Devcontainer infrastructure change summary * `npm run validate:skills` - Skill structure validation * `npm run spell-check` - Spelling validation * `npm run format:tables` - Markdown table formatting diff --git a/.github/workflows/devcontainer-change-log.yml b/.github/workflows/devcontainer-change-log.yml index 32c426d1d..797c5ce5d 100644 --- a/.github/workflows/devcontainer-change-log.yml +++ b/.github/workflows/devcontainer-change-log.yml @@ -31,69 +31,19 @@ jobs: fetch-depth: 0 - name: Write infrastructure change summary - env: - GIT_SHA: ${{ github.sha }} - REF_NAME: ${{ github.ref_name }} - EVENT_NAME: ${{ github.event_name }} - BEFORE_SHA: ${{ github.event.before }} + shell: pwsh run: | - set -euo pipefail - - { - echo "## Devcontainer Infrastructure Changes" - echo "" - echo "| Property | Value |" - echo "|----------|-------|" - echo "| Commit | \`${GIT_SHA}\` |" - echo "| Branch | \`${REF_NAME}\` |" - echo "| Trigger | \`${EVENT_NAME}\` |" - echo "" - - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - echo "_Triggered via workflow_dispatch. No push range available for automatic diff._" - elif [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then - echo "_Initial push to branch — no prior commit range available._" - else - if ! CHANGED=$(git diff --name-only "$BEFORE_SHA" "$GIT_SHA" -- '.devcontainer/' '.github/workflows/copilot-setup-steps.yml' 2>&1); then - echo "_Could not compute diff: \`$BEFORE_SHA\` may not be reachable (force push?)._" - elif [ -z "$CHANGED" ]; then - echo "_No devcontainer infrastructure files changed in this push._" - else - echo "| File | Category | Pre-build Impact |" - echo "|------|----------|-----------------|" - while IFS= read -r file; do - [ -z "$file" ] && continue - case "$file" in - .devcontainer/scripts/on-create.sh) - echo "| \`$file\` | Lifecycle Scripts | High |" - ;; - .devcontainer/scripts/post-create.sh) - echo "| \`$file\` | Lifecycle Scripts | Low |" - ;; - .devcontainer/Dockerfile*|.devcontainer/*.dockerfile) - echo "| \`$file\` | Base Image | High |" - ;; - .devcontainer/features/*) - echo "| \`$file\` | Features | Medium |" - ;; - .devcontainer/devcontainer.json) - echo "| \`$file\` | Config | High |" - ;; - .devcontainer/devcontainer-lock.json) - echo "| \`$file\` | Lockfile | Medium |" - ;; - .github/workflows/copilot-setup-steps.yml) - echo "| \`$file\` | Setup Steps | Medium |" - ;; - .devcontainer/*) - echo "| \`$file\` | Config | Medium |" - ;; - *) - echo "| \`$file\` | Other | Unknown |" - ;; - esac - done <<< "$CHANGED" - fi - fi - } >> "$GITHUB_STEP_SUMMARY" + $params = @{ + EventName = '${{ github.event_name }}' + } + if ('${{ github.sha }}') { + $params['CommitSha'] = '${{ github.sha }}' + } + if ('${{ github.ref_name }}') { + $params['BranchName'] = '${{ github.ref_name }}' + } + if ('${{ github.event.before }}') { + $params['BeforeSha'] = '${{ github.event.before }}' + } + & scripts/devcontainer/Write-DevcontainerChangeLog.ps1 @params diff --git a/.github/workflows/devcontainer-lockfile-check.yml b/.github/workflows/devcontainer-lockfile-check.yml index 03c44984b..51aa7a229 100644 --- a/.github/workflows/devcontainer-lockfile-check.yml +++ b/.github/workflows/devcontainer-lockfile-check.yml @@ -26,55 +26,12 @@ jobs: - name: Validate lockfile integrity id: validate - shell: bash + shell: pwsh run: | - set -euo pipefail - - LOCK=".devcontainer/devcontainer-lock.json" - CONFIG=".devcontainer/devcontainer.json" - ERRORS=0 - - # 1. Lockfile must exist - if [[ ! -f "$LOCK" ]]; then - echo "::error file=$CONFIG::devcontainer-lock.json is missing — rebuild the dev container to generate it" - exit 1 - fi - - # 2. Every feature must have resolved + integrity with sha256 - MISSING_SHA=$(jq -r '.features | to_entries[] - | select(.value.resolved == null or .value.integrity == null - or (.value.integrity | startswith("sha256:") | not)) - | .key' "$LOCK") - if [[ -n "$MISSING_SHA" ]]; then - while IFS= read -r key; do - echo "::error file=$LOCK::Feature missing SHA-256 integrity: $key" - done <<< "$MISSING_SHA" - ERRORS=$((ERRORS + 1)) - fi - - # 3. Feature keys in lockfile must cover devcontainer.json features - CONFIG_KEYS=$(jq -r '.features | keys[] | ascii_downcase' "$CONFIG" | sort) - LOCK_KEYS=$(jq -r '.features | keys[] | ascii_downcase' "$LOCK" | sort) - MISSING_KEYS=$(comm -23 <(echo "$CONFIG_KEYS") <(echo "$LOCK_KEYS")) - if [[ -n "$MISSING_KEYS" ]]; then - while IFS= read -r key; do - echo "::error file=$LOCK::Lockfile missing entry for feature: $key" - done <<< "$MISSING_KEYS" - ERRORS=$((ERRORS + 1)) - fi - - if [[ "$ERRORS" -gt 0 ]]; then - echo "LOCKFILE_FAILED=true" >> "$GITHUB_ENV" - else - echo "✓ Lockfile covers all features with SHA-256 integrity" - fi - continue-on-error: ${{ inputs.soft-fail }} - - - name: Check results - if: ${{ !inputs.soft-fail }} - shell: bash - run: | - if [[ "${LOCKFILE_FAILED:-}" == "true" ]]; then - echo "::error::Devcontainer lockfile integrity check failed" - exit 1 - fi + New-Item -ItemType Directory -Force -Path logs | Out-Null + $params = @{} + if ('${{ inputs.soft-fail }}' -ne 'true') { + $params['FailOnViolation'] = $true + } + & scripts/devcontainer/Test-DevcontainerLockfile.ps1 @params + exit $LASTEXITCODE diff --git a/package.json b/package.json index 5eb931835..0dddefe9b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "lint:ai-artifacts": "pwsh -NoProfile -Command \"& './scripts/linting/Validate-PlannerArtifacts.ps1' -FailOnMissing\"", "lint:models": "pwsh -NoProfile -File scripts/linting/Test-ModelReferences.ps1 -OutputPath logs/model-validation-results.json", "lint:models:refresh": "pwsh -NoProfile -File scripts/linting/Update-ModelCatalog.ps1", - "lint:all": "npm run format:tables && npm run lint:md && npm run lint:ps && npm run lint:yaml && npm run lint:json && npm run lint:links && npm run lint:frontmatter && npm run lint:adr-consistency && npm run lint:collections-metadata && npm run lint:marketplace && npm run lint:version-consistency && npm run lint:permissions && npm run lint:dependency-pinning && npm run lint:ps-module-pins && npm run lint:py && npm run validate:skills && npm run lint:ai-artifacts && npm run lint:models && npm run eval:lint:vally && npm run eval:lint:schema && npm run eval:lint:text && npm run eval:lint:safety", + "lint:all": "npm run format:tables && npm run lint:md && npm run lint:ps && npm run lint:yaml && npm run lint:json && npm run lint:links && npm run lint:frontmatter && npm run lint:adr-consistency && npm run lint:collections-metadata && npm run lint:marketplace && npm run lint:version-consistency && npm run lint:permissions && npm run lint:dependency-pinning && npm run lint:ps-module-pins && npm run lint:py && npm run validate:skills && npm run lint:ai-artifacts && npm run lint:models && npm run validate:devcontainer-lockfile && npm run eval:lint:vally && npm run eval:lint:schema && npm run eval:lint:text && npm run eval:lint:safety", "format:tables": "pwsh -NoProfile -File scripts/linting/Format-MarkdownTables.ps1", "extension:prepare": "pwsh ./scripts/extension/Prepare-Extension.ps1 && npm run extension:postprocess", "extension:prepare:prerelease": "pwsh ./scripts/extension/Prepare-Extension.ps1 -Channel PreRelease && npm run extension:postprocess", @@ -42,6 +42,8 @@ "package:extension": "npm run extension:package --", "extension:package:prerelease": "pwsh ./scripts/extension/Package-Extension.ps1 -PreRelease", "validate:copyright": "pwsh -File scripts/linting/Test-CopyrightHeaders.ps1", + "validate:devcontainer-changelog": "pwsh -NoProfile -File scripts/devcontainer/Write-DevcontainerChangeLog.ps1", + "validate:devcontainer-lockfile": "pwsh -NoProfile -Command \"& './scripts/devcontainer/Test-DevcontainerLockfile.ps1' -FailOnViolation\"", "validate:skills": "pwsh -NoProfile -Command \"& './scripts/linting/Validate-SkillStructure.ps1' -WarningsAsErrors\"", "validate:ai-artifacts": "pwsh -NoProfile -Command \"& './scripts/linting/Validate-PlannerArtifacts.ps1'\"", "lint:py": "pwsh -NoProfile -File ./scripts/linting/Invoke-PythonLint.ps1 -OutputPath logs/python-lint-results.json", diff --git a/scripts/README.md b/scripts/README.md index 1babe921f..44cda4fe4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,11 +20,12 @@ This directory contains PowerShell scripts for automating linting, validation, a ```text scripts/ ├── collections/ Collection validation and shared helpers +├── devcontainer/ Devcontainer lockfile and change log validation ├── extension/ VS Code extension packaging utilities ├── lib/ Shared utility modules ├── linting/ PowerShell linting and validation scripts ├── plugins/ Copilot CLI plugin generation -└── security/ Security scanning and dependency pinning scripts +├── security/ Security scanning and dependency pinning scripts └── tests/ Pester test organization ``` @@ -65,6 +66,22 @@ The `linting/` directory contains scripts for validating code quality and docume See [linting/README.md](linting/README.md) for detailed documentation. +## Devcontainer Scripts + +The `devcontainer/` directory contains scripts for devcontainer infrastructure validation: + +| Script | Purpose | +|-------------------------------------|----------------------------------------------------------| +| `Test-DevcontainerLockfile.ps1` | Validate lockfile existence, SHA-256 integrity, coverage | +| `Write-DevcontainerChangeLog.ps1` | Classify changed files and generate markdown summary | + +Run locally: + +```bash +npm run validate:devcontainer-lockfile +npm run validate:devcontainer-changelog +``` + ## Security Scripts The `security/` directory contains scripts for security scanning and dependency management: @@ -97,16 +114,17 @@ Collection validation and shared helpers. Pester test organization matching the scripts structure. -| Directory | Tests For | -|----------------|---------------------------| -| `collections/` | Collection helpers tests | -| `extension/` | Extension packaging tests | -| `lib/` | Library utility tests | -| `linting/` | Linting script tests | -| `security/` | Security validation tests | -| `plugins/` | Plugin generation tests | -| `Fixtures/` | Shared test fixtures | -| `Mocks/` | Shared mock data | +| Directory | Tests For | +|------------------|---------------------------------| +| `collections/` | Collection helpers tests | +| `devcontainer/` | Devcontainer validation tests | +| `extension/` | Extension packaging tests | +| `lib/` | Library utility tests | +| `linting/` | Linting script tests | +| `security/` | Security validation tests | +| `plugins/` | Plugin generation tests | +| `Fixtures/` | Shared test fixtures | +| `Mocks/` | Shared mock data | Run all tests: diff --git a/scripts/devcontainer/Test-DevcontainerLockfile.ps1 b/scripts/devcontainer/Test-DevcontainerLockfile.ps1 new file mode 100644 index 000000000..fdc12d333 --- /dev/null +++ b/scripts/devcontainer/Test-DevcontainerLockfile.ps1 @@ -0,0 +1,237 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Validates devcontainer lockfile integrity and feature coverage. + +.DESCRIPTION + Checks that devcontainer-lock.json exists, all features have SHA-256 integrity + hashes and resolved references, and that every feature declared in devcontainer.json + is present in the lockfile. Outputs results as JSON and emits CI annotations for + any violations found. + +.PARAMETER RepoRoot + Root directory of the repository. Defaults to the git working tree root or the + script directory when not inside a git repository. + +.PARAMETER OutputPath + Path where validation results JSON should be saved. Defaults to + 'logs/devcontainer-lockfile-results.json'. + +.PARAMETER FailOnViolation + Exit with code 1 when any validation check fails. + +.EXAMPLE + ./Test-DevcontainerLockfile.ps1 + Validate lockfile in the current repository with default settings. + +.EXAMPLE + ./Test-DevcontainerLockfile.ps1 -FailOnViolation + Validate lockfile and exit with error code on failures. + +.NOTES + Runs via: npm run validate:devcontainer-lockfile +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$RepoRoot = (git rev-parse --show-toplevel 2>$null), + + [Parameter(Mandatory = $false)] + [string]$OutputPath = 'logs/devcontainer-lockfile-results.json', + + [Parameter(Mandatory = $false)] + [switch]$FailOnViolation +) + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $PSScriptRoot } + +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force + +#region Functions + +function Test-LockfileExists { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot + ) + + $lockfilePath = Join-Path $RepoRoot '.devcontainer/devcontainer-lock.json' + if (Test-Path $lockfilePath) { + return @{ + Passed = $true + Message = "Lockfile exists at $lockfilePath" + } + } + + return @{ + Passed = $false + Message = "Lockfile not found at $lockfilePath" + } +} + +function Test-FeatureIntegrity { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$LockfilePath + ) + + $lockData = Get-Content -Path $LockfilePath -Raw | ConvertFrom-Json + $violations = @() + + foreach ($feature in $lockData.features.PSObject.Properties) { + $name = $feature.Name + $value = $feature.Value + + if (-not $value.resolved) { + $violations += "Feature '$name' is missing a resolved reference" + } + if (-not $value.integrity) { + $violations += "Feature '$name' is missing an integrity hash" + } + elseif (-not $value.integrity.StartsWith('sha256:')) { + $violations += "Feature '$name' has non-SHA-256 integrity: $($value.integrity)" + } + } + + return @{ + Passed = ($violations.Count -eq 0) + Violations = $violations + } +} + +function Test-FeatureCoverage { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$LockfilePath, + + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $lockData = Get-Content -Path $LockfilePath -Raw | ConvertFrom-Json + $configData = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json + + $lockKeys = @($lockData.features.PSObject.Properties | + ForEach-Object { $_.Name.ToLowerInvariant() }) + $configKeys = @($configData.features.PSObject.Properties | + ForEach-Object { $_.Name.ToLowerInvariant() }) + + $missingKeys = @($configKeys | Where-Object { $_ -notin $lockKeys }) + + return @{ + Passed = ($missingKeys.Count -eq 0) + MissingKeys = $missingKeys + } +} + +function Invoke-LockfileValidation { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot + ) + + $lockfilePath = Join-Path $RepoRoot '.devcontainer/devcontainer-lock.json' + $configPath = Join-Path $RepoRoot '.devcontainer/devcontainer.json' + + $details = @() + + # Check 1: Lockfile exists + $existsResult = Test-LockfileExists -RepoRoot $RepoRoot + $details += @{ + CheckName = 'LockfileExists' + Passed = $existsResult.Passed + Message = $existsResult.Message + } + + if (-not $existsResult.Passed) { + Write-CIAnnotation -Level Error -Message $existsResult.Message + return @{ + TotalChecks = 1 + PassedChecks = 0 + FailedChecks = 1 + Details = $details + } + } + + # Check 2: Feature integrity + $integrityResult = Test-FeatureIntegrity -LockfilePath $lockfilePath + $details += @{ + CheckName = 'FeatureIntegrity' + Passed = $integrityResult.Passed + Violations = $integrityResult.Violations + } + + if (-not $integrityResult.Passed) { + foreach ($violation in $integrityResult.Violations) { + Write-CIAnnotation -Level Error -Message $violation + } + } + + # Check 3: Feature coverage + $coverageResult = Test-FeatureCoverage -LockfilePath $lockfilePath -ConfigPath $configPath + $details += @{ + CheckName = 'FeatureCoverage' + Passed = $coverageResult.Passed + MissingKeys = $coverageResult.MissingKeys + } + + if (-not $coverageResult.Passed) { + foreach ($key in $coverageResult.MissingKeys) { + Write-CIAnnotation -Level Error -Message "Feature '$key' declared in devcontainer.json but missing from lockfile" + } + } + + $passedCount = ($details | Where-Object { $_.Passed }).Count + $failedCount = ($details | Where-Object { -not $_.Passed }).Count + + return @{ + TotalChecks = $details.Count + PassedChecks = $passedCount + FailedChecks = $failedCount + Details = $details + } +} + +#endregion Functions + +#region Main Execution + +if ($MyInvocation.InvocationName -ne '.') { + try { + New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath -Parent) | Out-Null + $result = Invoke-LockfileValidation -RepoRoot $RepoRoot + $result | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8 + + if ($result.FailedChecks -gt 0) { + Write-CIAnnotation -Level Error -Message "Devcontainer lockfile integrity check failed with $($result.FailedChecks) error(s)" + if ($FailOnViolation) { + exit 1 + } + } + else { + Write-Host "[PASS] Lockfile covers all features with SHA-256 integrity" + } + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Test-DevcontainerLockfile failed: $($_.Exception.Message)" + exit 1 + } +} + +#endregion Main Execution diff --git a/scripts/devcontainer/Write-DevcontainerChangeLog.ps1 b/scripts/devcontainer/Write-DevcontainerChangeLog.ps1 new file mode 100644 index 000000000..4dfb40514 --- /dev/null +++ b/scripts/devcontainer/Write-DevcontainerChangeLog.ps1 @@ -0,0 +1,198 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Classifies devcontainer file changes and generates a markdown summary. + +.DESCRIPTION + Analyzes git diff output to identify changed devcontainer infrastructure files, + classifies each by category and pre-build impact, and produces a markdown summary + table. In CI, writes to GITHUB_STEP_SUMMARY; locally, writes to stdout. + +.PARAMETER CommitSha + The commit SHA to diff against. Defaults to HEAD when not specified. + +.PARAMETER BranchName + The branch name for display in the summary header. + +.PARAMETER EventName + The GitHub event name that triggered the workflow. Defaults to 'local'. + +.PARAMETER BeforeSha + The before-push SHA for computing the diff range. + +.PARAMETER RepoRoot + Root directory of the repository. Defaults to git toplevel or script directory. + +.EXAMPLE + ./Write-DevcontainerChangeLog.ps1 + Generate a local devcontainer change summary for the current HEAD. + +.EXAMPLE + ./Write-DevcontainerChangeLog.ps1 -CommitSha "abc123" -BeforeSha "def456" -BranchName "main" -EventName "push" + Generate a change summary for a specific commit range in CI. + +.NOTES + Runs via: npm run devcontainer:changelog (when configured) + Replaces inline bash in .github/workflows/devcontainer-change-log.yml +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$CommitSha, + + [Parameter(Mandatory = $false)] + [string]$BranchName, + + [Parameter(Mandatory = $false)] + [string]$EventName = 'local', + + [Parameter(Mandatory = $false)] + [string]$BeforeSha, + + [Parameter(Mandatory = $false)] + [string]$RepoRoot = (git rev-parse --show-toplevel 2>$null) +) + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $PSScriptRoot } + +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force + +#region Functions + +function Get-DevcontainerFileClassification { + <# + .SYNOPSIS + Classifies a devcontainer file path by category and impact. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$FilePath + ) + + switch -Wildcard ($FilePath) { + '.devcontainer/scripts/on-create.sh' { return @{ Category = 'Lifecycle Scripts'; Impact = 'High' } } + '.devcontainer/scripts/post-create.sh' { return @{ Category = 'Lifecycle Scripts'; Impact = 'Low' } } + { $_ -like '.devcontainer/Dockerfile*' -or $_ -like '.devcontainer/*.dockerfile' } { return @{ Category = 'Base Image'; Impact = 'High' } } + '.devcontainer/features/*' { return @{ Category = 'Features'; Impact = 'Medium' } } + '.devcontainer/devcontainer.json' { return @{ Category = 'Config'; Impact = 'High' } } + '.devcontainer/devcontainer-lock.json' { return @{ Category = 'Lockfile'; Impact = 'Medium' } } + '.github/workflows/copilot-setup-steps.yml' { return @{ Category = 'Setup Steps'; Impact = 'Medium' } } + '.devcontainer/*' { return @{ Category = 'Config'; Impact = 'Medium' } } + default { return @{ Category = 'Other'; Impact = 'Unknown' } } + } +} + +function New-DevcontainerChangeSummary { + <# + .SYNOPSIS + Builds a markdown change summary for devcontainer infrastructure files. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false)] + [string]$CommitSha, + + [Parameter(Mandatory = $false)] + [string]$BranchName, + + [Parameter(Mandatory = $false)] + [string]$EventName = 'local', + + [Parameter(Mandatory = $false)] + [string]$BeforeSha, + + [Parameter(Mandatory = $false)] + [string]$RepoRoot + ) + + if ([string]::IsNullOrWhiteSpace($CommitSha)) { + $CommitSha = git -C $RepoRoot rev-parse HEAD 2>$null + } + if ([string]::IsNullOrWhiteSpace($BranchName)) { + $BranchName = git -C $RepoRoot rev-parse --abbrev-ref HEAD 2>$null + } + + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('## Devcontainer Infrastructure Changes') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Property | Value |') + [void]$sb.AppendLine('|----------|-------|') + [void]$sb.AppendLine("| Commit | ``$CommitSha`` |") + [void]$sb.AppendLine("| Branch | ``$BranchName`` |") + [void]$sb.AppendLine("| Trigger | ``$EventName`` |") + [void]$sb.AppendLine('') + + if ($EventName -eq 'workflow_dispatch') { + [void]$sb.AppendLine('_Triggered via workflow_dispatch. No push range available for automatic diff._') + return $sb.ToString() + } + + if ($BeforeSha -eq '0000000000000000000000000000000000000000') { + [void]$sb.AppendLine('_Initial push to branch -- no prior commit range available._') + return $sb.ToString() + } + + if ([string]::IsNullOrWhiteSpace($BeforeSha)) { + # Local run without a before SHA -- diff HEAD~1 as a reasonable default + $BeforeSha = git -C $RepoRoot rev-parse 'HEAD~1' 2>$null + if ($LASTEXITCODE -ne 0) { + [void]$sb.AppendLine('_No prior commit available for diff (initial commit?)._') + return $sb.ToString() + } + } + + $changed = git -C $RepoRoot diff --name-only $BeforeSha $CommitSha -- '.devcontainer/' '.github/workflows/copilot-setup-steps.yml' 2>&1 + if ($LASTEXITCODE -ne 0) { + [void]$sb.AppendLine("_Could not compute diff: ``$BeforeSha`` may not be reachable (force push?)._") + return $sb.ToString() + } + + $files = ($changed -split "`n") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if (-not $files -or $files.Count -eq 0) { + [void]$sb.AppendLine('_No devcontainer infrastructure files changed in this push._') + return $sb.ToString() + } + + [void]$sb.AppendLine('| File | Category | Pre-build Impact |') + [void]$sb.AppendLine('|------|----------|-----------------|') + foreach ($file in $files) { + $classification = Get-DevcontainerFileClassification -FilePath $file + [void]$sb.AppendLine("| ``$file`` | $($classification.Category) | $($classification.Impact) |") + } + + return $sb.ToString() +} + +#endregion Functions + +#region Main Execution + +if ($MyInvocation.InvocationName -ne '.') { + try { + $markdown = New-DevcontainerChangeSummary -CommitSha $CommitSha -BranchName $BranchName -EventName $EventName -BeforeSha $BeforeSha -RepoRoot $RepoRoot + + if ($env:GITHUB_STEP_SUMMARY) { + $markdown | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding UTF8 + } + else { + Write-Output $markdown + } + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Write-DevcontainerChangeLog failed: $($_.Exception.Message)" + exit 1 + } +} + +#endregion Main Execution diff --git a/scripts/tests/devcontainer/Test-DevcontainerLockfile.Tests.ps1 b/scripts/tests/devcontainer/Test-DevcontainerLockfile.Tests.ps1 new file mode 100644 index 000000000..8db0a2fde --- /dev/null +++ b/scripts/tests/devcontainer/Test-DevcontainerLockfile.Tests.ps1 @@ -0,0 +1,207 @@ +#Requires -Modules Pester +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +BeforeAll { + . (Join-Path $PSScriptRoot '../../devcontainer/Test-DevcontainerLockfile.ps1') + Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1') -Force + + $script:FixturesPath = Join-Path $PSScriptRoot '../fixtures/Devcontainer' + + Mock Write-Host {} + Mock Write-CIAnnotation {} +} + +Describe 'Test-LockfileExists' -Tag 'Unit' { + Context 'when lockfile exists' { + It 'Returns Passed true' { + $lockDir = Join-Path $TestDrive '.devcontainer' + New-Item -ItemType Directory -Path $lockDir -Force | Out-Null + '{}' | Set-Content -Path (Join-Path $lockDir 'devcontainer-lock.json') + + $result = Test-LockfileExists -RepoRoot $TestDrive + + $result.Passed | Should -BeTrue + } + } + + Context 'when lockfile is missing' { + It 'Returns Passed false' { + $result = Test-LockfileExists -RepoRoot $TestDrive + + $result.Passed | Should -BeFalse + } + } +} + +Describe 'Test-FeatureIntegrity' -Tag 'Unit' { + Context 'when all features have valid integrity' { + It 'Returns Passed true with no violations' { + $lockfile = Join-Path $script:FixturesPath 'valid-lock.json' + + $result = Test-FeatureIntegrity -LockfilePath $lockfile + + $result.Passed | Should -BeTrue + $result.Violations | Should -HaveCount 0 + } + } + + Context 'when feature is missing integrity' { + It 'Returns violation for missing integrity' { + $lockfile = Join-Path $script:FixturesPath 'missing-integrity-lock.json' + + $result = Test-FeatureIntegrity -LockfilePath $lockfile + + $result.Passed | Should -BeFalse + $result.Violations | Should -HaveCount 1 + } + } + + Context 'when feature is missing resolved' { + It 'Returns violation for missing resolved' { + $lockfile = Join-Path $script:FixturesPath 'missing-resolved-lock.json' + + $result = Test-FeatureIntegrity -LockfilePath $lockfile + + $result.Passed | Should -BeFalse + $result.Violations.Count | Should -BeGreaterThan 0 + } + } + + Context 'when integrity has wrong prefix' { + It 'Returns violation for non-sha256 hash' { + $lockfile = Join-Path $script:FixturesPath 'wrong-hash-lock.json' + + $result = Test-FeatureIntegrity -LockfilePath $lockfile + + $result.Passed | Should -BeFalse + } + } + + Context 'when features object is empty' { + It 'Returns Passed true' { + $lockfile = Join-Path $script:FixturesPath 'empty-features-lock.json' + + $result = Test-FeatureIntegrity -LockfilePath $lockfile + + $result.Passed | Should -BeTrue + } + } +} + +Describe 'Test-FeatureCoverage' -Tag 'Unit' { + Context 'when all config features are in lockfile' { + It 'Returns Passed true' { + $lockfile = Join-Path $script:FixturesPath 'valid-lock.json' + $config = Join-Path $script:FixturesPath 'valid-config.json' + + $result = Test-FeatureCoverage -LockfilePath $lockfile -ConfigPath $config + + $result.Passed | Should -BeTrue + $result.MissingKeys | Should -HaveCount 0 + } + } + + Context 'when config has extra features' { + It 'Returns MissingKeys containing the extra feature' { + $lockfile = Join-Path $script:FixturesPath 'valid-lock.json' + $config = Join-Path $script:FixturesPath 'extra-config-features.json' + + $result = Test-FeatureCoverage -LockfilePath $lockfile -ConfigPath $config + + $result.Passed | Should -BeFalse + $result.MissingKeys | Should -Contain 'ghcr.io/devcontainers/features/go:1' + } + } + + Context 'when both have empty features' { + It 'Returns Passed true' { + $lockfile = Join-Path $script:FixturesPath 'empty-features-lock.json' + $config = Join-Path $script:FixturesPath 'empty-features-config.json' + + $result = Test-FeatureCoverage -LockfilePath $lockfile -ConfigPath $config + + $result.Passed | Should -BeTrue + } + } + + Context 'when comparison is case-insensitive' { + It 'Matches features regardless of case' { + $lockDir = Join-Path $TestDrive 'case-test' + New-Item -ItemType Directory -Path $lockDir -Force | Out-Null + + $lockContent = @{ + features = @{ + 'ghcr.io/devcontainers/features/Node:1' = @{ + resolved = 'ghcr.io/devcontainers/features/node@sha256:abc123' + integrity = 'sha256:abc123' + } + } + } | ConvertTo-Json -Depth 5 + $lockPath = Join-Path $lockDir 'lock.json' + $lockContent | Set-Content -Path $lockPath + + $configContent = @{ + features = @{ + 'ghcr.io/devcontainers/features/node:1' = @{} + } + } | ConvertTo-Json -Depth 5 + $configPath = Join-Path $lockDir 'config.json' + $configContent | Set-Content -Path $configPath + + $result = Test-FeatureCoverage -LockfilePath $lockPath -ConfigPath $configPath + + $result.Passed | Should -BeTrue + } + } +} + +Describe 'Invoke-LockfileValidation' -Tag 'Unit' { + Context 'when all checks pass' { + It 'Returns FailedChecks 0' { + $devDir = Join-Path $TestDrive '.devcontainer' + New-Item -ItemType Directory -Path $devDir -Force | Out-Null + + $lockContent = Get-Content -Path (Join-Path $script:FixturesPath 'valid-lock.json') -Raw + $lockContent | Set-Content -Path (Join-Path $devDir 'devcontainer-lock.json') + + $configContent = Get-Content -Path (Join-Path $script:FixturesPath 'valid-config.json') -Raw + $configContent | Set-Content -Path (Join-Path $devDir 'devcontainer.json') + + $result = Invoke-LockfileValidation -RepoRoot $TestDrive + + $result.FailedChecks | Should -Be 0 + } + } + + Context 'when lockfile is missing' { + It 'Returns FailedChecks greater than 0' { + $devDir = Join-Path $TestDrive '.devcontainer' + New-Item -ItemType Directory -Path $devDir -Force | Out-Null + + $configContent = Get-Content -Path (Join-Path $script:FixturesPath 'valid-config.json') -Raw + $configContent | Set-Content -Path (Join-Path $devDir 'devcontainer.json') + + $result = Invoke-LockfileValidation -RepoRoot $TestDrive + + $result.FailedChecks | Should -BeGreaterThan 0 + } + } + + Context 'when integrity violations exist' { + It 'Calls Write-CIAnnotation' { + $devDir = Join-Path $TestDrive '.devcontainer' + New-Item -ItemType Directory -Path $devDir -Force | Out-Null + + $lockContent = Get-Content -Path (Join-Path $script:FixturesPath 'missing-integrity-lock.json') -Raw + $lockContent | Set-Content -Path (Join-Path $devDir 'devcontainer-lock.json') + + $configContent = Get-Content -Path (Join-Path $script:FixturesPath 'valid-config.json') -Raw + $configContent | Set-Content -Path (Join-Path $devDir 'devcontainer.json') + + Invoke-LockfileValidation -RepoRoot $TestDrive + + Should -Invoke Write-CIAnnotation -Times 1 + } + } +} diff --git a/scripts/tests/devcontainer/Write-DevcontainerChangeLog.Tests.ps1 b/scripts/tests/devcontainer/Write-DevcontainerChangeLog.Tests.ps1 new file mode 100644 index 000000000..87f8b66ed --- /dev/null +++ b/scripts/tests/devcontainer/Write-DevcontainerChangeLog.Tests.ps1 @@ -0,0 +1,97 @@ +#Requires -Modules Pester +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +BeforeAll { + . (Join-Path $PSScriptRoot '../../devcontainer/Write-DevcontainerChangeLog.ps1') + Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1') -Force + + Mock Write-Host {} + Mock Write-CIAnnotation {} + Mock Write-CIStepSummary {} +} + +Describe 'Get-DevcontainerFileClassification' -Tag 'Unit' { + It 'Classifies as with impact' -ForEach @( + @{ Path = '.devcontainer/scripts/on-create.sh'; Category = 'Lifecycle Scripts'; Impact = 'High' } + @{ Path = '.devcontainer/scripts/post-create.sh'; Category = 'Lifecycle Scripts'; Impact = 'Low' } + @{ Path = '.devcontainer/Dockerfile'; Category = 'Base Image'; Impact = 'High' } + @{ Path = '.devcontainer/Dockerfile.custom'; Category = 'Base Image'; Impact = 'High' } + @{ Path = '.devcontainer/base.dockerfile'; Category = 'Base Image'; Impact = 'High' } + @{ Path = '.devcontainer/features/custom-feature'; Category = 'Features'; Impact = 'Medium' } + @{ Path = '.devcontainer/devcontainer.json'; Category = 'Config'; Impact = 'High' } + @{ Path = '.devcontainer/devcontainer-lock.json'; Category = 'Lockfile'; Impact = 'Medium' } + @{ Path = '.github/workflows/copilot-setup-steps.yml'; Category = 'Setup Steps'; Impact = 'Medium' } + @{ Path = '.devcontainer/something-else.txt'; Category = 'Config'; Impact = 'Medium' } + @{ Path = 'unrelated/file.txt'; Category = 'Other'; Impact = 'Unknown' } + ) { + $result = Get-DevcontainerFileClassification -FilePath $Path + $result.Category | Should -Be $Category + $result.Impact | Should -Be $Impact + } +} + +Describe 'New-DevcontainerChangeSummary' -Tag 'Unit' { + Context 'when EventName is workflow_dispatch' { + It 'Returns markdown with dispatch message' { + $result = New-DevcontainerChangeSummary -CommitSha 'abc123' -BranchName 'main' -EventName 'workflow_dispatch' + $result | Should -Match 'workflow_dispatch' + $result | Should -Match 'No push range available' + } + } + + Context 'when BeforeSha is all zeros' { + It 'Returns markdown with initial push message' { + $result = New-DevcontainerChangeSummary -CommitSha 'abc123' -BranchName 'main' -EventName 'push' -BeforeSha '0000000000000000000000000000000000000000' + $result | Should -Match 'Initial push' + } + } + + Context 'when git diff succeeds with changed files' { + BeforeAll { + Mock git { + $global:LASTEXITCODE = 0 + return @( + '.devcontainer/devcontainer.json' + '.devcontainer/scripts/on-create.sh' + ) + } -ParameterFilter { $args -contains 'diff' } + } + + It 'Returns markdown with classified file table' { + $result = New-DevcontainerChangeSummary -CommitSha 'abc123' -BranchName 'main' -EventName 'push' -BeforeSha 'def456' -RepoRoot '/fake' + $result | Should -Match 'devcontainer\.json' + $result | Should -Match 'on-create\.sh' + $result | Should -Match 'Config' + $result | Should -Match 'Lifecycle Scripts' + } + } + + Context 'when git diff fails (force push)' { + BeforeAll { + Mock git { + $global:LASTEXITCODE = 128 + return 'fatal: bad object abc123' + } -ParameterFilter { $args -contains 'diff' } + } + + It 'Returns markdown with force push message' { + $result = New-DevcontainerChangeSummary -CommitSha 'abc123' -BranchName 'main' -EventName 'push' -BeforeSha 'def456' -RepoRoot '/fake' + $result | Should -Match 'not be reachable' + } + } + + Context 'when git diff returns empty' { + BeforeAll { + Mock git { + $global:LASTEXITCODE = 0 + return '' + } -ParameterFilter { $args -contains 'diff' } + } + + It 'Returns markdown with no changes message' { + $result = New-DevcontainerChangeSummary -CommitSha 'abc123' -BranchName 'main' -EventName 'push' -BeforeSha 'def456' -RepoRoot '/fake' + $result | Should -Match 'No devcontainer infrastructure files changed' + } + } +} diff --git a/scripts/tests/fixtures/Devcontainer/empty-features-config.json b/scripts/tests/fixtures/Devcontainer/empty-features-config.json new file mode 100644 index 000000000..219acb6be --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/empty-features-config.json @@ -0,0 +1,3 @@ +{ + "features": {} +} diff --git a/scripts/tests/fixtures/Devcontainer/empty-features-lock.json b/scripts/tests/fixtures/Devcontainer/empty-features-lock.json new file mode 100644 index 000000000..219acb6be --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/empty-features-lock.json @@ -0,0 +1,3 @@ +{ + "features": {} +} diff --git a/scripts/tests/fixtures/Devcontainer/extra-config-features.json b/scripts/tests/fixtures/Devcontainer/extra-config-features.json new file mode 100644 index 000000000..57e4d0e20 --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/extra-config-features.json @@ -0,0 +1,7 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "24" }, + "ghcr.io/devcontainers/features/python:1": { "version": "3.11" }, + "ghcr.io/devcontainers/features/go:1": { "version": "1.21" } + } +} diff --git a/scripts/tests/fixtures/Devcontainer/missing-integrity-lock.json b/scripts/tests/fixtures/Devcontainer/missing-integrity-lock.json new file mode 100644 index 000000000..2e92ad052 --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/missing-integrity-lock.json @@ -0,0 +1,8 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:abc123" + } + } +} diff --git a/scripts/tests/fixtures/Devcontainer/missing-resolved-lock.json b/scripts/tests/fixtures/Devcontainer/missing-resolved-lock.json new file mode 100644 index 000000000..6d3172107 --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/missing-resolved-lock.json @@ -0,0 +1,8 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "integrity": "sha256:abc123" + } + } +} diff --git a/scripts/tests/fixtures/Devcontainer/valid-config.json b/scripts/tests/fixtures/Devcontainer/valid-config.json new file mode 100644 index 000000000..9e59a7d43 --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/valid-config.json @@ -0,0 +1,6 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "24" }, + "ghcr.io/devcontainers/features/python:1": { "version": "3.11" } + } +} diff --git a/scripts/tests/fixtures/Devcontainer/valid-lock.json b/scripts/tests/fixtures/Devcontainer/valid-lock.json new file mode 100644 index 000000000..723f92be9 --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/valid-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:abc123", + "integrity": "sha256:abc123" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/python@sha256:def456", + "integrity": "sha256:def456" + } + } +} diff --git a/scripts/tests/fixtures/Devcontainer/wrong-hash-lock.json b/scripts/tests/fixtures/Devcontainer/wrong-hash-lock.json new file mode 100644 index 000000000..11cae2d8c --- /dev/null +++ b/scripts/tests/fixtures/Devcontainer/wrong-hash-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:abc123", + "integrity": "md5:abc123" + } + } +} From 83a260607c12fda28c813d44de416bad498156ff Mon Sep 17 00:00:00 2001 From: Katrien De Graeve Date: Mon, 15 Jun 2026 07:03:48 +0000 Subject: [PATCH 4/5] style(docs): improve formatting in README for devcontainer scripts section --- scripts/README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 44cda4fe4..a19f703b1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -70,10 +70,10 @@ See [linting/README.md](linting/README.md) for detailed documentation. The `devcontainer/` directory contains scripts for devcontainer infrastructure validation: -| Script | Purpose | -|-------------------------------------|----------------------------------------------------------| -| `Test-DevcontainerLockfile.ps1` | Validate lockfile existence, SHA-256 integrity, coverage | -| `Write-DevcontainerChangeLog.ps1` | Classify changed files and generate markdown summary | +| Script | Purpose | +|-----------------------------------|----------------------------------------------------------| +| `Test-DevcontainerLockfile.ps1` | Validate lockfile existence, SHA-256 integrity, coverage | +| `Write-DevcontainerChangeLog.ps1` | Classify changed files and generate markdown summary | Run locally: @@ -114,17 +114,17 @@ Collection validation and shared helpers. Pester test organization matching the scripts structure. -| Directory | Tests For | -|------------------|---------------------------------| -| `collections/` | Collection helpers tests | -| `devcontainer/` | Devcontainer validation tests | -| `extension/` | Extension packaging tests | -| `lib/` | Library utility tests | -| `linting/` | Linting script tests | -| `security/` | Security validation tests | -| `plugins/` | Plugin generation tests | -| `Fixtures/` | Shared test fixtures | -| `Mocks/` | Shared mock data | +| Directory | Tests For | +|-----------------|-------------------------------| +| `collections/` | Collection helpers tests | +| `devcontainer/` | Devcontainer validation tests | +| `extension/` | Extension packaging tests | +| `lib/` | Library utility tests | +| `linting/` | Linting script tests | +| `security/` | Security validation tests | +| `plugins/` | Plugin generation tests | +| `Fixtures/` | Shared test fixtures | +| `Mocks/` | Shared mock data | Run all tests: From cd0e5feee6e94d5642034f407fe5cf7211b6061e Mon Sep 17 00:00:00 2001 From: katriendg Date: Tue, 16 Jun 2026 15:50:11 +0000 Subject: [PATCH 5/5] docs: update ms.date and add it in copilot repo rules --- .github/copilot-instructions.md | 5 +++++ scripts/README.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa2e00eda..f5c43fb26 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,6 +22,11 @@ Rules for comments: * Comments that contradict current behavior are removed or updated. * Temporal markers (phase references, dates, task IDs) are removed from code files during any edit. +Rules for markdown frontmatter: + +* When editing any Markdown file whose frontmatter already contains an `ms.date` field, update that field to today's date. +* Format the date using ISO 8601 (`YYYY-MM-DD`), matching the existing `ms.date` convention. + Rules for human review checkboxes: * Agents never check or mark complete any human review checkbox (for example, `- [ ] Reviewed and validated by a qualified human reviewer`). Only a human may convert `[ ]` to `[x]` on review checkboxes. diff --git a/scripts/README.md b/scripts/README.md index a19f703b1..080aab9ab 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,7 @@ title: Scripts description: PowerShell scripts for linting, validation, and security automation author: HVE Core Team -ms.date: 2026-03-17 +ms.date: 2026-06-16 ms.topic: reference keywords: - powershell