diff --git a/.github/workflows/build-desktop-tauri.yml b/.github/workflows/build-desktop-tauri.yml index c1f6bf5a..12b0e499 100644 --- a/.github/workflows/build-desktop-tauri.yml +++ b/.github/workflows/build-desktop-tauri.yml @@ -16,6 +16,15 @@ on: required: false type: boolean default: true + build_mode: + description: Build mode (`auto` | `tag-poll` | `nightly`); for workflow_dispatch, `auto` behaves as `manual` and disables publish_release + required: false + type: choice + default: auto + options: + - auto + - tag-poll + - nightly schedule: - cron: '0 * * * *' @@ -25,6 +34,8 @@ permissions: env: ASTRBOT_SOURCE_GIT_URL: ${{ vars.ASTRBOT_SOURCE_GIT_URL || 'https://github.com/AstrBotDevs/AstrBot.git' }} ASTRBOT_SOURCE_GIT_REF: ${{ vars.ASTRBOT_SOURCE_GIT_REF || 'master' }} + ASTRBOT_NIGHTLY_SOURCE_GIT_REF: ${{ vars.ASTRBOT_NIGHTLY_SOURCE_GIT_REF || 'master' }} + ASTRBOT_NIGHTLY_UTC_HOUR: ${{ vars.ASTRBOT_NIGHTLY_UTC_HOUR || '3' }} jobs: resolve_build_context: @@ -35,6 +46,11 @@ jobs: source_git_ref: ${{ steps.resolve.outputs.source_git_ref }} astrbot_version: ${{ steps.resolve.outputs.astrbot_version }} should_build: ${{ steps.resolve.outputs.should_build }} + build_mode: ${{ steps.resolve.outputs.build_mode }} + publish_release: ${{ steps.resolve.outputs.publish_release }} + release_tag: ${{ steps.resolve.outputs.release_tag }} + release_name: ${{ steps.resolve.outputs.release_name }} + release_prerelease: ${{ steps.resolve.outputs.release_prerelease }} steps: - name: Checkout uses: actions/checkout@v6.0.2 @@ -52,85 +68,20 @@ jobs: ASTRBOT_SOURCE_GIT_REF: ${{ env.ASTRBOT_SOURCE_GIT_REF }} WORKFLOW_SOURCE_GIT_URL: ${{ github.event.inputs.source_git_url }} WORKFLOW_SOURCE_GIT_REF: ${{ github.event.inputs.source_git_ref }} + WORKFLOW_PUBLISH_RELEASE: ${{ github.event.inputs.publish_release }} + WORKFLOW_BUILD_MODE: ${{ github.event.inputs.build_mode }} GITHUB_TOKEN: ${{ github.token }} GH_REPOSITORY: ${{ github.repository }} GITHUB_EVENT_NAME: ${{ github.event_name }} - run: | - set -euo pipefail - - source_git_url="${ASTRBOT_SOURCE_GIT_URL}" - source_git_ref="${ASTRBOT_SOURCE_GIT_REF}" - should_build="true" - - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - if [ -n "${WORKFLOW_SOURCE_GIT_URL:-}" ]; then - source_git_url="${WORKFLOW_SOURCE_GIT_URL}" - fi - if [ -n "${WORKFLOW_SOURCE_GIT_REF:-}" ]; then - source_git_ref="${WORKFLOW_SOURCE_GIT_REF}" - fi - fi - - if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then - latest_tag="$(git ls-remote --tags --refs "${source_git_url}" \ - | awk '{print $2}' \ - | sed 's#refs/tags/##' \ - | sort -V \ - | tail -n 1)" - if [ -z "${latest_tag}" ]; then - echo "Unable to resolve latest tag from ${source_git_url}" >&2 - exit 1 - fi - source_git_ref="${latest_tag}" - echo "Scheduled run detected latest upstream tag: ${source_git_ref}" - - http_status="$(curl -sS -o /dev/null -w '%{http_code}' \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${GH_REPOSITORY}/releases/tags/${source_git_ref}")" - if [ "${http_status}" = "200" ]; then - should_build="false" - echo "Release ${source_git_ref} already exists. Tag unchanged, skipping build." - else - echo "Release ${source_git_ref} not found (HTTP ${http_status}). Build will run." - fi - fi - - version="" - if [ "${should_build}" = "true" ]; then - if printf '%s' "${source_git_ref}" | grep -Eq '^v[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$'; then - version="${source_git_ref#v}" - echo "Resolved version directly from source tag: ${source_git_ref}" - else - workdir="$(mktemp -d)" - repo_dir="${workdir}/AstrBot" - git init "${repo_dir}" - git -C "${repo_dir}" remote add origin "${source_git_url}" - git -C "${repo_dir}" fetch --depth 1 origin "${source_git_ref}" - git -C "${repo_dir}" checkout --detach FETCH_HEAD - version="$(python3 scripts/ci/read-project-version.py "${repo_dir}/pyproject.toml")" - fi - else - version="${source_git_ref#v}" - if [ -z "${version}" ] || [ "${version}" = "${source_git_ref}" ]; then - version="unknown" - fi - fi + ASTRBOT_NIGHTLY_SOURCE_GIT_REF: ${{ env.ASTRBOT_NIGHTLY_SOURCE_GIT_REF }} + ASTRBOT_NIGHTLY_UTC_HOUR: ${{ env.ASTRBOT_NIGHTLY_UTC_HOUR }} + run: bash scripts/ci/resolve-build-context.sh - { - echo "source_git_url=${source_git_url}" - echo "source_git_ref=${source_git_ref}" - echo "astrbot_version=${version}" - echo "should_build=${should_build}" - } >> "${GITHUB_OUTPUT}" - echo "Resolved source: ${source_git_url}@${source_git_ref}" - echo "Resolved AstrBot version: ${version}" - echo "Build enabled: ${should_build}" sync_repo_version: name: Sync Repository Version needs: resolve_build_context - if: ${{ needs.resolve_build_context.outputs.should_build == 'true' && github.event_name == 'schedule' }} + if: ${{ github.event_name == 'schedule' && needs.resolve_build_context.outputs.should_build == 'true' && needs.resolve_build_context.outputs.build_mode == 'tag-poll' }} runs-on: ubuntu-latest permissions: contents: write @@ -456,7 +407,7 @@ jobs: release: name: Publish GitHub Release - if: ${{ needs.resolve_build_context.outputs.should_build == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_release == 'true')) }} + if: ${{ needs.resolve_build_context.outputs.should_build == 'true' && needs.resolve_build_context.outputs.publish_release == 'true' }} needs: - resolve_build_context - build-linux @@ -488,13 +439,16 @@ jobs: - name: Create or update release uses: softprops/action-gh-release@v2.5.0 with: - tag_name: v${{ needs.resolve_build_context.outputs.astrbot_version }} - name: AstrBot Desktop v${{ needs.resolve_build_context.outputs.astrbot_version }} + tag_name: ${{ needs.resolve_build_context.outputs.release_tag }} + name: ${{ needs.resolve_build_context.outputs.release_name }} body: | Automated desktop package release. - Source: `${{ needs.resolve_build_context.outputs.source_git_url }}` - Ref: `${{ needs.resolve_build_context.outputs.source_git_ref }}` + - Mode: `${{ needs.resolve_build_context.outputs.build_mode }}` - Windows tip: prefer `nsis-web` installer for smaller downloads and faster install startup. generate_release_notes: true + prerelease: ${{ needs.resolve_build_context.outputs.release_prerelease == 'true' }} + make_latest: ${{ needs.resolve_build_context.outputs.release_prerelease != 'true' }} files: release-artifacts/**/* fail_on_unmatched_files: true diff --git a/scripts/ci/resolve-build-context.sh b/scripts/ci/resolve-build-context.sh new file mode 100755 index 00000000..afb478aa --- /dev/null +++ b/scripts/ci/resolve-build-context.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DEFAULT_NIGHTLY_UTC_HOUR='3' +DEFAULT_LS_REMOTE_RETRY_ATTEMPTS='3' +DEFAULT_LS_REMOTE_RETRY_SLEEP_SECONDS='2' + +temp_dirs=() +cleanup_temp_dirs() { + local dir + for dir in "${temp_dirs[@]-}"; do + [ -n "${dir}" ] || continue + rm -rf "${dir}" 2>/dev/null || true + done +} +trap cleanup_temp_dirs EXIT + +is_transient_git_error() { + local message="$1" + printf '%s' "${message}" | grep -Eiq \ + '(Could not resolve host|Failed to connect|Connection (timed out|reset|refused)|Operation timed out|Temporary failure|TLS|SSL|HTTP [0-9]*5[0-9]{2}|The requested URL returned error: 5[0-9]{2}|network is unreachable)' +} + +sanitize_positive_int() { + local raw="$1" + local fallback="$2" + local max_value="$3" + case "${raw}" in + ''|*[!0-9]*) printf '%s\n' "${fallback}" ;; + *) + if [ "${raw}" -lt 1 ] 2>/dev/null; then + printf '%s\n' "${fallback}" + elif [ "${raw}" -gt "${max_value}" ] 2>/dev/null; then + printf '%s\n' "${max_value}" + else + printf '%s\n' "${raw}" + fi + ;; + esac +} + +git_ls_remote_with_retry() { + local source_url="$1" + local source_ref="$2" + local label="$3" + local attempts="$4" + local sleep_seconds="$5" + + local attempt=1 + local output="" + local error_class="" + + while [ "${attempt}" -le "${attempts}" ]; do + if output="$(git ls-remote "${source_url}" "${source_ref}" 2>&1)"; then + printf '%s\n' "${output}" + return 0 + fi + + if is_transient_git_error "${output}"; then + error_class="transient-network" + else + error_class="non-transient" + fi + echo "::warning::git ls-remote failed (${label}) attempt ${attempt}/${attempts}, class=${error_class}: ${output}" + + if [ "${error_class}" != "transient-network" ]; then + break + fi + if [ "${attempt}" -lt "${attempts}" ]; then + sleep "${sleep_seconds}" + fi + attempt=$((attempt + 1)) + done + + local final_attempt="${attempt}" + if [ "${final_attempt}" -gt "${attempts}" ]; then + final_attempt="${attempts}" + fi + echo "::error::Unable to resolve ${label} from ${source_url} after ${final_attempt} attempt(s)." + return 1 +} + +source_git_url="${ASTRBOT_SOURCE_GIT_URL}" +source_git_ref="${ASTRBOT_SOURCE_GIT_REF}" +nightly_source_git_ref="${ASTRBOT_NIGHTLY_SOURCE_GIT_REF:-master}" +nightly_utc_hour="${ASTRBOT_NIGHTLY_UTC_HOUR:-${DEFAULT_NIGHTLY_UTC_HOUR}}" +requested_build_mode="$(printf '%s' "${WORKFLOW_BUILD_MODE:-auto}" | tr '[:upper:]' '[:lower:]')" +should_build="true" +build_mode="manual" +publish_release="false" +release_tag="" +release_name="" +release_prerelease="false" + +case "${requested_build_mode}" in + auto|tag-poll|nightly) ;; + *) + echo "::error::invalid build_mode input '${requested_build_mode}'; expected one of: auto, tag-poll, nightly." + exit 1 + ;; +esac + +case "${nightly_utc_hour}" in + '') + nightly_utc_hour="${DEFAULT_NIGHTLY_UTC_HOUR}" + ;; + *[!0-9]*) + echo "WARN: non-numeric ASTRBOT_NIGHTLY_UTC_HOUR=${nightly_utc_hour}, fallback to ${DEFAULT_NIGHTLY_UTC_HOUR}." + nightly_utc_hour="${DEFAULT_NIGHTLY_UTC_HOUR}" + ;; +esac +if [ "${nightly_utc_hour}" -gt 23 ] 2>/dev/null; then + echo "WARN: invalid ASTRBOT_NIGHTLY_UTC_HOUR=${nightly_utc_hour}, fallback to ${DEFAULT_NIGHTLY_UTC_HOUR}." + nightly_utc_hour="${DEFAULT_NIGHTLY_UTC_HOUR}" +fi +nightly_utc_hour_padded="$(printf '%02d' "${nightly_utc_hour}")" +echo "Nightly UTC hour normalized to ${nightly_utc_hour_padded} (raw='${ASTRBOT_NIGHTLY_UTC_HOUR:-}')." + +if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + if [ -n "${WORKFLOW_SOURCE_GIT_URL:-}" ]; then + source_git_url="${WORKFLOW_SOURCE_GIT_URL}" + fi + if [ -n "${WORKFLOW_SOURCE_GIT_REF:-}" ]; then + source_git_ref="${WORKFLOW_SOURCE_GIT_REF}" + fi + if [ "${WORKFLOW_PUBLISH_RELEASE:-true}" = "true" ]; then + publish_release="true" + fi + if [ "${requested_build_mode}" = "tag-poll" ]; then + build_mode="tag-poll" + elif [ "${requested_build_mode}" = "nightly" ]; then + build_mode="nightly" + else + echo "workflow_dispatch build_mode=auto: using manual mode." + if [ "${publish_release}" = "true" ]; then + echo "::warning::workflow_dispatch with build_mode=auto resolves to manual mode; publish_release=true is normalized to false to avoid unintended release publishing." + publish_release="false" + fi + fi +fi + +if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then + current_utc_hour="$(date -u +%H)" + if [ "${current_utc_hour}" = "${nightly_utc_hour_padded}" ]; then + build_mode="nightly" + publish_release="true" + echo "Scheduled nightly run at UTC hour ${current_utc_hour}." + else + build_mode="tag-poll" + publish_release="true" + echo "Scheduled tag polling run at UTC hour ${current_utc_hour}." + fi +fi + +retry_attempts="$( + sanitize_positive_int \ + "${ASTRBOT_LS_REMOTE_RETRY_ATTEMPTS:-${DEFAULT_LS_REMOTE_RETRY_ATTEMPTS}}" \ + "${DEFAULT_LS_REMOTE_RETRY_ATTEMPTS}" \ + "10" +)" +retry_sleep_seconds="$( + sanitize_positive_int \ + "${ASTRBOT_LS_REMOTE_RETRY_SLEEP_SECONDS:-${DEFAULT_LS_REMOTE_RETRY_SLEEP_SECONDS}}" \ + "${DEFAULT_LS_REMOTE_RETRY_SLEEP_SECONDS}" \ + "60" +)" + +if [ "${build_mode}" = "nightly" ]; then + nightly_branch="${nightly_source_git_ref}" + if [ -z "${nightly_branch}" ]; then + echo "ASTRBOT_NIGHTLY_SOURCE_GIT_REF must be set to a branch name or refs/heads/ for nightly builds." >&2 + exit 1 + fi + case "${nightly_branch}" in + refs/heads/*) + echo "Normalizing nightly source ref '${nightly_branch}' to branch name for git ls-remote." + nightly_branch="${nightly_branch#refs/heads/}" + ;; + refs/*) + echo "ASTRBOT_NIGHTLY_SOURCE_GIT_REF must be a branch name or refs/heads/; got '${nightly_branch}'." >&2 + exit 1 + ;; + esac + + nightly_remote_output="$( + git_ls_remote_with_retry \ + "${source_git_url}" \ + "refs/heads/${nightly_branch}" \ + "nightly branch refs/heads/${nightly_branch}" \ + "${retry_attempts}" \ + "${retry_sleep_seconds}" + )" + source_git_ref="$(printf '%s\n' "${nightly_remote_output}" | awk 'NR==1{print $1}')" + if [ -z "${source_git_ref}" ]; then + echo "Unable to resolve latest commit from ${source_git_url} refs/heads/${nightly_branch} (configured ASTRBOT_NIGHTLY_SOURCE_GIT_REF='${nightly_source_git_ref}')." >&2 + exit 1 + fi + echo "Nightly source resolved from ${nightly_branch}@${source_git_ref} (configured ASTRBOT_NIGHTLY_SOURCE_GIT_REF='${nightly_source_git_ref}')." +elif [ "${build_mode}" = "tag-poll" ]; then + tag_remote_output="$( + git_ls_remote_with_retry \ + "${source_git_url}" \ + "refs/tags/*" \ + "upstream tags refs/tags/*" \ + "${retry_attempts}" \ + "${retry_sleep_seconds}" + )" + latest_tag="$(printf '%s\n' "${tag_remote_output}" \ + | awk '{print $2}' \ + | sed 's#refs/tags/##' \ + | sort -V \ + | tail -n 1)" + if [ -z "${latest_tag}" ]; then + echo "Unable to resolve latest tag from ${source_git_url}" >&2 + exit 1 + fi + source_git_ref="${latest_tag}" + echo "Tag polling run detected latest upstream tag: ${source_git_ref}" + + http_status="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GH_REPOSITORY}/releases/tags/${source_git_ref}")" + if [ "${http_status}" = "200" ]; then + should_build="false" + echo "Release ${source_git_ref} already exists. Tag unchanged, skipping build." + else + echo "Release ${source_git_ref} not found (HTTP ${http_status}). Build will run." + fi +fi + +version="" +if [ "${should_build}" = "true" ]; then + if printf '%s' "${source_git_ref}" | grep -Eq '^v[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$'; then + version="${source_git_ref#v}" + echo "Resolved version directly from source tag: ${source_git_ref}" + else + workdir="$(mktemp -d)" + temp_dirs+=("${workdir}") + repo_dir="${workdir}/AstrBot" + git init "${repo_dir}" + git -C "${repo_dir}" remote add origin "${source_git_url}" + git -C "${repo_dir}" fetch --depth 1 origin "${source_git_ref}" + git -C "${repo_dir}" checkout --detach FETCH_HEAD + version="$(python3 scripts/ci/read-project-version.py "${repo_dir}/pyproject.toml")" + fi +else + version="${source_git_ref#v}" + if [ -z "${version}" ] || [ "${version}" = "${source_git_ref}" ]; then + version="unknown" + fi +fi + +if [ "${build_mode}" = "nightly" ] && [ "${should_build}" = "true" ]; then + nightly_date="$(date -u +%Y%m%d)" + short_sha="$(printf '%s' "${source_git_ref}" | cut -c1-8)" + version="${version}-nightly.${nightly_date}.${short_sha}" + release_tag="v${version}" + release_name="AstrBot Desktop v${version}" + release_prerelease="true" +elif [ "${publish_release}" = "true" ] && [ "${should_build}" = "true" ]; then + release_tag="v${version}" + release_name="AstrBot Desktop v${version}" + release_prerelease="false" +fi + +{ + echo "source_git_url=${source_git_url}" + echo "source_git_ref=${source_git_ref}" + echo "astrbot_version=${version}" + echo "should_build=${should_build}" + echo "build_mode=${build_mode}" + echo "publish_release=${publish_release}" + echo "release_tag=${release_tag}" + echo "release_name=${release_name}" + echo "release_prerelease=${release_prerelease}" +} >> "${GITHUB_OUTPUT}" + +echo "Resolved source: ${source_git_url}@${source_git_ref}" +echo "Resolved AstrBot version: ${version}" +echo "Build enabled: ${should_build}" +echo "Build mode: ${build_mode}" +echo "Publish release: ${publish_release}" +echo "Release tag: ${release_tag:-}" +echo "Release prerelease: ${release_prerelease}"