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

  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 # v4.2.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@29824e69f54612133e76f7eaac726eef6c875baf # 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@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.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@29824e69f54612133e76f7eaac726eef6c875baf # v2.0.0
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - name: Reset pre-release branch
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          BRANCH="prerelease/next"
          REPO="${{ github.repository }}"
          if gh api "/repos/$REPO/git/refs/heads/$BRANCH" --silent 2>/dev/null; then
            gh api "/repos/$REPO/git/refs/heads/$BRANCH" \
              -X PATCH \
              -f sha="${{ github.sha }}" \
              -F force=true
            echo "Reset $BRANCH to ${{ github.sha }}"
          fi

      - name: Update pre-release PR version
        # Placeholder version computation — release-prerelease-pr.yml is the authoritative source.
        # This provides an interim PR title until the next push to main triggers release-prerelease-pr.yml.
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          BRANCH="prerelease/next"
          PR_NUMBER=$(gh pr list --head "$BRANCH" --state open \
            --json number --jq '.[0].number // empty' \
            -R "${{ github.repository }}")
          if [ -n "$PR_NUMBER" ]; then
            RELEASED="${{ needs.release-please.outputs.version }}"
            MAJOR=$(echo "$RELEASED" | cut -d. -f1)
            MINOR=$(echo "$RELEASED" | cut -d. -f2)
            PRE_VERSION="${MAJOR}.$((MINOR + 1)).0"
            gh pr edit "$PR_NUMBER" \
              --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.*"
            echo "Updated PR #$PR_NUMBER to $PRE_VERSION"
          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:
      contents: write
    steps:
      - name: Checkout dependency manifests
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.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@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
        with:
          path: .
          format: spdx-json
          output-file: dependencies.spdx.json
          artifact-name: sbom-dependencies
          upload-release-assets: false
          upload-artifact: true
          config: .syft.yaml
          dependency-snapshot: true

      - 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 # v4.2.2
        with:
          sparse-checkout: .syft.yaml
          sparse-checkout-cone-mode: false
          persist-credentials: false

      - name: Download dependency SBOM
        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
        with:
          name: sbom-dependencies
          path: ./dist

      - name: Download VSIX artifact
        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
        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@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.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: Attest build provenance
        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: dist/dependencies.spdx.json

      - name: Upload assets to GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release upload "${{ needs.release-please.outputs.tag_name }}" \
            "${{ steps.vsix.outputs.file }}" \
            "dist/${{ steps.vsix.outputs.name }}.spdx.json" \
            --clobber -R "${{ github.repository }}"

  upload-plugin-packages:
    name: Upload Plugin Package (${{ matrix.id }})
    needs: [release-please, plugin-package-release]
    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
    steps:
      - name: Download plugin artifact
        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
        with:
          name: plugin-package-${{ matrix.id }}
          path: ./dist/plugins

      - name: Upload plugin package to GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        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
          gh release upload "${{ needs.release-please.outputs.tag_name }}" "$PLUGIN_ARCHIVE" --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@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
        with:
          name: sbom-dependencies
          path: ./current

      - 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 }}"

  publish-release:
    name: Publish GitHub Release
    needs: [release-please, attest-and-upload, upload-plugin-packages, sbom-diff]
    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"
