diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8e3f418 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Summary + + + +## Validation + + + +## Release notes + +Before merging, apply exactly one category label to this PR: + +- `enhancement` — user-facing feature or improvement +- `bug` — behavior correction +- `breaking-change` — incompatible behavior or migration +- `documentation` — documentation-only change +- `maintenance` — internal tooling, dependencies, or maintenance +- `skip-release-notes` — no user-facing release note is needed + +The release workflow includes PR titles, grouped by these labels, and identifies contributors automatically. Keep the title concise and avoid putting issue references alone in the title. diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..ae2be51 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,25 @@ +changelog: + exclude: + labels: + - skip-release-notes + authors: + - dependabot[bot] + categories: + - title: "⚠️ Breaking changes" + labels: + - breaking-change + - title: "✨ Features and improvements" + labels: + - enhancement + - title: "🐛 Bug fixes" + labels: + - bug + - title: "📚 Documentation" + labels: + - documentation + - title: "🧰 Maintenance" + labels: + - maintenance + - title: "Other changes" + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 295bd7d..222466a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: runs-on: macos-26 permissions: contents: write + pull-requests: read env: GH_TOKEN: ${{ github.token }} HOMEBREW_NO_AUTO_UPDATE: "1" @@ -36,6 +37,7 @@ jobs: uses: actions/checkout@v5 with: ref: main + fetch-depth: 0 submodules: recursive - name: Verify release source and version @@ -81,23 +83,20 @@ jobs: set -euo pipefail MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" [[ "$MAIN_SHA" == "$GITHUB_SHA" ]] || { echo "main moved during the build; rerun from the current main commit." >&2; exit 1; } + PREVIOUS_TAG="$(gh release list --limit 100 \ + --json tagName,isDraft,isPrerelease,publishedAt \ + | jq -er '[.[] | select(.isDraft == false and .isPrerelease == false)] | sort_by(.publishedAt) | last | .tagName')" NOTES_FILE="${RUNNER_TEMP}/release-notes.md" - cat >"${NOTES_FILE}" < " >&2 + exit 2 +fi + +RELEASE_VERSION="$1" +SOURCE_COMMIT="$2" +PREVIOUS_TAG="$3" +OUTPUT_FILE="$4" +GITHUB_REPOSITORY_VALUE="${GITHUB_REPOSITORY:-LeonStadler/Cortexa}" +RELEASE_TAG="v${RELEASE_VERSION}" + +[[ "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Release version must use MAJOR.MINOR.PATCH: ${RELEASE_VERSION}" >&2 + exit 2 +} +[[ "${SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]] || { + echo "Source commit must be a full lowercase Git SHA: ${SOURCE_COMMIT}" >&2 + exit 2 +} +[[ -n "${PREVIOUS_TAG}" ]] || { + echo "A previous published release tag is required to generate release notes." >&2 + exit 2 +} +git cat-file -e "${SOURCE_COMMIT}^{commit}" +git rev-parse --verify "refs/tags/${PREVIOUS_TAG}^{commit}" >/dev/null +git merge-base --is-ancestor "${PREVIOUS_TAG}" "${SOURCE_COMMIT}" || { + echo "Previous release ${PREVIOUS_TAG} is not an ancestor of ${SOURCE_COMMIT}." >&2 + exit 1 +} + +NOTES_RESPONSE="$(gh api --method POST \ + "repos/${GITHUB_REPOSITORY_VALUE}/releases/generate-notes" \ + -f "tag_name=${RELEASE_TAG}" \ + -f "target_commitish=${SOURCE_COMMIT}" \ + -f "previous_tag_name=${PREVIOUS_TAG}" \ + -f "configuration_file_path=.github/release.yml")" +GENERATED_NOTES="$(jq -r '.body' <<<"${NOTES_RESPONSE}")" + +DIRECT_COMMITS="$(git rev-list --no-merges "${PREVIOUS_TAG}..${SOURCE_COMMIT}")" +DIRECT_NOTES=() +while IFS= read -r COMMIT; do + [[ -n "${COMMIT}" ]] || continue + ASSOCIATED_PR_COUNT="$(gh api "repos/${GITHUB_REPOSITORY_VALUE}/commits/${COMMIT}/pulls" --jq 'length')" + [[ "${ASSOCIATED_PR_COUNT}" == "0" ]] || continue + + SUBJECT="$(git show -s --format=%s "${COMMIT}")" + AUTHOR="$(git show -s --format=%an "${COMMIT}")" + DIRECT_NOTES+=("- ${SUBJECT} — ${AUTHOR} (${COMMIT:0:7})") +done <<<"${DIRECT_COMMITS}" + +{ + printf '## Cortexa %s — macOS, Apple Silicon\n\n' "${RELEASE_VERSION}" + + if [[ "${#DIRECT_NOTES[@]}" -gt 0 ]]; then + printf '### Direct commits\n\n' + printf '%s\n' "${DIRECT_NOTES[@]}" + printf '\n' + fi + + printf '%s\n\n' "${GENERATED_NOTES}" + + cat <<'EOF' +### Download and install + +The DMG targets Apple Silicon (arm64). This open-source build is ad-hoc signed and is not notarized; macOS may ask you to approve the first launch. See the included install notes. Speech models are downloaded by the app when needed. +EOF + + printf '\nSource commit: %s\n' "${SOURCE_COMMIT}" +} >"${OUTPUT_FILE}" diff --git a/scripts/verify_whisper_cli_runtime.sh b/scripts/verify_whisper_cli_runtime.sh index 1343dbf..eb697de 100755 --- a/scripts/verify_whisper_cli_runtime.sh +++ b/scripts/verify_whisper_cli_runtime.sh @@ -12,10 +12,11 @@ error() { usage() { cat < [path to ggml model] +Usage: $0 [path to ggml model [path to speech fixture]] Checks that whisper-cli has no non-system dynamic-library dependencies and can -start. When a model is supplied, also transcribes synthesized speech end to end. +start. When a model is supplied, also transcribes the pinned whisper.cpp speech +fixture end to end. The fixture path defaults to third_party/whisper.cpp/samples/jfk.wav. EOF exit 1 } @@ -26,6 +27,8 @@ fi CLI_PATH="$1" MODEL_PATH="${2:-}" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SPEECH_SAMPLE_PATH="${3:-${ROOT_DIR}/third_party/whisper.cpp/samples/jfk.wav}" [[ -x "${CLI_PATH}" ]] || error "whisper-cli is missing or not executable at ${CLI_PATH}" command -v otool >/dev/null 2>&1 || error "Required tool 'otool' is missing." @@ -66,8 +69,7 @@ if [[ -z "${MODEL_PATH}" ]]; then fi [[ -f "${MODEL_PATH}" ]] || error "Model is missing at ${MODEL_PATH}" -command -v say >/dev/null 2>&1 || error "Required tool 'say' is missing." -command -v afconvert >/dev/null 2>&1 || error "Required tool 'afconvert' is missing." +[[ -f "${SPEECH_SAMPLE_PATH}" ]] || error "Whisper speech fixture is missing at ${SPEECH_SAMPLE_PATH}" E2E_DIR="$(mktemp -d -t cortexa-whisper-e2e.XXXXXX)" cleanup_e2e() { @@ -75,26 +77,27 @@ cleanup_e2e() { } trap 'cleanup_e2e; cleanup_probe' EXIT -SPEECH_AIFF="${E2E_DIR}/speech.aiff" -SPEECH_WAV="${E2E_DIR}/speech.wav" OUTPUT_BASE="${E2E_DIR}/transcript" - -say -o "${SPEECH_AIFF}" "This is a speech recognition test for Cortexa." -afconvert -f WAVE -d LEI16@16000 "${SPEECH_AIFF}" "${SPEECH_WAV}" +CLI_STDOUT_LOG="${E2E_DIR}/stdout.log" +CLI_STDERR_LOG="${E2E_DIR}/stderr.log" if ! "${CLI_PATH}" \ -m "${MODEL_PATH}" \ - -f "${SPEECH_WAV}" \ + -f "${SPEECH_SAMPLE_PATH}" \ -l en \ -otxt \ -of "${OUTPUT_BASE}" \ -nt \ - >"${E2E_DIR}/stdout.log" \ - 2>"${E2E_DIR}/stderr.log"; then - E2E_ERROR="$(sed -n '1,20p' "${E2E_DIR}/stderr.log")" + >"${CLI_STDOUT_LOG}" \ + 2>"${CLI_STDERR_LOG}"; then + E2E_ERROR="$(sed -n '1,20p' "${CLI_STDERR_LOG}")" error "whisper-cli transcription failed: ${E2E_ERROR}" fi -[[ -s "${OUTPUT_BASE}.txt" ]] || error "whisper-cli produced no transcript for synthesized speech." +if [[ ! -s "${OUTPUT_BASE}.txt" ]]; then + CLI_STDOUT="$(sed -n '1,20p' "${CLI_STDOUT_LOG}")" + CLI_STDERR="$(sed -n '1,20p' "${CLI_STDERR_LOG}")" + error "whisper-cli produced no transcript for fixture ${SPEECH_SAMPLE_PATH}. stdout: ${CLI_STDOUT:-}; stderr: ${CLI_STDERR:-}" +fi TRANSCRIPT="$(tr '\n' ' ' <"${OUTPUT_BASE}.txt" | sed 's/[[:space:]]\{1,\}/ /g; s/^ //; s/ $//')" -log "Transcription E2E passed with non-empty output: ${TRANSCRIPT}" +log "Transcription E2E passed with non-empty output from ${SPEECH_SAMPLE_PATH}: ${TRANSCRIPT}"