name: Release

# Adapted from https://github.com/taiki-e/github-actions/blob/HEAD/.github/workflows/action-release.yml.

permissions:
  contents: read

on:
  workflow_dispatch:
    inputs:
      target:
        description: Package to be released
        required: true
        type: choice
        options:
          - install-action
          - install-action-manifest-schema
      version:
        description: Version to be increased
        required: true
        type: choice
        options:
          - patch
          - minor
          - major

defaults:
  run:
    shell: bash --noprofile --norc -CeEuxo pipefail {0}

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

jobs:
  prepare:
    if: github.repository_owner == 'taiki-e' && inputs.target == 'install-action'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
      - uses: taiki-e/install-action@b8be7f5e140177087325943c4a8e169d01c59b3d # v2.75.3
        with:
          tool: parse-changelog
          fallback: none
      - id: check
        run: |
          set +x
          IFS=$'\n\t'
          trap -- 's=$?; printf >&2 "%s\n" "${0##*/}:${LINENO}: \`${BASH_COMMAND}\` exit with ${s}"; exit ${s}' ERR
          retry() {
            for i in {1..10}; do
              if "$@"; then
                return 0
              else
                sleep "${i}"
              fi
            done
            "$@"
          }
          bail() {
            printf '::error::%s\n' "$*"
            exit 1
          }
          if { sed --help 2>&1 || true; } | grep -Eq -e '-i extension'; then
            in_place=(-i '')
          else
            in_place=(-i)
          fi

          # shellcheck disable=SC2153
          version="${VERSION}"
          printf '%s\n' "version(input): ${version}"
          # shellcheck disable=SC2153
          tag_prefix="${TAG_PREFIX}"
          printf '%s\n' "tag_prefix: ${tag_prefix}"
          # shellcheck disable=SC2153
          changelog="${CHANGELOG}"
          printf '%s\n' "changelog: ${changelog}"

          # Get the current date.
          release_date=$(date -u '+%Y-%m-%d')
          printf '%s\n' "release-date: ${release_date}"
          printf '%s\n' "release-date=${release_date}" >>"${GITHUB_OUTPUT}"

          # Get the current revision.
          rev=$(git rev-parse HEAD)
          printf '%s\n' "rev: ${rev}"
          printf '%s\n' "rev=${rev}" >>"${GITHUB_OUTPUT}"

          prev_version=$(parse-changelog --title-no-link "${changelog}" | cut -d' ' -f1)

          # Determine the new version number and tag name.
          case "${version}" in
            major | minor | patch)
              if [[ ! "${prev_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
                bail "pre-release/build-metadata"
              fi
              major="${prev_version%%.*}"
              minor_patch="${prev_version#*.}"
              minor="${minor_patch%%.*}"
              patch="${minor_patch#*.}"
              case "${version}" in
                major) version="$((major+1)).0.0" ;;
                minor) version="${major}.$((minor+1)).0" ;;
                patch) version="${major}.${minor}.$((patch+1))" ;;
              esac
              ;;
            *) version="${version#v}" ;;
          esac
          if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z\.-]+)?(\+[0-9A-Za-z\.-]+)?$ ]]; then
            bail "invalid version format '${version}'"
          fi
          printf '%s\n' "version: ${version}"
          printf '%s\n' "version=${version}" >>"${GITHUB_OUTPUT}"
          tag="${tag_prefix}${version}"
          printf '%s\n' "tag: ${tag}"
          printf '%s\n' "tag=${tag}" >>"${GITHUB_OUTPUT}"

          # Make sure the same release has not been created in the past.
          if gh release view "${tag}" &>/dev/null; then
            bail "tag '${tag}' has already been created and pushed"
          fi
          # Make sure that the release was created from an allowed branch.
          if ! git branch | grep -Eq '\* '"${BRANCH}"'$'; then
            bail "current branch is not '${BRANCH}'"
          fi

          changed_paths=()
          retry git fetch origin --tags &>/dev/null
          tags=$(git --no-pager tag | { grep -E "^${tag_prefix}[0-9]+" || true; })
          if [[ -n "${tags}" ]]; then
            printf 'has-tags=true\n' >>"${GITHUB_OUTPUT}"
            # Make sure the same release does not exist in changelog.
            if grep -Eq "^## \\[${version//./\\.}\\]" "${changelog}"; then
              bail "release ${version} already exist in ${changelog}"
            fi
            if grep -Eq "^\\[${version//./\\.}\\]: " "${changelog}"; then
              bail "link to ${version} already exist in ${changelog}"
            fi

            # Update changelog.
            changed_paths+=("${changelog}")
            remote_url=$(grep -E '^\[Unreleased\]: https://' "${changelog}" | sed -E 's/^\[Unreleased\]: //; s/\.\.\.HEAD$//')
            prev_tag="${remote_url#*/compare/}"
            remote_url="${remote_url%/compare/*}"
            sed -E "${in_place[@]}" \
              -e "s/^## \\[Unreleased\\]/## [Unreleased]\\n\\n## [${version}] - ${release_date}/" \
              -e "s#^\[Unreleased\]: https://.*#[Unreleased]: ${remote_url}/compare/${tag}...HEAD\\n[${version}]: ${remote_url}/compare/${prev_tag}...${tag}#" "${changelog}"
            if ! grep -Eq "^## \\[${version//./\\.}\\] - ${release_date}$" "${changelog}"; then
              bail "failed to update ${changelog}"
            fi
            if ! grep -Eq "^\\[${version//./\\.}\\]: " "${changelog}"; then
              bail "failed to update ${changelog}"
            fi
          else
            # Make sure the release exists in changelog.
            if ! grep -Eq "^## \\[${version//./\\.}\\] - ${release_date}$" "${changelog}"; then
              bail "release ${version} does not exist in ${changelog} or has wrong release date"
            fi
            if ! grep -Eq "^\\[${version//./\\.}\\]: " "${changelog}"; then
              bail "link to ${version} does not exist in ${changelog}"
            fi
          fi
          # Make sure that a valid release note for this version exists.
          # https://github.com/taiki-e/parse-changelog
          changes=$(parse-changelog "${changelog}" "${version}")
          if [[ -z "${changes}" ]]; then
            bail "changelog for ${version} has no body"
          fi
          printf '============== CHANGELOG ==============\n'
          printf '%s\n' "${changes}"
          printf '=======================================\n'

          if [[ -n "${tags}" ]]; then
            git -c color.ui=always diff "${changed_paths[@]}"
            git add "${changed_paths[@]}"
          fi
          # Make sure that there is no unintended change.
          git add -N .
          git -c color.ui=always diff --exit-code

          (
            set -x
            git show HEAD --shortstat
          )
        env:
          VERSION: ${{ inputs.version }}
          TAG_PREFIX: v
          CHANGELOG: CHANGELOG.md
          BRANCH: main
    outputs:
      has-tags: ${{ steps.check.outputs.has-tags }}
      release-date: ${{ steps.check.outputs.release-date }}
      rev: ${{ steps.check.outputs.rev }}
      tag: ${{ steps.check.outputs.tag }}
      version: ${{ steps.check.outputs.version }}

  release:
    if: github.repository_owner == 'taiki-e' && inputs.target == 'install-action'
    needs: prepare
    runs-on: ubuntu-latest
    timeout-minutes: 60
    environment:
      name: release
      deployment: false
    permissions:
      contents: write # for taiki-e/create-gh-release-action
    steps:
      - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
      - uses: taiki-e/install-action@b8be7f5e140177087325943c4a8e169d01c59b3d # v2.75.3
        with:
          tool: parse-changelog
          fallback: none
      - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
        id: push-token
        with:
          client-id: ${{ secrets.PUSH_TOKEN_APP_CLIENT_ID }}
          private-key: ${{ secrets.PUSH_TOKEN_APP_PRIVATE_KEY }}
      - name: Create and push release commit and tag
        id: push
        run: |
          set +x
          IFS=$'\n\t'
          trap -- 's=$?; printf >&2 "%s\n" "${0##*/}:${LINENO}: \`${BASH_COMMAND}\` exit with ${s}"; exit ${s}' ERR
          retry() {
            for i in {1..10}; do
              if "$@"; then
                return 0
              else
                sleep "${i}"
              fi
            done
            "$@"
          }
          bail() {
            printf '::error::%s\n' "$*"
            exit 1
          }
          if { sed --help 2>&1 || true; } | grep -Eq -e '-i extension'; then
            in_place=(-i '')
          else
            in_place=(-i)
          fi

          git config user.name 'Taiki Endo'
          git config user.email 'te316e89@gmail.com'

          # shellcheck disable=SC2153
          version="${VERSION}"
          # shellcheck disable=SC2153
          tag="${TAG}"
          # shellcheck disable=SC2153
          changelog="${CHANGELOG}"
          # shellcheck disable=SC2153
          release_date="${RELEASE_DATE}"

          # Make sure the current revision is same as prepare step.
          # --unshallow is necessary to successfully push the
          # "releases/${major_version_tag}" branch in the subsequent step.
          retry git fetch origin --unshallow &>/dev/null
          rev=$(git rev-parse HEAD)
          if [[ "${rev}" != "${PREPARE_REV}" ]]; then
            bail "revision difference between prepare step"
          fi

          # Make sure the same release has not been created in the past.
          if gh release view "${tag}" &>/dev/null; then
            bail "tag '${tag}' has already been created and pushed"
          fi
          # Make sure that the release was created from an allowed branch.
          if ! git branch | grep -Eq '\* '"${BRANCH}"'$'; then
            bail "current branch is not '${BRANCH}'"
          fi

          changed_paths=()
          if [[ "${HAS_TAGS}" == "true" ]]; then
            # Update changelog.
            changed_paths+=("${changelog}")
            remote_url=$(grep -E '^\[Unreleased\]: https://' "${changelog}" | sed -E 's/^\[Unreleased\]: //; s/\.\.\.HEAD$//')
            prev_tag="${remote_url#*/compare/}"
            remote_url="${remote_url%/compare/*}"
            sed -E "${in_place[@]}" \
              -e "s/^## \\[Unreleased\\]/## [Unreleased]\\n\\n## [${version}] - ${release_date}/" \
              -e "s#^\[Unreleased\]: https://.*#[Unreleased]: ${remote_url}/compare/${tag}...HEAD\\n[${version}]: ${remote_url}/compare/${prev_tag}...${tag}#" "${changelog}"
            if ! grep -Eq "^## \\[${version//./\\.}\\] - ${release_date}$" "${changelog}"; then
              bail "failed to update ${changelog}"
            fi
            if ! grep -Eq "^\\[${version//./\\.}\\]: " "${changelog}"; then
              bail "failed to update ${changelog}"
            fi
          fi
          changes=$(parse-changelog "${changelog}" "${version}")
          printf '============== CHANGELOG ==============\n'
          printf '%s\n' "${changes}"
          printf '=======================================\n'

          if [[ "${HAS_TAGS}" == "true" ]]; then
            # Create a release commit.
            (
              set -x
              git add "${changed_paths[@]}"
              git commit -m "Release ${version}"
            )
          fi

          major_version_tag="v${version%%.*}"
          (
            set -x
            git tag "${tag}"
            git branch "releases/${major_version_tag}"
            git tag -f "${major_version_tag}"
          )
          refs=("refs/heads/${BRANCH}" "refs/tags/${tag}" "refs/heads/releases/${major_version_tag}" "+refs/tags/${major_version_tag}")

          tools=()
          for tool in tools/codegen/base/*.json; do
            tool="${tool##*/}"
            tools+=("${tool%.*}")
          done
          # Aliases.
          # NB: Update case for aliases in main.sh, tool input option in test-alias job
          # in .github/workflows/ci.yml, and match for alias for tools/codegen/src/tools-markdown.rs.
          tools+=(
            nextest
            taplo-cli
            typos-cli
            wasm-bindgen-cli
            wasmtime-cli
          )
          # Non-manifest-based tools.
          tools+=(valgrind)

          branches=()
          for tool in "${tools[@]}"; do
            (
              set -x
              git checkout -b "releases/${tool}"
              sed -E "${in_place[@]}" action.yml \
                -e "s/required: true/required: false/g" \
                -e "s/# default: #publish:tool/default: ${tool}/g"
              git add action.yml
              git commit -m "${tool}"
              git tag -f "${tool}"
              git checkout refs/tags/"${tag}"
            )
            refs+=("+refs/heads/releases/${tool}" "+refs/tags/${tool}")
            branches+=("releases/${tool}")
          done

          prev_credential_helper=$(git config get --local credential.helper || true)
          if [[ -n "${prev_credential_helper}" ]]; then
            printf 'credential helper is already set (%s)\n' "${prev_credential_helper}"
          else
            protocol="${GITHUB_SERVER_URL%%://*}"
            hostname="${GITHUB_SERVER_URL#*://}"
            (
              set -x
              git config --local credential.helper cache
            )
            git credential approve <<EOF
          protocol=${protocol}
          host=${hostname}
          username=${GITHUB_ACTOR}
          password=${PUSH_TOKEN}
          EOF
            # Remove credential helper config on exit.
            trap -- '(set -x; git credential-cache exit; git config --local --unset credential.helper || true)' EXIT
          fi

          (
            set -x
            retry git push origin --atomic "${refs[@]}"
            git branch -d "releases/${major_version_tag}"
            git branch -D "${branches[@]}"

            schema_workspace=/tmp/workspace
            rm -rf -- "${schema_workspace}"
            # Checkout manifest-schema branch
            schema_version="$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name == "install-action-manifest-schema") | .version')"
            if [[ "${schema_version}" == "0."* ]]; then
              schema_version="0.$(cut -d. -f2 <<<"${schema_version}")"
            else
              schema_version="$(cut -d. -f1 <<<"${schema_version}")"
            fi
            schema_branch="manifest-schema-${schema_version}"

            git worktree add --force "${schema_workspace}"
            (
              cd -- "${schema_workspace}"
              if git fetch origin "${schema_branch}"; then
                git checkout "origin/${schema_branch}" -B "${schema_branch}"
              elif ! git checkout "${schema_branch}"; then
                # New branch with no history. Credit: https://stackoverflow.com/a/13969482
                git checkout --orphan "${schema_branch}"
                git rm -rf -- . || true
                git commit -m 'Initial commit' --allow-empty
              fi
            )

            # Copy over schema
            cp -- ./manifests/* "${schema_workspace}"

            (
              cd -- "${schema_workspace}"
              # Stage changes
              git add .
              # Detect changes, then commit and push if changes exist
              if [[ "$(git status --porcelain=v1 | LC_ALL=C wc -l)" != "0" ]]; then
                git commit -m 'Update manifest schema'
                retry git push origin HEAD
              fi
            )

            rm -rf -- "${schema_workspace}"
            git worktree prune
            # TODO: get branch in schema_workspace dir instead
            git branch -D "${schema_branch}" "${schema_workspace##*/}"
          )
        env:
          VERSION: ${{ needs.prepare.outputs.version }}
          RELEASE_DATE: ${{ needs.prepare.outputs.release-date }}
          HAS_TAGS: ${{ needs.prepare.outputs.has-tags }}
          TAG: ${{ needs.prepare.outputs.tag }}
          CHANGELOG: CHANGELOG.md
          BRANCH: main
          PREPARE_REV: ${{ needs.prepare.outputs.rev }}
          # Note that if we use secrets.GITHUB_TOKEN, the pushed commit/tag cannot trigger other workflows.
          PUSH_TOKEN: ${{ steps.push-token.outputs.token }}
      - uses: taiki-e/create-gh-release-action@eba8ea96c86cca8a37f1b56e94b4d13301fba651 # v1.11.0
        with:
          changelog: CHANGELOG.md
          title: $version
          branch: main
          ref: refs/tags/${{ needs.prepare.outputs.tag }}

  release-manifest-schema:
    if: github.repository_owner == 'taiki-e' && inputs.target == 'install-action-manifest-schema'
    uses: taiki-e/github-actions/.github/workflows/rust-release.yml@3c1cac16ef654d1febb66f21f328fa167f0d2fef # main
    permissions:
      contents: write # for taiki-e/create-gh-release-action
      id-token: write # for rust-lang/crates-io-auth-action
      attestations: write # unused (used when options for uploading binaries are set)
    secrets:
      PUSH_TOKEN_APP_CLIENT_ID: ${{ secrets.PUSH_TOKEN_APP_CLIENT_ID }}
      PUSH_TOKEN_APP_PRIVATE_KEY: ${{ secrets.PUSH_TOKEN_APP_PRIVATE_KEY }}
    with:
      version: ${{ inputs.version }}
      tag-prefix: install-action-manifest-schema-
      crates: tools/manifest-schema
      changelog: tools/manifest-schema/CHANGELOG.md
      title: $prefix $version
      prefix: install-action-manifest-schema
      commit-prefix: 'manifest-schema: '
