name: Stable Release Pipeline

on:
  push:
    branches:
      - main

  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

# 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

  dependency-pinning-scan:
    name: Dependency Pinning Scan
    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: true

  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: true

  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: 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

  docusaurus-tests:
    name: Docusaurus Tests
    uses: ./.github/workflows/docusaurus-tests.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Find Python projects
        id: find
        shell: pwsh
        run: |
          $projectList = @(Get-ChildItem -Recurse -Force -Filter pyproject.toml |
            Where-Object { $_.FullName -notmatch 'node_modules' } |
            ForEach-Object { Resolve-Path -Relative $_.DirectoryName } |
            ForEach-Object { $_ -replace '^\.[\\/]', '' } |
            Sort-Object)
          $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"
          }

  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: false
      working-directory: ${{ matrix.directory }}

  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: false
    strategy:
      fail-fast: false
      matrix:
        directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}

  release-please:
    name: Release Please
    needs:
      - spell-check
      - markdown-lint
      - table-format
      - dependency-pinning-scan
      - gitleaks-scan
      - pester-tests
      - docusaurus-tests
      - python-lint
      - pytest
    # Allow release-please to run when conditional CI jobs (python-lint,
    # pytest) are skipped. Block only on actual failures or cancellations.
    if: ${{ !cancelled() && !failure() }}
    runs-on: ubuntu-latest
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
      version: ${{ steps.release.outputs.version }}
      major: ${{ steps.release.outputs.major }}
      minor: ${{ steps.release.outputs.minor }}
      patch: ${{ steps.release.outputs.patch }}
    permissions:
      contents: write
    steps:
      - name: Generate GitHub App Token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2.0.0
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - name: Run release-please
        id: release
        uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
        with:
          token: ${{ steps.app-token.outputs.token }}
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json
          # On release commits, skip PR creation to prevent bogus PRs
          # from draft release lazy tag timing. Release commits have zero
          # unreleased changes so skipping loses nothing. The tag is
          # created in the next step, ensuring subsequent runs find it.
          skip-github-pull-request: ${{ github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(main)') }}

      - name: Validate even-minor convention
        if: ${{ steps.release.outputs.release_created == 'true' }}
        run: |
          MINOR="${{ steps.release.outputs.minor }}"
          if (( MINOR % 2 != 0 )); then
            echo "::error::Release version ${{ steps.release.outputs.version }} has odd minor ($MINOR). Even/odd convention requires even minor for stable releases."
            exit 1
          fi

      # Workaround: release-please with "draft": true uses lazy tag
      # creation. The git tag is not materialized until the release is
      # published. Without the tag, release-please cannot find the draft
      # on subsequent runs, breaking version anchoring and causing bogus
      # major-version PRs. The skip-github-pull-request conditional above
      # prevents PR creation on release commits (zero unreleased changes),
      # and this step creates the tag so subsequent runs find the release.
      # Replace both workarounds with "force-tag-creation": true in
      # release-please-config.json once release-please-action ships a
      # version that includes googleapis/release-please#2627.
      - name: Create git tag for draft release
        if: ${{ steps.release.outputs.release_created == 'true' }}
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          TAG="${{ steps.release.outputs.tag_name }}"
          REPO="${{ github.repository }}"
          if ! gh api "/repos/$REPO/git/refs/tags/$TAG" --silent 2>/dev/null; then
            gh api "/repos/$REPO/git/refs" \
              -f "ref=refs/tags/$TAG" \
              -f "sha=${{ github.sha }}"
            echo "Created git tag $TAG -> ${{ github.sha }}"
          else
            echo "Git tag $TAG already exists"
          fi

  close-milestone:
    name: Close Release Milestone
    needs: [release-please]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - name: Close milestone for released version
        env:
          GH_TOKEN: ${{ github.token }}
          MAJOR: ${{ needs.release-please.outputs.major }}
          MINOR: ${{ needs.release-please.outputs.minor }}
        run: |
          REPO="${{ github.repository }}"
          TITLE="v${MAJOR}.${MINOR}.0"
          NUMBER=$(gh api "/repos/$REPO/milestones?state=open&per_page=100" \
            --jq ".[] | select(.title == \"$TITLE\") | .number")
          if [ -n "$NUMBER" ]; then
            gh api "/repos/$REPO/milestones/$NUMBER" \
              -X PATCH -f state=closed
            echo "✅ Closed milestone $TITLE (#$NUMBER)"
          else
            echo "ℹ️ No open milestone found matching '$TITLE' — skipping"
          fi

  reset-prerelease:
    name: Reset Pre-Release Branch
    needs: [release-please]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Generate GitHub App Token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2.0.0
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}
          persist-credentials: false

      - name: Setup Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "24"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Install PowerShell-Yaml
        shell: pwsh
        run: |
          if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml | Where-Object { $_.Version -eq '0.4.7' })) {
            Install-Module -Name PowerShell-Yaml -RequiredVersion 0.4.7 -Force -Scope CurrentUser
          }

      - name: Reset pre-release branch
        id: reset-branch
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          REPO: ${{ github.repository }}
        run: |
          BRANCH="prerelease/next"
          RELEASED="${{ needs.release-please.outputs.version }}"
          MAJOR=$(echo "$RELEASED" | cut -d. -f1)
          MINOR=$(echo "$RELEASED" | cut -d. -f2)
          PRE_VERSION="${MAJOR}.$((MINOR + 1)).0"
          echo "pre-version=${PRE_VERSION}" >> "$GITHUB_OUTPUT"

          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"

          # Build the version-bump commit before pushing.
          # A single force-push of the final state prevents the remote from
          # ever seeing prerelease/next identical to main, which would cause
          # GitHub to auto-close the open PR.
          git checkout -B "$BRANCH"

          # Update all version-tracked files and regenerate plugin outputs
          pwsh -File scripts/release/Update-VersionFiles.ps1 -Version "$PRE_VERSION"

          git add -A
          git commit -m "chore: reset pre-release version to ${PRE_VERSION}" || echo "No version changes to commit"
          git push --force origin "$BRANCH"

      - name: Create or update pre-release PR
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          REPO: ${{ github.repository }}
        run: |
          BRANCH="prerelease/next"
          RELEASED="${{ needs.release-please.outputs.version }}"
          PRE_VERSION="${{ steps.reset-branch.outputs.pre-version }}"
          TITLE="chore(main): pre-release ${PRE_VERSION}"
          BODY="## Pre-Release ${PRE_VERSION}

          Reset after release ${RELEASED}. Next push to main will update this PR.

          ---
          *Managed automatically by pre-release workflow.*"

          PR_NUMBER=$(gh pr list --head "$BRANCH" --state open \
            --json number --jq '.[0].number // empty' \
            -R "$REPO")

          if [ -n "$PR_NUMBER" ]; then
            gh pr edit "$PR_NUMBER" \
              --title "$TITLE" \
              --body "$BODY" \
              -R "$REPO"
            echo "Updated PR #$PR_NUMBER to $PRE_VERSION"
          else
            gh pr create \
              --head "$BRANCH" \
              --base main \
              --title "$TITLE" \
              --body "$BODY" \
              --label "autorelease: prerelease" \
              -R "$REPO"
            echo "Created new pre-release PR"
          fi

  extension-package-release:
    name: Package VS Code Extensions (Release)
    needs: [release-please]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    uses: ./.github/workflows/extension-package.yml
    with:
      version: ${{ needs.release-please.outputs.version }}
    permissions:
      contents: read

  plugin-package-release:
    name: Package Plugins (Release)
    needs: [release-please]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    uses: ./.github/workflows/plugin-package.yml
    with:
      version: ${{ needs.release-please.outputs.version }}
    permissions:
      contents: read

  generate-dependency-sbom:
    name: Generate Dependency SBOM
    needs: [release-please]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      # write required for gh release upload to attach SBOM to the release
      contents: write
    steps:
      - name: Checkout dependency manifests
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          sparse-checkout: |
            package.json
            package-lock.json
            .syft.yaml
          sparse-checkout-cone-mode: false
          persist-credentials: false

      - name: Generate dependency SBOM
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
        with:
          path: .
          format: spdx-json
          output-file: dependencies.spdx.json
          upload-release-assets: false
          upload-artifact: false
          config: .syft.yaml
          dependency-snapshot: true

      - name: Upload dependency SBOM artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: sbom-dependencies
          path: dependencies.spdx.json

      - name: Upload dependency SBOM to release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release upload "${{ needs.release-please.outputs.tag_name }}" \
            "dependencies.spdx.json" \
            --clobber -R "${{ github.repository }}"

  attest-and-upload:
    name: Attest and Upload (${{ matrix.id }})
    needs: [release-please, extension-package-release, generate-dependency-sbom]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.extension-package-release.outputs.collections-matrix) }}
    permissions:
      contents: write
      id-token: write
      attestations: write
      artifact-metadata: write
    steps:
      - name: Checkout Syft config
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          sparse-checkout: .syft.yaml
          sparse-checkout-cone-mode: false
          persist-credentials: false

      - name: Download dependency SBOM
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: sbom-dependencies
          path: ./sbom

      - name: Download VSIX artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: extension-vsix-${{ matrix.id }}
          path: ./dist

      - name: Resolve VSIX filename
        id: vsix
        run: |
          shopt -s nullglob
          files=(dist/*.vsix)
          if [[ ${#files[@]} -eq 0 ]]; then
            echo "::error::No VSIX file found in dist/"
            exit 1
          fi
          if [[ ${#files[@]} -gt 1 ]]; then
            echo "::error::Expected exactly one VSIX file but found ${#files[@]}: ${files[*]}"
            exit 1
          fi
          VSIX_FILE="${files[0]}"
          VSIX_NAME=$(basename "$VSIX_FILE")
          echo "file=$VSIX_FILE" >> "$GITHUB_OUTPUT"
          echo "name=$VSIX_NAME" >> "$GITHUB_OUTPUT"

      - name: Generate SBOM
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
        with:
          file: ${{ steps.vsix.outputs.file }}
          format: spdx-json
          output-file: dist/${{ steps.vsix.outputs.name }}.spdx.json
          artifact-name: sbom-${{ matrix.id }}
          upload-release-assets: false
          upload-artifact: true
          config: .syft.yaml

      - name: Verify SBOM files exist
        run: |
          test -f "dist/${{ steps.vsix.outputs.name }}.spdx.json" \
            || { echo "::error::Per-VSIX SBOM not found: dist/${{ steps.vsix.outputs.name }}.spdx.json"; exit 1; }
          test -f "sbom/dependencies.spdx.json" \
            || { echo "::error::Dependency SBOM not found: sbom/dependencies.spdx.json"; exit 1; }

      - name: Attest build provenance
        id: attest
        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
        with:
          subject-path: ${{ steps.vsix.outputs.file }}

      - name: Attest SBOM
        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
        with:
          subject-path: ${{ steps.vsix.outputs.file }}
          sbom-path: dist/${{ steps.vsix.outputs.name }}.spdx.json

      - name: Attest dependency SBOM
        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
        with:
          subject-path: ${{ steps.vsix.outputs.file }}
          sbom-path: sbom/dependencies.spdx.json

      - name: Upload assets to GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
          BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
        run: |
          cp "$BUNDLE_PATH" \
            "dist/${{ steps.vsix.outputs.name }}.sigstore.json"
          jq -c '.dsseEnvelope' "$BUNDLE_PATH" > \
            "dist/${{ steps.vsix.outputs.name }}.intoto.jsonl"
          gh release upload "${{ needs.release-please.outputs.tag_name }}" \
            "${{ steps.vsix.outputs.file }}" \
            "dist/${{ steps.vsix.outputs.name }}.spdx.json" \
            "dist/${{ steps.vsix.outputs.name }}.sigstore.json" \
            "dist/${{ steps.vsix.outputs.name }}.intoto.jsonl" \
            --clobber -R "${{ github.repository }}"

  upload-plugin-packages:
    name: Attest and Upload Plugin (${{ matrix.id }})
    needs: [release-please, plugin-package-release, generate-dependency-sbom]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.plugin-package-release.outputs.collections-matrix) }}
    permissions:
      contents: write
      id-token: write
      attestations: write
      artifact-metadata: write
    steps:
      - name: Checkout Syft config
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          sparse-checkout: .syft.yaml
          sparse-checkout-cone-mode: false
          persist-credentials: false

      - name: Download dependency SBOM
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: sbom-dependencies
          path: ./sbom

      - name: Download plugin artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: plugin-package-${{ matrix.id }}
          path: ./dist/plugins

      - name: Verify plugin archive exists
        run: |
          PLUGIN_ARCHIVE="dist/plugins/${{ matrix.id }}.zip"
          if [ ! -f "$PLUGIN_ARCHIVE" ]; then
            echo "::error::Plugin archive not found for collection ${{ matrix.id }}"
            exit 1
          fi

      - name: Generate SBOM
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
        with:
          file: dist/plugins/${{ matrix.id }}.zip
          format: spdx-json
          output-file: dist/plugins/${{ matrix.id }}.zip.spdx.json
          artifact-name: sbom-plugin-${{ matrix.id }}
          upload-release-assets: false
          upload-artifact: true
          config: .syft.yaml

      - name: Verify SBOM files exist
        run: |
          test -f "dist/plugins/${{ matrix.id }}.zip.spdx.json" \
            || { echo "::error::Per-plugin SBOM not found: dist/plugins/${{ matrix.id }}.zip.spdx.json"; exit 1; }
          test -f "sbom/dependencies.spdx.json" \
            || { echo "::error::Dependency SBOM not found: sbom/dependencies.spdx.json"; exit 1; }

      - name: Attest build provenance
        id: attest
        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
        with:
          subject-path: dist/plugins/${{ matrix.id }}.zip

      - name: Attest SBOM
        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
        with:
          subject-path: dist/plugins/${{ matrix.id }}.zip
          sbom-path: dist/plugins/${{ matrix.id }}.zip.spdx.json

      - name: Attest dependency SBOM
        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
        with:
          subject-path: dist/plugins/${{ matrix.id }}.zip
          sbom-path: sbom/dependencies.spdx.json

      - name: Upload assets to GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
          BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
        run: |
          cp "$BUNDLE_PATH" \
            "dist/plugins/${{ matrix.id }}.zip.sigstore.json"
          jq -c '.dsseEnvelope' "$BUNDLE_PATH" > \
            "dist/plugins/${{ matrix.id }}.zip.intoto.jsonl"
          gh release upload "${{ needs.release-please.outputs.tag_name }}" \
            "dist/plugins/${{ matrix.id }}.zip" \
            "dist/plugins/${{ matrix.id }}.zip.spdx.json" \
            "dist/plugins/${{ matrix.id }}.zip.sigstore.json" \
            "dist/plugins/${{ matrix.id }}.zip.intoto.jsonl" \
            --clobber -R "${{ github.repository }}"

  sbom-diff:
    name: SBOM Dependency Diff
    needs: [release-please, generate-dependency-sbom]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    continue-on-error: true
    permissions:
      contents: write
    steps:
      - name: Download current dependency SBOM
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: sbom-dependencies
          path: ./current

      - name: Verify current dependency SBOM
        run: |
          test -f "current/dependencies.spdx.json" \
            || { echo "::error::Current dependency SBOM not found in artifact"; exit 1; }

      - name: Download previous release dependency SBOM
        id: previous
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          REPO="${{ github.repository }}"
          PREV_TAG=$(gh api "/repos/$REPO/releases/latest" --jq '.tag_name' 2>/dev/null || echo "")
          if [ -z "$PREV_TAG" ]; then
            echo "No previous release found"
            echo "found=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "Previous release: $PREV_TAG"
          echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
          mkdir -p previous
          if gh release download "$PREV_TAG" -R "$REPO" -p "dependencies.spdx.json" -D previous 2>/dev/null; then
            echo "found=true" >> "$GITHUB_OUTPUT"
          else
            echo "Previous release has no dependency SBOM"
            echo "found=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Generate dependency diff
        if: ${{ steps.previous.outputs.found == 'true' }}
        run: |
          python3 <<'PYTHON'
          import json
          import pathlib

          def load_packages(path):
              with open(path) as f:
                  data = json.load(f)
              root_ids = set(data.get('documentDescribes', []))
              pkgs = {}
              for p in data.get('packages', []):
                  if p.get('SPDXID', '') in root_ids:
                      continue
                  name = p.get('name', 'unknown')
                  version = p.get('versionInfo', 'unknown')
                  pkgs[name] = version
              return pkgs

          current = load_packages('current/dependencies.spdx.json')
          previous = load_packages('previous/dependencies.spdx.json')

          added = {k: v for k, v in current.items() if k not in previous}
          removed = {k: v for k, v in previous.items() if k not in current}
          changed = {
              k: (previous[k], current[k])
              for k in current
              if k in previous and current[k] != previous[k]
          }

          lines = ['# Dependency SBOM Diff', '']
          if not added and not removed and not changed:
              lines.append('No dependency changes detected.')
          else:
              if added:
                  lines.extend(['## Added', '', '| Package | Version |', '|---------|---------|'])
                  for name in sorted(added):
                      lines.append(f'| {name} | {added[name]} |')
                  lines.append('')
              if removed:
                  lines.extend(['## Removed', '', '| Package | Version |', '|---------|---------|'])
                  for name in sorted(removed):
                      lines.append(f'| {name} | {removed[name]} |')
                  lines.append('')
              if changed:
                  lines.extend([
                      '## Version Changes', '',
                      '| Package | Previous | Current |',
                      '|---------|----------|---------|',
                  ])
                  for name in sorted(changed):
                      old, new = changed[name]
                      lines.append(f'| {name} | {old} | {new} |')
                  lines.append('')

          lines.extend(['', '_Generated from SPDX JSON dependency SBOMs._', ''])
          pathlib.Path('dependency-diff.md').write_text('\n'.join(lines))
          count = len(added) + len(removed) + len(changed)
          print(f'Diff: {len(added)} added, {len(removed)} removed, {len(changed)} changed')
          PYTHON

      - name: Upload diff to GitHub Release
        if: ${{ steps.previous.outputs.found == 'true' }}
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release upload "${{ needs.release-please.outputs.tag_name }}" \
            "dependency-diff.md" \
            --clobber -R "${{ github.repository }}"

  append-verification-notes:
    name: Append Verification Instructions
    needs: [release-please, attest-and-upload, upload-plugin-packages]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Append verification section to release notes
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="${{ needs.release-please.outputs.tag_name }}"
          REPO="${{ github.repository }}"
          gh release view "$TAG" --json body --jq '.body' -R "$REPO" > notes.md
          cat >> notes.md <<'EOF'

          ## Verifying Downloads

          All release assets include [Sigstore](https://www.sigstore.dev/) build-provenance
          attestations generated by GitHub Actions. Verify any downloaded asset with the
          GitHub CLI:

          ```bash
          # Verify a VSIX extension package
          gh attestation verify <file>.vsix --repo microsoft/hve-core

          # Verify a plugin ZIP package
          gh attestation verify <file>.zip --repo microsoft/hve-core
          ```

          See [SECURITY.md](https://github.com/microsoft/hve-core/blob/main/SECURITY.md)
          for full supply-chain verification details including SBOM validation.
          EOF
          gh release edit "$TAG" --notes-file notes.md -R "$REPO"

  publish-release:
    name: Publish GitHub Release
    needs: [release-please, attest-and-upload, upload-plugin-packages, sbom-diff, append-verification-notes]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Publish GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="${{ needs.release-please.outputs.tag_name }}"
          REPO="${{ github.repository }}"
          # Promote draft to published. The release becomes immutable
          # after publish with all assets already attached.
          gh release edit "$TAG" --draft=false -R "$REPO"
