name: Build

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

# Serialize builds on main so that two pushes landing close together
# do not race on the release-tag step. PR builds (which use a per-PR
# ref) cancel earlier runs of the same PR.
concurrency:
  group: build-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: write
  id-token: write

jobs:
  prep:
    name: Prepare the build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
  build:
    name: Build on ${{ matrix.os }} with Java ${{ matrix.java }}
    needs: prep
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        java:
          - 25
        include:
          - os: ubuntu-latest
            java: 17
          - os: ubuntu-latest
            java: 21
          - os: ubuntu-latest
            java: 26

    env:
      IS_MAIN_RELEASE_BUILDER: ${{ (github.event_name == 'push'
                              || github.event_name == 'workflow_dispatch') &&
                              github.ref == 'refs/heads/main' &&
                              matrix.os == 'ubuntu-latest' &&
                              matrix.java == 25 }}
      IS_ANY_RELEASE_BUILDER: ${{ (github.event_name == 'push'
                              || github.event_name == 'workflow_dispatch') &&
                              github.ref == 'refs/heads/main' &&
                              matrix.java == 25 }}
      IS_PUSH_TO_MAIN: ${{ github.event_name == 'push' &&
                              github.ref == 'refs/heads/main' &&
                              matrix.java == 25 }}
    steps:
      - uses: actions/checkout@v6
      - name: Set up Java JDK
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: ${{ matrix.java }}
      - uses: actions/setup-python@v6
        with:
          python-version: '3.13'
      - name: Install dependencies
        run: python checker.py dldeps
      - name: Build
        shell: bash
        run: |
          version="$(date +"%y.%-m.%-d") ($(git rev-parse --short ${{ github.sha }}))"
          python checker.py --version="$version" build
          python checker.py --version="$version" jar
          python checker.py --version="$version" war
      - name: Build runtime image
        if: ${{ env.IS_ANY_RELEASE_BUILDER == 'true' }}
        shell: bash
        run: |
          version="$(date +"%y.%-m.%-d") ($(git rev-parse --short ${{ github.sha }}))"
          python checker.py --version="$version" image
      - name: Set up Playwright for e2e tests
        if: ${{ env.IS_ANY_RELEASE_BUILDER == 'true' }}
        run: |
          npm install -g pnpm
          pnpm install
          pnpm exec playwright install --with-deps chromium
      - name: Test
        if: ${{ env.IS_ANY_RELEASE_BUILDER == 'true' }}
        shell: bash
        run: |
          java -jar build/dist/vnu.jar site/nu-about.html
          python checker.py self-test
          python checker.py test
      - name: Upload e2e failure artifacts
        # The e2e tests interact with the checker only through a browser,
        # so a server-side failure surfaces here just as a timed-out wait
        # for a selector. Playwright records the page it actually got in
        # test-results/, which is where the underlying error shows up.
        if: ${{ failure() && env.IS_ANY_RELEASE_BUILDER == 'true' }}
        uses: actions/upload-artifact@v7
        with:
          name: e2e-failure-${{ matrix.os }}-java${{ matrix.java }}
          path: |
            test-results/
            playwright-report/
          retention-days: 7
          if-no-files-found: ignore
      - name: Test specs
        if: ${{ env.IS_MAIN_RELEASE_BUILDER == 'true' }}
        shell: bash
        run: |
          python checker.py test-specs
      - name: Import GPG signing key
        if: ${{ env.IS_ANY_RELEASE_BUILDER == 'true' }}
        uses: crazy-max/ghaction-import-gpg@v7
        with:
          gpg_private_key: ${{ secrets.GPG_SIGNING_KEY }}
          passphrase: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }}
          git_user_signingkey: true
          git_commit_gpgsign: true
      - name: Sign https://github.com/validator/validator/releases files
        if: ${{ env.IS_ANY_RELEASE_BUILDER == 'true' }}
        shell: bash
        run: |
          python checker.py sign
      - name: Upload release artifacts (zip files)
        if: ${{ env.IS_ANY_RELEASE_BUILDER == 'true' }}
        uses: actions/upload-artifact@v7
        with:
          name: release-zips-${{ matrix.os }}
          path: build/dist/*.zip*
          retention-days: 1
      - name: Upload release artifacts (jar and war)
        if: ${{ env.IS_MAIN_RELEASE_BUILDER == 'true' }}
        uses: actions/upload-artifact@v7
        with:
          name: release-jars
          path: |
            build/dist/*.jar*
            build/dist-war/*.war*
          retention-days: 1
      - name: Add the release tag
        if: ${{ env.IS_MAIN_RELEASE_BUILDER == 'true' }}
        run: |
          # actions/checkout doesn't fetch tags by default, so the
          # version-computation below would not see existing remote
          # tags without this fetch.
          git fetch --tags --quiet origin
          PREFIX=$(date +%y.%-m.%-d)
          LAST=$(git tag --list "${PREFIX}.*" | grep -E "^${PREFIX}\.[0-9]+$" | awk -F. '{print $NF}' | sort -n | tail -1)
          if ! git tag --list "${PREFIX}" | grep -q .; then
            VERSION="${PREFIX}"
          elif [[ -z "$LAST" ]]; then
            VERSION="${PREFIX}.1"
          else
            VERSION="${PREFIX}.$((LAST + 1))"
          fi
          git tag -s -m "$VERSION" "$VERSION"
      - name: Update the 'latest' tag
        if: ${{ env.IS_MAIN_RELEASE_BUILDER == 'true' }}
        run: |
          # Force-update the tag in place rather than delete-then-recreate.
          # Deleting a tag on origin detaches its GitHub Release into Draft
          # state (with an "untagged-…" URL); the release-assets job below
          # would normally republish, but it's skipped whenever any matrix
          # build fails — leaving the release stuck as a draft. A force-
          # push updates the tag pointer atomically, with no transient
          # period where the tag is missing.
          #
          git tag -fs -m latest latest
          git push origin main
          git push --force origin refs/tags/latest
          git push origin --tags

  release-assets:
    name: Upload release assets
    needs: build
    runs-on: ubuntu-latest
    if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
    steps:
      - uses: actions/checkout@v6
      - name: Download all release artifacts
        uses: actions/download-artifact@v8
        with:
          pattern: release-*
          path: artifacts
          merge-multiple: true
      - name: List downloaded artifacts
        run: find artifacts -type f | sort
      - name: Flatten artifacts directory
        run: |
          find artifacts -mindepth 2 -type f -exec mv {} artifacts/ \;
          find artifacts -type d -empty -delete
      - name: Upload all assets to release
        uses: ncipollo/release-action@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit: ${{ github.sha }}
          allowUpdates: true
          artifacts: artifacts/*
          bodyFile: "IMPORTANT.md"
          omitName: true
          tag: latest
          draft: false
          # Pin the "Latest" badge to this release. Without this,
          # ncipollo defaults to "legacy" which lets GitHub auto-pick
          # by highest semver tag — so the badge would jump to an
          # old release (e.g. 20.6.30) on every update.
          makeLatest: true
          # Wipe any pre-existing assets first — so "orphaned" files the
          # build no longer produces don't linger on.
          removeArtifacts: true
      - name: Refresh the release's published_at timestamp
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: |
          set -euo pipefail
          # GitHub's release page shows "github-actions released this
          # N ago" using the release's published_at field, which only
          # updates when a release transitions from Draft to Published.
          # Without this step, published_at sticks at whenever the
          # release was first published; subsequent assets/metadata
          # updates from ncipollo above don't bump it. Briefly toggle
          # draft so published_at gets refreshed each build.
          REL_ID=$(gh api "repos/$GH_REPO/releases/tags/latest" --jq '.id')
          gh api -X PATCH "repos/$GH_REPO/releases/$REL_ID" -F draft=true \
            >/dev/null
          gh api -X PATCH "repos/$GH_REPO/releases/$REL_ID" -F draft=false \
            >/dev/null
          echo "Refreshed published_at on release $REL_ID."

  verify-release-assets:
    name: Verify release assets
    needs: release-assets
    runs-on: ubuntu-latest
    if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
    steps:
      - name: Set up Java
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: 17
      - name: Verify JAR and WAR versions match expected
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euxo pipefail
          EXPECTED_VERSION=$(date +"%y.%-m.%-d")
          echo "Expected version: $EXPECTED_VERSION"

          echo "Downloading vnu.jar from release..."
          curl -fL https://github.com/${{ github.repository }}/releases/download/latest/vnu.jar -o vnu.jar
          JAR_VERSION=$(java -jar vnu.jar --version)
          echo "vnu.jar version: $JAR_VERSION"
          # Extract just the version number (before any space/parenthesis)
          JAR_VERSION_NUM=$(echo "$JAR_VERSION" | cut -d' ' -f1)
          if [[ "$JAR_VERSION_NUM" != "$EXPECTED_VERSION" ]]; then
            echo "::error::vnu.jar version mismatch! Expected $EXPECTED_VERSION, got $JAR_VERSION_NUM"
            exit 1
          fi
          echo "\u2705 vnu.jar version matches expected"

          echo "Downloading vnu.war from release..."
          curl -fL https://github.com/${{ github.repository }}/releases/download/latest/vnu.war -o vnu.war
          mkdir war-extract
          cd war-extract
          unzip -q ../vnu.war
          WAR_VERSION=$(java -cp "WEB-INF/classes:WEB-INF/lib/*" nu.validator.servlet.VerifierServlet --version 2>/dev/null || echo "unknown")
          cd ..
          # WAR version check is best-effort; main check is that it downloaded successfully
          echo "vnu.war extracted successfully"
          echo "\u2705 vnu.war is valid"
      - name: Verify release assets and ensure release is published
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          EXPECTED_ASSETS: |
            vnu.jar
            vnu.war
            vnu.linux.zip
            vnu.osx.zip
            vnu.windows.zip
        run: |
          set -euxo pipefail
          verify_release() {
            echo 'Fetching release metadata for tag “latest”…'
            release_json=$(gh api repos/${{ github.repository }}/releases/tags/latest)
            asset_names=$(echo "$release_json" | jq -r '.assets[].name')
            echo "Assets found in release:"
            echo "$asset_names"
            missing=0
            for expected in $EXPECTED_ASSETS; do
              if ! echo "$asset_names" | grep -qx "$expected"; then
                echo "::error::Missing expected asset: $expected"
                missing=1
              fi
            done
            if [ "$missing" -ne 0 ]; then
              echo "::error::One or more expected release assets are missing!"
              return 1
            fi
            echo "✅ All expected release assets are present."
            echo "Verifying that release assets are downloadable..."
            for asset_url in $(echo "$release_json" | jq -r '.assets[].browser_download_url'); do
              if ! curl -fL --head --silent --max-time 20 "$asset_url" >/dev/null; then
                echo "::error::Asset not downloadable: $asset_url"
                return 1
              fi
            done
            echo "✅ All release assets are downloadable."
            if [ "$(echo "$release_json" | jq -r '.draft')" = "true" ]; then
              echo "::error::Release is in Draft state!"
              return 1
            fi
            echo "✅ Release is not in Draft state."
            if [ "$(echo "$release_json" | jq -r '.prerelease')" = "true" ]; then
              echo "::error::Release is marked as a Pre-release!"
              return 1
            fi
            echo "✅ Release is not marked as a Pre-release."
            return 0
          }
          max_attempts=5
          attempt=1
          while [ $attempt -le $max_attempts ]; do
            echo "Verification attempt $attempt of $max_attempts..."
            if verify_release; then
              echo "✅ All release checks passed."
              exit 0
            fi

            # Check if the failure was due to draft state — if so, publish it
            release_json=$(gh api repos/${{ github.repository }}/releases/tags/latest)
            if [ "$(echo "$release_json" | jq -r '.draft')" = "true" ]; then
              echo "⚠️ Release is in Draft state (likely due to race conditions) — publishing it now..."
              gh release edit latest --draft=false
              echo "✅ Release has been published."
            fi

            if [ $attempt -lt $max_attempts ]; then
              echo "⚠️ Verification failed, waiting 30 seconds before retry..."
              sleep 30
            fi
            attempt=$((attempt + 1))
          done

          echo "::error::Release verification failed after $max_attempts attempts!"
          exit 1

  npm-release:
    name: Release new npm package
    needs: [build, verify-release-assets]
    if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: write
    steps:
      - uses: actions/checkout@v6
      - name: Download vnu.jar from GitHub release
        shell: bash
        run: |
          mkdir -p build/dist
          echo "Downloading vnu.jar from GitHub release..."
          if ! curl -fL https://github.com/validator/validator/releases/download/latest/vnu.jar -o build/dist/vnu.jar; then
            echo "::error::Failed to download vnu.jar from GitHub release"
            exit 1
          fi
          jar_size=$(stat -c%s build/dist/vnu.jar 2>/dev/null || stat -f%z build/dist/vnu.jar)
          echo "Downloaded vnu.jar size: $jar_size bytes"
          if [ "$jar_size" -lt 1000000 ]; then
            echo "::error::Downloaded vnu.jar is too small ($jar_size bytes), likely not a valid jar file!"
            echo "Content of file:"
            head -c 200 build/dist/vnu.jar
            exit 1
          fi
          if ! file build/dist/vnu.jar | grep "Java archive" > /dev/null; then
            echo "::error::Downloaded file is not a valid Java archive!"
            file build/dist/vnu.jar
            head -c 200 build/dist/vnu.jar
            exit 1
          fi
          echo "✅ vnu.jar successfully downloaded ($jar_size bytes)"
      - name: Run checker.py npm-release
        shell: bash
        run: |
          unset NODE_AUTH_TOKEN
          npm config set prefix "${HOME}/.npm-global"
          export PATH="$HOME/.npm-global/bin:$PATH"
          npm install -g npm@latest
          echo "npm version: $(npm --version)"
          python ./checker.py npm-release
      - name: Verify npm package includes vnu.jar
        shell: bash
        run: |
          echo "Testing that npm pack includes vnu.jar correctly..."
          npm pack --quiet
          if ! tar -tzf vnu-jar-*.tgz | grep "package/build/dist/vnu.jar" > /dev/null; then
            echo "::error::vnu.jar not found in npm package!"
            exit 1
          fi
          tar -xzf vnu-jar-*.tgz
          jar_size=$(stat -c%s package/build/dist/vnu.jar 2>/dev/null || stat -f%z package/build/dist/vnu.jar)
          echo "vnu.jar size in package: $jar_size bytes"
          if [ "$jar_size" -lt 1000000 ]; then
            echo "::error::vnu.jar in package is too small ($jar_size bytes), likely corrupted!"
            exit 1
          fi
          if ! file package/build/dist/vnu.jar | grep "Java archive" > /dev/null; then
            echo "::error::vnu.jar is not a valid Java archive!"
            file package/build/dist/vnu.jar
            exit 1
          fi
          echo "✅ vnu.jar is correctly included in npm package ($jar_size bytes)"

  verify-npm-package:
    name: Verify published npm package
    needs: npm-release
    if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
    runs-on: ubuntu-latest
    steps:
      - name: Set up Java
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: 17
      - name: Test npx vnu-jar@latest
        shell: bash
        run: |
          echo "Waiting 30 seconds for npm registry to propagate..."
          sleep 30
          cat > test.html << 'EOF'
          <!DOCTYPE html>
          <html lang="en">
          <head><title>Test</title></head>
          <body><p>Hello World</p></body>
          </html>
          EOF
          echo "Testing: npx vnu-jar@latest --version"
          if ! npx --yes vnu-jar@latest --version; then
            echo "::error::Failed to run 'npx vnu-jar@latest --version'"
            exit 1
          fi
          echo "Testing: npx vnu-jar@latest test.html"
          if ! npx --yes vnu-jar@latest test.html; then
            echo "::error::Failed to validate test.html with npx vnu-jar@latest"
            exit 1
          fi
          echo "✅ npx vnu-jar@latest works correctly"

  maven-release:
    name: Release new Maven package
    needs: build
    if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: write
    steps:
      - name: Check out the repo
        uses: actions/checkout@v6
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: '3.13'
      - name: Check if version already exists on Maven Central
        id: check_version
        shell: bash
        run: |
          if python checker.py --version="$(date +"%y.%-m.%-d")" maven-version-exists; then
            echo "exists=true" >> $GITHUB_OUTPUT
          else
            echo "exists=false" >> $GITHUB_OUTPUT
          fi
      - name: Set up Java JDK
        if: steps.check_version.outputs.exists == 'false'
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: 25
      - name: Install dependencies
        if: steps.check_version.outputs.exists == 'false'
        run: python checker.py dldeps
      - name: Build
        if: steps.check_version.outputs.exists == 'false'
        shell: bash
        run: |
          version="$(date +"%y.%-m.%-d") ($(git rev-parse --short ${{ github.sha }}))"
          python checker.py --version="$version" build
          python checker.py --version="$version" jar
      - name: Import GPG signing key
        if: steps.check_version.outputs.exists == 'false'
        uses: crazy-max/ghaction-import-gpg@v7
        with:
          gpg_private_key: ${{ secrets.GPG_SIGNING_KEY }}
          passphrase: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }}
          git_user_signingkey: true
          git_commit_gpgsign: true
      - name: Generate all maven artifacts
        if: steps.check_version.outputs.exists == 'false'
        shell: bash
        run: |
          ls ./build/dist
          python checker.py maven-artifacts
          python checker.py maven-sign
      - name: Test Maven artifact locally before release
        if: steps.check_version.outputs.exists == 'false'
        shell: bash
        run: |
          python checker.py maven-test
      - name: Release new maven package
        if: steps.check_version.outputs.exists == 'false'
        shell: bash
        run: |
          max_attempts=3
          attempt=1
          until python checker.py maven-release; do
            if [ $attempt -ge $max_attempts ]; then
              echo "::error::Maven release failed after $max_attempts attempts"
              exit 1
            fi
            echo "⚠️ Attempt $attempt failed, retrying in 30 seconds..."
            sleep 30
            attempt=$((attempt + 1))
          done
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          MAVEN_USER_TOKEN: ${{ secrets.MAVEN_USER_TOKEN }}

  ant-build:
    name: Ant build ${{ matrix.target }}
    needs: prep
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        target: [
          build,
          jar,
          war,
          cssvalidator-build,
          cssvalidator-artifacts,
          galimatias-build,
          galimatias-artifacts,
          htmlparser-build,
          htmlparser-artifacts,
          jing-build,
          jing-artifacts,
          langdetect-build,
          langdetect-artifacts,
          validator-artifacts,
          validator-build,
          validator-bundle,
          validator-compare-full-and-merged-split,
          validator-javadoc-jar,
          validator-sources-jar,
          validator-split-client,
          validator-split-core,
          validator-split-server,
        ]
    steps:
      - uses: actions/checkout@v6
      - name: Set up Java JDK
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: 25
      - uses: actions/setup-python@v6
        with:
          python-version: '3.13'
      - name: Run target ${{ matrix.target }}
        run: |
          max_attempts=3
          attempt=1
          until ant -f build/build.xml ${{ matrix.target }}; do
            if [ $attempt -ge $max_attempts ]; then
              echo "::error::Ant target ${{ matrix.target }} failed after $max_attempts attempts"
              exit 1
            fi
            echo "⚠️ Attempt $attempt failed, retrying in 30 seconds..."
            sleep 30
            attempt=$((attempt + 1))
          done

  ant-clean:
    name: Ant clean
    needs: prep
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Java JDK
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: 25
      - uses: actions/setup-python@v6
        with:
          python-version: '3.13'
      - name: Prepare experiment
        run: |
          tar -cf /tmp/archive.tar .
          mkdir -p _reference
          mkdir -p _experiment
          tar -C _reference -xf /tmp/archive.tar
          tar -C _experiment -xf /tmp/archive.tar
      - name: Run experiment
        run: |
          cd _experiment
          ant -f build/build.xml jar war
          ant -f build/build.xml distclean
      - name: Check differences
        run: diff -r _reference _experiment

  test-message-check:
    name: tests/message.json check
    needs: prep
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Java JDK
        uses: actions/setup-java@v5
        with:
          distribution: zulu
          java-version: 25
      - uses: actions/setup-python@v6
        with:
          python-version: '3.13'
      - name: Clean
        run: python checker.py clean
      - name: Build vnu.jar
        run: python checker.py --version="$version" build
      - name: Rebuild tests/messages.json
        run: python checker.py make-messages
      - name: Check if there are changes to messages.json
        run: git diff --exit-code tests/messages.json
# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
