diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d4ba24..87ce0bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1 +1,9 @@
## [unreleased]
+
+### Added
+
+- Added automated GitHub release creation.
+- Added changelog-based release notes.
+- Added configurable tag templates and release metadata.
+- Added contributor detection and GitHub-native mentions.
+- Added optional CI workflow dispatch for generated changelog PRs.
diff --git a/Dockerfile b/Dockerfile
index a6bc066..c52ec77 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Build
-FROM node:24.16.0-alpine AS build
+FROM node:24.17.0-alpine AS build
WORKDIR /app
@@ -24,7 +24,7 @@ RUN rm -rf ./src \
./tsconfig.prod.json
# Stage 2: Production
-FROM node:24.16.0-alpine
+FROM node:24.17.0-alpine
WORKDIR /app
diff --git a/action.yml b/action.yml
index 91239c5..67e1200 100644
--- a/action.yml
+++ b/action.yml
@@ -38,6 +38,16 @@ inputs:
description: "Explicit version for releasing (overwrites package.json)"
required: false
default: ""
+ ci-workflows:
+ description: >
+ Comma-separated workflow file names to dispatch after creating a changelog PR.
+ Use 'auto' (default) to dispatch all workflow_dispatch-enabled workflows except the release workflow itself.
+ Use 'none' or 'false' to disable dispatch entirely.
+ Dispatched CI job results are mirrored as GitHub Check Runs on the changelog PR head SHA,
+ so required checks are satisfied automatically.
+ Requires: permissions: actions: write, checks: write (plus contents: write, pull-requests: write).
+ required: false
+ default: "auto"
runs:
using: "docker"
diff --git a/scripts/collect-commits.sh b/scripts/collect-commits.sh
index dd080d1..bb9d2a5 100644
--- a/scripts/collect-commits.sh
+++ b/scripts/collect-commits.sh
@@ -81,6 +81,14 @@ else
fi
fi
+# Prepare full changelog link for GitHub release notes.
+if [ -n "$prev_semver" ]; then
+ full_changelog_url="$BASE_URL/$REPO/compare/$prev_semver...$TAG"
+ printf '**Full Changelog**: [%s...%s](%s)\n' "$prev_semver" "$TAG" "$full_changelog_url" > full_changelog.txt
+else
+ : > full_changelog.txt
+fi
+
# Check if commit range is valid
if [ -z "$commit_range" ]; then
echo "No commit range defined. Skipping commit collection."
@@ -88,7 +96,8 @@ if [ -z "$commit_range" ]; then
fi
# Collect commit log and contributors
-commit_data=$(git log "$commit_range" --max-count=30 --pretty=format:"%H|%an|%ae") || { echo "::error:: commit data failed"; return 0; }
+field_sep=$(printf '\037')
+commit_data=$(git log "$commit_range" --max-count=30 --pretty=format:"%H${field_sep}%h${field_sep}%an${field_sep}%ae${field_sep}%s") || { echo "::error:: commit data failed"; return 0; }
if [ -z "$commit_data" ]; then
echo "No commits found in the specified range."
commit_log="* No changes since last release."
@@ -97,118 +106,122 @@ if [ -z "$commit_data" ]; then
echo "Dry-Run: Skipping writing 'commit_log.txt' and 'contributors.txt'."
else
echo "$commit_log" > commit_log.txt
- echo "
" > contributors.txt
+ : > contributors.txt
fi
return 0
fi
# Collect commit log
-commit_log=$(git log "$commit_range" --max-count=30 --pretty=format:"* [%h]($BASE_URL/$REPO/commit/%H) %s (%an)")
-log_status=$?
-
-if [ $log_status -ne 0 ]; then
- echo "::error:: git log failed with exit code $log_status"
- echo "::error:: commit_range was '$commit_range'"
- return 0
-fi
-
-# Extract unique contributor emails and commit hashes
-commit_emails=$(echo "$commit_data" | awk -F"|" '{print $3}' | sort | uniq)
-
-# Save commit log to a file
-if [ "$DRY_RUN" = "true" ]; then
- echo "Dry-Run: Skipping writing 'commit_log.txt'."
-else
- echo "$commit_log" > commit_log.txt
-fi
+# Build GitHub-native release notes with @mentions and PR links.
+commit_log_file=$(mktemp)
+contributors_mentions_file=$(mktemp)
+seen_logins_file=$(mktemp)
+seen_prs_file=$(mktemp)
+
+printf '%s\n' "$commit_data" | while IFS="$field_sep" read -r commit_sha short_sha author_name author_email subject; do
+ [ -z "$commit_sha" ] && continue
+
+ login=""
+ pr_number=""
+ pr_title=""
+ pr_url=""
+ pr_user=""
+ commit_url="$BASE_URL/$REPO/commit/$commit_sha"
+
+ commit_response=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ "$BASE_API_URL/repos/$REPO/commits/$commit_sha")
+
+ if printf '%s\n' "$commit_response" | jq empty > /dev/null 2>&1; then
+ login=$(printf '%s\n' "$commit_response" | jq -r '.author.login // empty')
+ fi
-# Skip contributor collection in dry-run mode
-if [ "$DRY_RUN" = "true" ]; then
- echo "Dry-Run: Skipping contributor collection."
- contributor_details="| Dry-Run: Contributor collection skipped |
"
-else
+ pr_response_file=$(mktemp)
+ pr_http_code=$(curl -sS -o "$pr_response_file" -w "%{http_code}" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ "$BASE_API_URL/repos/$REPO/commits/$commit_sha/pulls?per_page=1" || echo "000")
+
+ pr_response=$(cat "$pr_response_file")
+ rm -f "$pr_response_file"
+
+ if [ "$pr_http_code" = "200" ] && \
+ printf '%s\n' "$pr_response" | jq -e 'type == "array" and length > 0' > /dev/null 2>&1; then
+ pr_number=$(printf '%s\n' "$pr_response" | jq -r '.[0].number // empty')
+ pr_title=$(printf '%s\n' "$pr_response" | jq -r '.[0].title // empty')
+ pr_url=$(printf '%s\n' "$pr_response" | jq -r '.[0].html_url // empty')
+ pr_user=$(printf '%s\n' "$pr_response" | jq -r '.[0].user.login // empty')
+ else
+ pr_number=$(printf '%s\n' "$subject" | sed -n 's/.*[Mm]erge pull request #\([0-9][0-9]*\).*/\1/p')
- # Prepare contributors list with profile pictures
- contributor_details=""
- seen_logins=""
- for email in $commit_emails; do
- login=""
- full_name=""
- profile_url=""
- avatar_url=""
-
- if echo "$email" | grep -q '\[bot\]'; then
- echo "Skipping bot user: $email"
- continue
+ if [ -z "$pr_number" ]; then
+ pr_number=$(printf '%s\n' "$subject" | sed -n 's/.*(#\([0-9][0-9]*\)).*/\1/p')
fi
- commit_sha=$(echo "$commit_data" | awk -F"|" -v email="$email" '$3 == email { print $1; exit }')
-
- if [ -n "$commit_sha" ]; then
+ if [ -n "$pr_number" ]; then
+ pr_url="$BASE_URL/$REPO/pull/$pr_number"
- commit_response=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
- -H "Accept: application/vnd.github+json" \
- "$BASE_API_URL/repos/$REPO/commits/$commit_sha")
-
- if echo "$commit_response" | jq empty > /dev/null 2>&1; then
- login=$(echo "$commit_response" | jq -r '.author.login // empty')
+ if printf '%s\n' "$subject" | grep -qi '^Merge pull request #[0-9]'; then
+ pr_title="Pull request #$pr_number"
+ else
+ pr_title="$subject"
+ fi
fi
- else
- echo "::warning:: No commit SHA found for email $email. Skipping commit lookup."
fi
- # Step 3: Final check
if [ -z "$login" ] || [ "$login" = "empty" ]; then
- echo "::warning:: No valid GitHub user found for email $email or commit lookup. Skipping..."
- continue
+ login="$pr_user"
fi
- case " $seen_logins " in
- *" $login "*)
- echo "Skipping duplicate contributor login: $login"
- continue
- ;;
- esac
- seen_logins="$seen_logins $login"
-
- # Fetch GitHub user details
- user_response=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
- -H "Accept: application/vnd.github+json" \
- "$BASE_API_URL/users/$login")
-
-
- if echo "$user_response" | jq empty > /dev/null 2>&1; then
- full_name=$(echo "$user_response" | jq -r '.name // empty')
- profile_url=$(echo "$user_response" | jq -r '.html_url // empty')
- avatar_url=$(echo "$user_response" | jq -r '.avatar_url // empty')
+ is_bot=false
+ if printf '%s\n' "$author_email" | grep -q '\[bot\]' || \
+ printf '%s\n' "$login" | grep -q '\[bot\]'; then
+ is_bot=true
fi
- # If no full name is found, use the login name
- if [ -z "$full_name" ] || [ "$full_name" = "empty" ]; then
- full_name="$login"
+ if [ -n "$login" ] && [ "$login" != "empty" ] && [ "$is_bot" = "false" ]; then
+ if ! grep -Fxq -- "$login" "$seen_logins_file"; then
+ printf '%s\n' "$login" >> "$seen_logins_file"
+ printf '@%s\n' "$login" >> "$contributors_mentions_file"
+ fi
fi
- if [ -z "$profile_url" ] || [ -z "$avatar_url" ] || [ "$profile_url" = "empty" ] || [ "$avatar_url" = "empty" ]; then
- echo "::warning:: No valid GitHub profile for $email. Skipping..."
+ if [ -n "$pr_number" ] && [ "$pr_number" != "empty" ] && \
+ [ -n "$pr_url" ] && [ "$pr_url" != "empty" ]; then
+ if grep -Fxq -- "$pr_number" "$seen_prs_file"; then
continue
fi
- # Build contributor HTML
- contributor_details="$contributor_details
-
- 
- $full_name
-
- | "
+ printf '%s\n' "$pr_number" >> "$seen_prs_file"
+
+ if [ -z "$pr_title" ] || [ "$pr_title" = "empty" ]; then
+ pr_title="$subject"
+ fi
+
+ if [ -n "$login" ] && [ "$login" != "empty" ] && [ "$is_bot" = "false" ]; then
+ printf '* %s by @%s in [#%s](%s)\n' "$pr_title" "$login" "$pr_number" "$pr_url" >> "$commit_log_file"
+ else
+ printf '* %s in [#%s](%s)\n' "$pr_title" "$pr_number" "$pr_url" >> "$commit_log_file"
+ fi
+
+ continue
+ fi
+
+ if [ -n "$login" ] && [ "$login" != "empty" ] && [ "$is_bot" = "false" ]; then
+ printf '* %s by @%s in [%s](%s)\n' "$subject" "$login" "$short_sha" "$commit_url" >> "$commit_log_file"
+ else
+ printf '* %s by %s in [%s](%s)\n' "$subject" "$author_name" "$short_sha" "$commit_url" >> "$commit_log_file"
+ fi
done
- contributor_details="$contributor_details
"
-fi
+commit_log=$(cat "$commit_log_file")
+contributors_mentions=$(paste -sd' ' "$contributors_mentions_file")
+
+rm -f "$commit_log_file" "$contributors_mentions_file" "$seen_logins_file" "$seen_prs_file"
-# Save contributors to a file
if [ "$DRY_RUN" = "true" ]; then
- echo "Dry-Run: Skipping writing 'contributors.txt'."
- echo "$contributor_details" > contributors.txt
+ echo "Dry-Run: Skipping writing 'commit_log.txt' and 'contributors.txt'."
else
- echo "$contributor_details" > contributors.txt
-fi
+ echo "$commit_log" > commit_log.txt
+ printf '%s\n' "$contributors_mentions" > contributors.txt
+fi
\ No newline at end of file
diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh
index acb7fab..3ba6e93 100644
--- a/scripts/create-pr.sh
+++ b/scripts/create-pr.sh
@@ -1,10 +1,362 @@
#!/bin/sh
set -e # Stop the script if any command fails
+CREATE_PR_SCRIPT_VERSION="ci-dispatch-check-run-v2"
+echo "CREATE_PR_SCRIPT_VERSION=$CREATE_PR_SCRIPT_VERSION"
+
# Define variables
CHANGELOG_FILE_PATH="${CHANGELOG_FILE_PATH:-CHANGELOG.md}"
TEMP_DIR=$(mktemp -d)
+urlencode() {
+ jq -nr --arg value "$1" '$value|@uri'
+}
+
+map_job_conclusion_to_check_conclusion() {
+ conclusion="$1"
+
+ case "$conclusion" in
+ success|failure|neutral|cancelled|skipped|timed_out|action_required)
+ echo "$conclusion"
+ ;;
+ *)
+ echo "failure"
+ ;;
+ esac
+}
+
+create_check_run() {
+ sha="$1"
+ check_name="$2"
+ conclusion="$3"
+ details_url="$4"
+ summary="$5"
+
+ check_payload=$(jq -n \
+ --arg name "$check_name" \
+ --arg head_sha "$sha" \
+ --arg conclusion "$conclusion" \
+ --arg details_url "$details_url" \
+ --arg summary "$summary" \
+ '{
+ name: $name,
+ head_sha: $head_sha,
+ status: "completed",
+ conclusion: $conclusion,
+ output: {
+ title: $name,
+ summary: $summary
+ }
+ }
+ | if $details_url == "" then . else . + {details_url: $details_url} end')
+
+ check_response_file=$(mktemp)
+
+ if ! check_http_code=$(curl -sS -o "$check_response_file" -w "%{http_code}" -X POST \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ -d "$check_payload" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/check-runs"); then
+ check_http_code="000"
+ fi
+
+ check_response=$(cat "$check_response_file" || true)
+ rm -f "$check_response_file"
+
+ if [ "$check_http_code" != "201" ]; then
+ echo "::error::Failed to create check run '$check_name' for $sha (HTTP $check_http_code): $check_response"
+ exit 1
+ fi
+
+ echo "Created check run '$check_name' with conclusion '$conclusion' for $sha."
+}
+
+find_dispatched_workflow_run_id() {
+ workflow_file="$1"
+ branch_ref="$2"
+ head_sha="$3"
+ dispatch_started_at="$4"
+
+ # All diagnostic output goes to stderr so it appears in logs but is not
+ # captured into the $() that reads this function's return value.
+ echo "Resolving workflow run for dispatched workflow: $workflow_file" >&2
+
+ # Pass the branch name as-is in the query string. GitHub's ?branch= filter
+ # expects the literal branch name; percent-encoding (e.g. %2F for /) is NOT
+ # decoded on GitHub's query router and would return zero runs for branches
+ # whose names contain a slash (e.g. release-changelog-update/1.0.0).
+ attempt=0
+ runs_response=""
+
+ while [ "$attempt" -lt 60 ]; do
+ runs_response_file=$(mktemp)
+
+ if ! runs_http_code=$(curl -sS -o "$runs_response_file" -w "%{http_code}" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs?branch=$branch_ref&event=workflow_dispatch&per_page=20"); then
+ runs_http_code="000"
+ fi
+
+ runs_response=$(cat "$runs_response_file" || true)
+ rm -f "$runs_response_file"
+
+ if [ "$runs_http_code" = "200" ] && printf '%s\n' "$runs_response" | jq empty > /dev/null 2>&1; then
+ run_id=$(printf '%s\n' "$runs_response" | jq -r --arg head_sha "$head_sha" '
+ .workflow_runs[]?
+ | select(.head_sha == $head_sha)
+ | .id
+ ' | head -n 1)
+
+ if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then
+ run_id=$(printf '%s\n' "$runs_response" | jq -r \
+ --arg branch "$branch_ref" \
+ --arg since "$dispatch_started_at" \
+ '.workflow_runs[]? | select(.head_branch == $branch and .created_at >= $since) | .id' \
+ | head -n 1)
+ fi
+
+ if [ -n "$run_id" ] && [ "$run_id" != "null" ]; then
+ echo "$run_id"
+ return 0
+ fi
+ fi
+
+ attempt=$((attempt + 1))
+ sleep 5
+ done
+
+ echo "::error::Could not find dispatched workflow run for '$workflow_file' on branch '$branch_ref' (SHA '$head_sha', since '$dispatch_started_at')." >&2
+ echo "Available workflow runs (last page):" >&2
+ printf '%s\n' "$runs_response" | jq -r '.workflow_runs[]? |
+ " id=\(.id) branch=\(.head_branch) sha=\(.head_sha) status=\(.status) conclusion=\(.conclusion // "null") created=\(.created_at) url=\(.html_url)"' >&2 || true
+ return 1
+}
+
+wait_for_workflow_run_completion() {
+ run_id="$1"
+ attempt=0
+
+ while [ "$attempt" -lt 120 ]; do
+ run_response_file=$(mktemp)
+
+ if ! run_http_code=$(curl -sS -o "$run_response_file" -w "%{http_code}" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$run_id"); then
+ run_http_code="000"
+ fi
+
+ run_response=$(cat "$run_response_file" || true)
+ rm -f "$run_response_file"
+
+ if [ "$run_http_code" = "200" ] && printf '%s\n' "$run_response" | jq empty > /dev/null 2>&1; then
+ run_status=$(printf '%s\n' "$run_response" | jq -r '.status // empty')
+ run_conclusion=$(printf '%s\n' "$run_response" | jq -r '.conclusion // empty')
+
+ echo "Workflow run $run_id status: $run_status conclusion: ${run_conclusion:-none}"
+
+ if [ "$run_status" = "completed" ]; then
+ return 0
+ fi
+ fi
+
+ attempt=$((attempt + 1))
+ sleep 10
+ done
+
+ echo "::error::Timed out waiting for workflow run $run_id to complete."
+ exit 1
+}
+
+mirror_workflow_jobs_as_check_runs() {
+ run_id="$1"
+ head_sha="$2"
+
+ jobs_response_file=$(mktemp)
+
+ if ! jobs_http_code=$(curl -sS -o "$jobs_response_file" -w "%{http_code}" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$run_id/jobs?per_page=100"); then
+ jobs_http_code="000"
+ fi
+
+ jobs_response=$(cat "$jobs_response_file" || true)
+ rm -f "$jobs_response_file"
+
+ if [ "$jobs_http_code" != "200" ] || ! printf '%s\n' "$jobs_response" | jq empty > /dev/null 2>&1; then
+ echo "::error::Failed to read jobs for workflow run $run_id (HTTP $jobs_http_code): $jobs_response"
+ exit 1
+ fi
+
+ if ! printf '%s\n' "$jobs_response" | jq -e '.jobs | length > 0' > /dev/null 2>&1; then
+ echo "::error::Workflow run $run_id has no jobs. Cannot mirror required checks."
+ exit 1
+ fi
+
+ jobs_file=$(mktemp)
+ tab=$(printf '\t')
+
+ printf '%s\n' "$jobs_response" | jq -r '
+ .jobs[]
+ | [
+ .name,
+ (.conclusion // "failure"),
+ (.html_url // "")
+ ]
+ | @tsv
+ ' > "$jobs_file"
+
+ while IFS="$tab" read -r job_name job_conclusion job_url; do
+ [ -z "$job_name" ] && continue
+
+ check_conclusion=$(map_job_conclusion_to_check_conclusion "$job_conclusion")
+
+ create_check_run \
+ "$head_sha" \
+ "$job_name" \
+ "$check_conclusion" \
+ "$job_url" \
+ "Mirrored result from dispatched workflow run $run_id."
+
+ case "$check_conclusion" in
+ success|neutral|skipped)
+ ;;
+ *)
+ rm -f "$jobs_file"
+ echo "::error::Dispatched CI job '$job_name' finished with conclusion '$job_conclusion'."
+ exit 1
+ ;;
+ esac
+ done < "$jobs_file"
+
+ rm -f "$jobs_file"
+}
+
+get_current_release_workflow_path() {
+ printf '%s\n' "$GITHUB_WORKFLOW_REF" | sed -n 's#^[^/]*/[^/]*/\(.github/workflows/[^@]*\)@.*#\1#p'
+}
+
+workflow_supports_auto_dispatch() {
+ workflow_path="$1"
+
+ workflow_content=$(grep -Ev '^[[:space:]]*#' "$workflow_path" || true)
+
+ printf '%s\n' "$workflow_content" | grep -Eq '(^|[^_[:alnum:]-])workflow_dispatch([^_[:alnum:]-]|$)'
+}
+
+resolve_ci_workflows() {
+ if [ -z "$CI_WORKFLOWS" ] || [ "$CI_WORKFLOWS" = "auto" ]; then
+ current_release_workflow_path="$(get_current_release_workflow_path)"
+
+ for workflow_path in .github/workflows/*.yml .github/workflows/*.yaml; do
+ [ -f "$workflow_path" ] || continue
+
+ if [ -n "$current_release_workflow_path" ] && [ "$workflow_path" = "$current_release_workflow_path" ]; then
+ echo "Skipping release workflow itself: $workflow_path" >&2
+ continue
+ fi
+
+ if workflow_supports_auto_dispatch "$workflow_path"; then
+ basename "$workflow_path"
+ fi
+ done | sort -u
+
+ return 0
+ fi
+
+ if [ "$CI_WORKFLOWS" = "none" ] || [ "$CI_WORKFLOWS" = "false" ]; then
+ return 0
+ fi
+
+ printf '%s' "$CI_WORKFLOWS" \
+ | tr ',' '\n' \
+ | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
+ | sed '/^$/d' \
+ | while IFS= read -r workflow_file; do
+ basename "$workflow_file"
+ done \
+ | sort -u
+}
+
+dispatch_configured_ci_workflows() {
+ branch_ref="$1"
+ head_sha="$2"
+
+ if [ -z "$GITHUB_TOKEN" ]; then
+ echo "::error::GITHUB_TOKEN is required to dispatch CI workflows."
+ exit 1
+ fi
+
+ if [ -z "$head_sha" ]; then
+ echo "::error::PR head SHA is required to mirror CI checks."
+ exit 1
+ fi
+
+ workflows_file=$(mktemp)
+ resolve_ci_workflows > "$workflows_file"
+
+ if [ ! -s "$workflows_file" ]; then
+ echo "No CI workflows configured or discovered for dispatch."
+ rm -f "$workflows_file"
+ return 0
+ fi
+
+ echo "Dispatching CI workflows for branch: $branch_ref"
+ echo "Mirroring dispatched CI jobs as check runs for PR head SHA: $head_sha"
+
+ while IFS= read -r workflow_file; do
+ [ -z "$workflow_file" ] && continue
+
+ echo "Dispatching workflow: $workflow_file"
+
+ dispatch_started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+ dispatch_payload=$(jq -n --arg ref "$branch_ref" '{ref: $ref}')
+ dispatch_response_file=$(mktemp)
+
+ if ! dispatch_http_code=$(curl -sS -o "$dispatch_response_file" -w "%{http_code}" -X POST \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ -d "$dispatch_payload" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/dispatches"); then
+ dispatch_http_code="000"
+ fi
+
+ dispatch_response=$(cat "$dispatch_response_file" || true)
+ rm -f "$dispatch_response_file"
+
+ case "$dispatch_http_code" in
+ 200|201|202|204)
+ echo "Workflow dispatched successfully: $workflow_file"
+ ;;
+ *)
+ echo "::error::Failed to dispatch workflow '$workflow_file' for branch '$branch_ref' (HTTP $dispatch_http_code): $dispatch_response"
+ rm -f "$workflows_file"
+ exit 1
+ ;;
+ esac
+
+ run_id=$(find_dispatched_workflow_run_id "$workflow_file" "$branch_ref" "$head_sha" "$dispatch_started_at") || {
+ rm -f "$workflows_file"
+ exit 1
+ }
+
+ echo "Found dispatched workflow run: $run_id"
+
+ wait_for_workflow_run_completion "$run_id"
+ mirror_workflow_jobs_as_check_runs "$run_id" "$head_sha"
+ done < "$workflows_file"
+
+ rm -f "$workflows_file"
+}
+
if [ "$CHANGELOG_UPDATED" != "true" ]; then
echo "Changelog was not updated. Skipping branch creation and pull request."
return 0
@@ -37,6 +389,9 @@ else
git commit -m "chore: update changelog for version $VERSION" || echo "No changes to commit"
fi
+branch_head_sha=$(git rev-parse HEAD)
+echo "Local branch HEAD SHA (before push): $branch_head_sha"
+
if [ "$DRY_RUN" = "true" ]; then
echo "Dry-Run: Skipping 'git push origin $branch_name'."
else
@@ -51,6 +406,7 @@ if [ "$DRY_RUN" = "true" ]; then
echo "Dry-Run: Skipping PR creation."
echo "Would have sent API request with title: '$pr_title' and branch '$branch_name'."
pr_url="https://github.com/$GITHUB_REPOSITORY/pull/dry-run-placeholder"
+ CHANGELOG_PR_HEAD_SHA="$branch_head_sha"
else
response_file=$(mktemp)
http_code=$(curl -sS -o "$response_file" -w "%{http_code}" -X POST \
@@ -62,22 +418,65 @@ else
response=$(cat "$response_file")
rm -f "$response_file"
- if [ "$http_code" != "201" ]; then
+ if [ "$http_code" = "422" ]; then
+ # PR already exists for this branch — look it up to get its current head SHA.
+ owner=$(printf '%s' "$GITHUB_REPOSITORY" | cut -d/ -f1)
+ existing_file=$(mktemp)
+ curl -sS -o "$existing_file" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?head=$owner:$branch_name&state=open&per_page=1"
+ existing_response=$(cat "$existing_file")
+ rm -f "$existing_file"
+ pr_url=$(printf '%s\n' "$existing_response" | jq -r '.[0].html_url // empty')
+ CHANGELOG_PR_HEAD_SHA=$(printf '%s\n' "$existing_response" | jq -r '.[0].head.sha // empty')
+ echo "PR already exists: $pr_url (head SHA: $CHANGELOG_PR_HEAD_SHA)"
+ elif [ "$http_code" != "201" ]; then
echo "::error:: PR creation failed (HTTP $http_code): $response"
exit 1
+ else
+ pr_url=$(echo "$response" | jq -r '.html_url // empty')
+ # Use the SHA GitHub returned in the PR response — this is authoritative and
+ # guaranteed to match what GitHub links to the PR's required checks.
+ CHANGELOG_PR_HEAD_SHA=$(echo "$response" | jq -r '.head.sha // empty')
fi
- pr_url=$(echo "$response" | jq -r '.html_url // empty')
-
if [ -z "$pr_url" ] || [ "$pr_url" = "null" ]; then
- echo "::error:: PR was created but html_url is missing: $response"
+ echo "::error:: PR URL could not be determined: $response"
exit 1
fi
+
+ if [ -z "$CHANGELOG_PR_HEAD_SHA" ] || [ "$CHANGELOG_PR_HEAD_SHA" = "null" ]; then
+ echo "PR head SHA not returned by API — falling back to local git SHA"
+ CHANGELOG_PR_HEAD_SHA="$branch_head_sha"
+ fi
fi
+echo "CHANGELOG_PR_HEAD_SHA=$CHANGELOG_PR_HEAD_SHA" | tee -a "$GITHUB_ENV"
+export CHANGELOG_PR_HEAD_SHA
+echo "Changelog PR head SHA: $CHANGELOG_PR_HEAD_SHA"
+
echo "PR_URL=$pr_url" | tee -a $GITHUB_ENV
export PR_URL="$pr_url"
+echo "--- CI dispatch debug ---"
+echo " pwd: $(pwd)"
+echo " branch_name: $branch_name"
+echo " CHANGELOG_PR_HEAD_SHA: $CHANGELOG_PR_HEAD_SHA"
+echo " CI_WORKFLOWS: $CI_WORKFLOWS"
+echo " .github/workflows files:"
+for _dbg_wf in .github/workflows/*.yml .github/workflows/*.yaml; do
+ [ -f "$_dbg_wf" ] && echo " $_dbg_wf"
+done
+echo "--- end debug ---"
+
+if [ "$DRY_RUN" = "true" ]; then
+ echo "Dry-Run: Skipping CI workflow dispatch."
+else
+ dispatch_configured_ci_workflows "$branch_name" "$CHANGELOG_PR_HEAD_SHA"
+fi
+
cd "$GITHUB_WORKSPACE"
rm -rf "$TEMP_DIR"
diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh
index 9839535..7d5269e 100644
--- a/scripts/entrypoint.sh
+++ b/scripts/entrypoint.sh
@@ -30,6 +30,14 @@ echo "RELEASE_TITLE_PREFIX=$(printenv INPUT_RELEASE-TITLE-PREFIX)" | tee -a "$GI
export VERSION_OVERRIDE="$(printenv INPUT_VERSION)"
echo "VERSION_OVERRIDE=$VERSION_OVERRIDE" | tee -a "$GITHUB_ENV"
+export CI_WORKFLOWS="$(printenv INPUT_CI-WORKFLOWS)"
+
+if [ -z "$CI_WORKFLOWS" ]; then
+ CI_WORKFLOWS="auto"
+fi
+
+echo "CI_WORKFLOWS=$CI_WORKFLOWS" | tee -a "$GITHUB_ENV"
+
# Import scripts instead of executing them with sh
. /app/scripts/setup-release.sh
. /app/scripts/collect-commits.sh
diff --git a/scripts/update-changelog.sh b/scripts/update-changelog.sh
index 71bca2c..099c8f9 100644
--- a/scripts/update-changelog.sh
+++ b/scripts/update-changelog.sh
@@ -40,7 +40,11 @@ if [ ! -f contributors.txt ]; then
fi
commit_log=$(cat commit_log.txt || echo "")
-contributors=$(cat contributors.txt || echo "")
+
+full_changelog=""
+if [ -f full_changelog.txt ]; then
+ full_changelog=$(cat full_changelog.txt || echo "")
+fi
# Check if the version already exists in the changelog
if grep -Eq "^## \\[\\[$VERSION\\]\\]" "$CHANGELOG_FILE_PATH" || \
@@ -57,19 +61,23 @@ if grep -Eq "^## \\[\\[$VERSION\\]\\]" "$CHANGELOG_FILE_PATH" || \
echo "::warning:: Failed to extract description with awk"
fi
- if [ -z "$description" ]; then
+ if [ -z "$(printf '%s' "$description" | tr -d '[:space:]')" ]; then
echo "No description available for version $VERSION."
- description="No description available for version $VERSION."
+ description="This release includes the changes below."
fi
{
echo "$description"
echo ""
- echo "### Commits"
- echo "$commit_log"
+ echo "------"
echo ""
- echo "### Contributors"
- echo "$contributors"
+ echo "## What's Changed"
+ echo "$commit_log"
+
+ if [ -n "$full_changelog" ]; then
+ echo ""
+ echo "$full_changelog"
+ fi
} > changelog_content.txt
echo "CHANGELOG_UPDATED=false" | tee -a $GITHUB_ENV
@@ -82,6 +90,10 @@ echo "Version $VERSION not found in changelog.md. Updating changelog..."
description=$(awk '/^## \[unreleased\]/{flag=1; next} /^## \[/{flag=0} flag' "$CHANGELOG_FILE_PATH")
+if [ -z "$(printf '%s' "$description" | tr -d '[:space:]')" ]; then
+ description="This release includes the changes below."
+fi
+
header=$(awk '/^## \[unreleased\]/{exit} {print}' "$CHANGELOG_FILE_PATH")
rest=$(awk 'BEGIN {found_unreleased=0; found_first_version=0} \
/^## \[unreleased\]/ {found_unreleased=1; next} \
@@ -106,11 +118,15 @@ fi
{
echo "$description"
echo ""
- echo "### Commits"
- echo "$commit_log"
+ echo "------"
echo ""
- echo "### Contributors"
- echo "$contributors"
+ echo "## What's Changed"
+ echo "$commit_log"
+
+ if [ -n "$full_changelog" ]; then
+ echo ""
+ echo "$full_changelog"
+ fi
} > changelog_content.txt
echo "CHANGELOG_UPDATED=true" | tee -a $GITHUB_ENV
diff --git a/src/__tests__/mocks/actions-github.ts b/src/__tests__/mocks/actions-github.ts
index c4482a5..51c2aab 100644
--- a/src/__tests__/mocks/actions-github.ts
+++ b/src/__tests__/mocks/actions-github.ts
@@ -19,6 +19,7 @@ export type ReleaseResponse = {
type CreateReleaseHandler = (input: ReleaseRequest) => Promise;
const releaseCalls: ReleaseRequest[] = [];
+const octokitTokens: string[] = [];
const defaultHandler: CreateReleaseHandler = (
input: ReleaseRequest,
@@ -38,13 +39,15 @@ export const context: { repo: { owner: string; repo: string } } = {
},
};
-export function getOctokit(_token: string): {
+export function getOctokit(token: string): {
rest: {
repos: {
createRelease: (input: ReleaseRequest) => Promise;
};
};
} {
+ octokitTokens.push(token);
+
return {
rest: {
repos: {
@@ -59,6 +62,7 @@ export function getOctokit(_token: string): {
export function __resetGithubMock(): void {
releaseCalls.length = 0;
+ octokitTokens.length = 0;
context.repo.owner = "test-owner";
context.repo.repo = "test-repo";
createReleaseHandler = defaultHandler;
@@ -76,3 +80,7 @@ export function __setCreateReleaseHandler(handler: CreateReleaseHandler): void {
export function __getCreateReleaseCalls(): readonly ReleaseRequest[] {
return [...releaseCalls];
}
+
+export function __getOctokitTokens(): readonly string[] {
+ return [...octokitTokens];
+}
diff --git a/src/__tests__/release.test.ts b/src/__tests__/release.test.ts
index 1f04082..b316f36 100644
--- a/src/__tests__/release.test.ts
+++ b/src/__tests__/release.test.ts
@@ -12,6 +12,7 @@ import {
import { createRelease } from "../release.js";
import {
__getCreateReleaseCalls,
+ __getOctokitTokens,
__resetGithubMock,
__setCreateReleaseHandler,
__setRepoContext,
@@ -19,6 +20,15 @@ import {
describe("createRelease", () => {
const originalEnv: NodeJS.ProcessEnv = { ...process.env };
+ const tempDirs: string[] = [];
+
+ function createGithubOutputFilePath(): string {
+ const tempDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "github-release-test-"),
+ );
+ tempDirs.push(tempDir);
+ return path.join(tempDir, "github-output.txt");
+ }
beforeEach(() => {
__resetGithubMock();
@@ -39,16 +49,16 @@ describe("createRelease", () => {
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
+
+ for (const tempDir of tempDirs.splice(0)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
});
test("creates a release with default values and writes GITHUB_OUTPUT", async () => {
__setRepoContext("open-resource-discovery", "github-release");
- const githubOutput = path.join(
- fs.mkdtempSync(path.join(os.tmpdir(), "github-release-test-")),
- "github-output.txt",
- );
-
+ const githubOutput = createGithubOutputFilePath();
process.env.GITHUB_OUTPUT = githubOutput;
const releaseUrl = await createRelease();
@@ -57,6 +67,8 @@ describe("createRelease", () => {
"https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3",
);
+ expect(__getOctokitTokens()).toEqual(["test-token"]);
+
expect(__getCreateReleaseCalls()).toEqual([
{
owner: "open-resource-discovery",
@@ -90,6 +102,8 @@ describe("createRelease", () => {
"https://github.com/acme/specification/releases/tag/v1.2.3",
);
+ expect(__getOctokitTokens()).toEqual(["test-token"]);
+
expect(__getCreateReleaseCalls()).toEqual([
{
owner: "acme",
@@ -104,6 +118,26 @@ describe("createRelease", () => {
]);
});
+ test("only exact string true enables draft and prerelease", async () => {
+ process.env.RELEASE_DRAFT = "TRUE";
+ process.env.RELEASE_PRERELEASE = "1";
+
+ await createRelease();
+
+ expect(__getCreateReleaseCalls()).toEqual([
+ {
+ owner: "test-owner",
+ repo: "test-repo",
+ tag_name: "v1.2.3",
+ target_commitish: "main",
+ name: "v1.2.3",
+ body: "",
+ draft: false,
+ prerelease: false,
+ },
+ ]);
+ });
+
test("fails if GITHUB_TOKEN is missing", async () => {
delete process.env.GITHUB_TOKEN;
@@ -111,6 +145,18 @@ describe("createRelease", () => {
"GITHUB_TOKEN is required but not set.",
);
+ expect(__getOctokitTokens()).toHaveLength(0);
+ expect(__getCreateReleaseCalls()).toHaveLength(0);
+ });
+
+ test("fails if GITHUB_TOKEN is empty", async () => {
+ process.env.GITHUB_TOKEN = "";
+
+ await expect(createRelease()).rejects.toThrow(
+ "GITHUB_TOKEN is required but not set.",
+ );
+
+ expect(__getOctokitTokens()).toHaveLength(0);
expect(__getCreateReleaseCalls()).toHaveLength(0);
});
@@ -121,6 +167,18 @@ describe("createRelease", () => {
"TAG is required but not set.",
);
+ expect(__getOctokitTokens()).toHaveLength(0);
+ expect(__getCreateReleaseCalls()).toHaveLength(0);
+ });
+
+ test("fails if TAG is empty", async () => {
+ process.env.TAG = "";
+
+ await expect(createRelease()).rejects.toThrow(
+ "TAG is required but not set.",
+ );
+
+ expect(__getOctokitTokens()).toHaveLength(0);
expect(__getCreateReleaseCalls()).toHaveLength(0);
});
@@ -131,6 +189,7 @@ describe("createRelease", () => {
await expect(createRelease()).rejects.toThrow("GitHub API error");
+ expect(__getOctokitTokens()).toEqual(["test-token"]);
expect(__getCreateReleaseCalls()).toHaveLength(1);
});
@@ -143,4 +202,16 @@ describe("createRelease", () => {
expect(__getCreateReleaseCalls()).toHaveLength(1);
});
+
+ test("fails if the response has an empty html_url", async () => {
+ __setCreateReleaseHandler(() =>
+ Promise.resolve({ data: { html_url: "" } }),
+ );
+
+ await expect(createRelease()).rejects.toThrow(
+ "Release response is missing html_url.",
+ );
+
+ expect(__getCreateReleaseCalls()).toHaveLength(1);
+ });
});
diff --git a/src/__tests__/scripts/collect-commits-script.test.ts b/src/__tests__/scripts/collect-commits-script.test.ts
new file mode 100644
index 0000000..999609c
--- /dev/null
+++ b/src/__tests__/scripts/collect-commits-script.test.ts
@@ -0,0 +1,260 @@
+import * as fs from "node:fs";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import {
+ createTempDir,
+ runSourcedShellScript,
+ writeExecutable,
+} from "./test-utils.js";
+
+describe("collect-commits.sh", () => {
+ let tempDir: string;
+ let binDir: string;
+
+ beforeEach(() => {
+ tempDir = createTempDir("github-release-collect-commits-");
+ binDir = path.join(tempDir, "bin");
+ fs.mkdirSync(binDir);
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test("deduplicates contributors by GitHub login when different emails are used", () => {
+ writeExecutable(
+ path.join(binDir, "git"),
+ `#!/bin/sh
+sep=$(printf '\\037')
+
+if [ "$1" = "fetch" ]; then
+ exit 0
+fi
+
+if [ "$1" = "tag" ] && [ "$2" = "--list" ]; then
+ printf '%s\\n' "v1.0.0"
+ exit 0
+fi
+
+if [ "$1" = "log" ]; then
+ printf '%s\\n' "sha-one\${sep}1111111\${sep}Alice\${sep}alice-work@example.com\${sep}First change"
+ printf '%s\\n' "sha-two\${sep}2222222\${sep}Alice\${sep}alice-private@example.com\${sep}Second change"
+ exit 0
+fi
+
+printf '%s\\n' "unexpected git call: $*" >&2
+exit 1
+`,
+ );
+
+ writeExecutable(
+ path.join(binDir, "curl"),
+ `#!/bin/sh
+case "$*" in
+ *sha-one*|*sha-two*)
+ printf '%s\\n' '{"author":{"login":"alice"}}'
+ ;;
+ *)
+ printf '%s\\n' '{"author":null}'
+ ;;
+esac
+`,
+ );
+
+ writeExecutable(
+ path.join(binDir, "jq"),
+ `#!/bin/sh
+input=$(cat)
+
+if [ "$1" = "empty" ]; then
+ exit 0
+fi
+
+if [ "$1" = "-r" ]; then
+ printf '%s\\n' "$input" | sed -n 's/.*"login":"\\([^"]*\\)".*/\\1/p'
+ exit 0
+fi
+
+exit 0
+`,
+ );
+
+ runSourcedShellScript({
+ scriptRelativePath: "scripts/collect-commits.sh",
+ cwd: tempDir,
+ binDir,
+ env: {
+ DRY_RUN: "false",
+ RELEASE_EXISTS: "false",
+ GITHUB_SERVER_URL: "https://github.com",
+ GITHUB_API_URL: "https://api.github.com",
+ GITHUB_REPOSITORY: "open-resource-discovery/github-release",
+ GITHUB_TOKEN: "test-token",
+ TAG: "v1.1.0",
+ TAG_EXISTS: "false",
+ },
+ });
+
+ expect(
+ fs.readFileSync(path.join(tempDir, "contributors.txt"), "utf8"),
+ ).toBe("@alice\n");
+
+ expect(fs.readFileSync(path.join(tempDir, "commit_log.txt"), "utf8")).toBe(
+ [
+ "* First change by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)",
+ "* Second change by @alice in [2222222](https://github.com/open-resource-discovery/github-release/commit/sha-two)",
+ "",
+ ].join("\n"),
+ );
+ });
+
+ test("does not create contributor mention for commits without GitHub login", () => {
+ writeExecutable(
+ path.join(binDir, "git"),
+ `#!/bin/sh
+sep=$(printf '\\037')
+
+if [ "$1" = "fetch" ]; then
+ exit 0
+fi
+
+if [ "$1" = "tag" ] && [ "$2" = "--list" ]; then
+ printf '%s\\n' "v1.0.0"
+ exit 0
+fi
+
+if [ "$1" = "log" ]; then
+ printf '%s\\n' "sha-one\${sep}1111111\${sep}Bob\${sep}bob@example.com\${sep}Fallback change"
+ exit 0
+fi
+
+printf '%s\\n' "unexpected git call: $*" >&2
+exit 1
+`,
+ );
+
+ writeExecutable(
+ path.join(binDir, "curl"),
+ `#!/bin/sh
+printf '%s\\n' '{"author":null}'
+`,
+ );
+
+ writeExecutable(
+ path.join(binDir, "jq"),
+ `#!/bin/sh
+input=$(cat)
+
+if [ "$1" = "empty" ]; then
+ exit 0
+fi
+
+if [ "$1" = "-r" ]; then
+ printf '%s\\n' "$input" | sed -n 's/.*"login":"\\([^"]*\\)".*/\\1/p'
+ exit 0
+fi
+
+exit 0
+`,
+ );
+
+ runSourcedShellScript({
+ scriptRelativePath: "scripts/collect-commits.sh",
+ cwd: tempDir,
+ binDir,
+ env: {
+ DRY_RUN: "false",
+ RELEASE_EXISTS: "false",
+ GITHUB_SERVER_URL: "https://github.com",
+ GITHUB_API_URL: "https://api.github.com",
+ GITHUB_REPOSITORY: "open-resource-discovery/github-release",
+ GITHUB_TOKEN: "test-token",
+ TAG: "v1.1.0",
+ TAG_EXISTS: "false",
+ },
+ });
+
+ expect(
+ fs.readFileSync(path.join(tempDir, "contributors.txt"), "utf8"),
+ ).toBe("\n");
+
+ expect(fs.readFileSync(path.join(tempDir, "commit_log.txt"), "utf8")).toBe(
+ "* Fallback change by Bob in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)\n",
+ );
+ });
+
+ test("does not mention bot contributors", () => {
+ writeExecutable(
+ path.join(binDir, "git"),
+ `#!/bin/sh
+sep=$(printf '\\037')
+
+if [ "$1" = "fetch" ]; then
+ exit 0
+fi
+
+if [ "$1" = "tag" ] && [ "$2" = "--list" ]; then
+ printf '%s\\n' "v1.0.0"
+ exit 0
+fi
+
+if [ "$1" = "log" ]; then
+ printf '%s\\n' "sha-bot\${sep}3333333\${sep}dependabot[bot]\${sep}dependabot[bot]@users.noreply.github.com\${sep}Dependency update"
+ exit 0
+fi
+
+printf '%s\\n' "unexpected git call: $*" >&2
+exit 1
+`,
+ );
+
+ writeExecutable(
+ path.join(binDir, "curl"),
+ `#!/bin/sh
+printf '%s\\n' '{"author":{"login":"dependabot"}}'
+`,
+ );
+
+ writeExecutable(
+ path.join(binDir, "jq"),
+ `#!/bin/sh
+input=$(cat)
+
+if [ "$1" = "empty" ]; then
+ exit 0
+fi
+
+if [ "$1" = "-r" ]; then
+ printf '%s\\n' "$input" | sed -n 's/.*"login":"\\([^"]*\\)".*/\\1/p'
+ exit 0
+fi
+
+exit 0
+`,
+ );
+
+ runSourcedShellScript({
+ scriptRelativePath: "scripts/collect-commits.sh",
+ cwd: tempDir,
+ binDir,
+ env: {
+ DRY_RUN: "false",
+ RELEASE_EXISTS: "false",
+ GITHUB_SERVER_URL: "https://github.com",
+ GITHUB_API_URL: "https://api.github.com",
+ GITHUB_REPOSITORY: "open-resource-discovery/github-release",
+ GITHUB_TOKEN: "test-token",
+ TAG: "v1.1.0",
+ TAG_EXISTS: "false",
+ },
+ });
+
+ expect(
+ fs.readFileSync(path.join(tempDir, "contributors.txt"), "utf8"),
+ ).toBe("\n");
+
+ expect(fs.readFileSync(path.join(tempDir, "commit_log.txt"), "utf8")).toBe(
+ "* Dependency update by dependabot[bot] in [3333333](https://github.com/open-resource-discovery/github-release/commit/sha-bot)\n",
+ );
+ });
+});
diff --git a/src/__tests__/scripts/create-pr-script.test.ts b/src/__tests__/scripts/create-pr-script.test.ts
new file mode 100644
index 0000000..c540dd5
--- /dev/null
+++ b/src/__tests__/scripts/create-pr-script.test.ts
@@ -0,0 +1,462 @@
+import * as fs from "node:fs";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import {
+ createTempDir,
+ runShellFunction,
+ writeExecutable,
+ writeJqMock,
+} from "./test-utils.js";
+
+const SCRIPT = "scripts/create-pr.sh";
+
+const BASE_ENV = {
+ GITHUB_TOKEN: "test-token",
+ GITHUB_API_URL: "https://api.github.com",
+ GITHUB_REPOSITORY: "owner/repo",
+ GITHUB_WORKFLOW_REF:
+ "owner/repo/.github/workflows/release.yml@refs/heads/main",
+ GITHUB_ENV: "/dev/null",
+ CI_WORKFLOWS: "auto",
+ VERSION: "1.0.0",
+ DRY_RUN: "false",
+};
+
+// ---------------------------------------------------------------------------
+// Auto-discovery tests (resolve_ci_workflows — no jq needed)
+// ---------------------------------------------------------------------------
+describe("create-pr.sh — auto-discovery", () => {
+ let tempDir: string;
+ let binDir: string;
+
+ beforeEach(() => {
+ tempDir = createTempDir("github-release-create-pr-");
+ binDir = path.join(tempDir, "bin");
+ fs.mkdirSync(binDir);
+ fs.mkdirSync(path.join(tempDir, ".github", "workflows"), {
+ recursive: true,
+ });
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test("finds workflow that declares workflow_dispatch", () => {
+ fs.writeFileSync(
+ path.join(tempDir, ".github", "workflows", "dummy-ci.yml"),
+ [
+ "on:",
+ " workflow_dispatch:",
+ "jobs:",
+ " test:",
+ " runs-on: ubuntu-latest",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: "resolve_ci_workflows",
+ cwd: tempDir,
+ binDir,
+ env: BASE_ENV,
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("dummy-ci.yml");
+ });
+
+ test("skips the release workflow itself", () => {
+ fs.writeFileSync(
+ path.join(tempDir, ".github", "workflows", "release.yml"),
+ [
+ "on:",
+ " push:",
+ " workflow_dispatch:",
+ "jobs:",
+ " release:",
+ " runs-on: ubuntu-latest",
+ ].join("\n"),
+ "utf8",
+ );
+
+ fs.writeFileSync(
+ path.join(tempDir, ".github", "workflows", "ci.yml"),
+ [
+ "on:",
+ " workflow_dispatch:",
+ "jobs:",
+ " test:",
+ " runs-on: ubuntu-latest",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: "resolve_ci_workflows",
+ cwd: tempDir,
+ binDir,
+ env: {
+ ...BASE_ENV,
+ GITHUB_WORKFLOW_REF:
+ "owner/repo/.github/workflows/release.yml@refs/heads/main",
+ },
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("ci.yml");
+ expect(result.stdout).not.toContain("release.yml");
+ });
+
+ test("does not discover workflow that has no workflow_dispatch trigger", () => {
+ fs.writeFileSync(
+ path.join(tempDir, ".github", "workflows", "deploy.yml"),
+ [
+ "on:",
+ " push:",
+ " branches: [main]",
+ "jobs:",
+ " deploy:",
+ " runs-on: ubuntu-latest",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: "resolve_ci_workflows",
+ cwd: tempDir,
+ binDir,
+ env: BASE_ENV,
+ });
+
+ expect(result.exitCode).toBe(0);
+ // deploy.yml must not appear; version banner lines are expected noise
+ expect(result.stdout).not.toContain("deploy.yml");
+ expect(result.stdout).not.toContain(".yml");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// dispatch_configured_ci_workflows tests (require jq)
+// ---------------------------------------------------------------------------
+describe("create-pr.sh — dispatch_configured_ci_workflows", () => {
+ let tempDir: string;
+ let binDir: string;
+
+ const PR_HEAD_SHA = "abc123def456abc123def456abc123def456abc1";
+ const RUN_ID = 42;
+ const BRANCH = "release-changelog-update/1.0.0";
+
+ const RUNS_JSON = JSON.stringify({
+ workflow_runs: [
+ {
+ id: RUN_ID,
+ head_sha: PR_HEAD_SHA,
+ head_branch: BRANCH,
+ status: "completed",
+ conclusion: "success",
+ created_at: "2024-01-01T00:00:00Z",
+ html_url: `https://github.com/owner/repo/actions/runs/${RUN_ID}`,
+ },
+ ],
+ });
+
+ const RUN_STATUS_JSON = JSON.stringify({
+ status: "completed",
+ conclusion: "success",
+ });
+
+ const JOBS_JSON = JSON.stringify({
+ jobs: [
+ {
+ name: "Dummy CI Check",
+ conclusion: "success",
+ html_url: `https://github.com/owner/repo/actions/runs/${RUN_ID}/jobs/1`,
+ },
+ ],
+ });
+
+ function writeCurlMock(
+ overrides: {
+ dispatchStatus?: string;
+ runsJson?: string;
+ runStatusJson?: string;
+ jobsJson?: string;
+ checkRunStatus?: string;
+ } = {},
+ ): void {
+ const dispatchStatus = overrides.dispatchStatus ?? "204";
+ const runsJson = overrides.runsJson ?? RUNS_JSON;
+ const runStatusJson = overrides.runStatusJson ?? RUN_STATUS_JSON;
+ const jobsJson = overrides.jobsJson ?? JOBS_JSON;
+ const checkRunStatus = overrides.checkRunStatus ?? "201";
+
+ // Write each response body as a temp file so the mock can serve them
+ const runsFile = path.join(tempDir, "mock-runs.json");
+ const runFile = path.join(tempDir, "mock-run.json");
+ const jobsFile = path.join(tempDir, "mock-jobs.json");
+ const checkFile = path.join(tempDir, "mock-check.json");
+
+ fs.writeFileSync(runsFile, runsJson, "utf8");
+ fs.writeFileSync(runFile, runStatusJson, "utf8");
+ fs.writeFileSync(jobsFile, jobsJson, "utf8");
+ fs.writeFileSync(checkFile, '{"id":99}', "utf8");
+
+ const runsFilePosix = runsFile.replace(/\\/g, "/");
+ const runFilePosix = runFile.replace(/\\/g, "/");
+ const jobsFilePosix = jobsFile.replace(/\\/g, "/");
+ const checkFilePosix = checkFile.replace(/\\/g, "/");
+
+ writeExecutable(
+ path.join(binDir, "curl"),
+ `#!/bin/sh
+# Extract -o and the URL (last non-flag arg) from argument list.
+# Matching on the URL only (not $* which includes the request body) avoids
+# false matches when a body payload contains paths like /runs/42/jobs/1.
+output_file=""
+url=""
+prev=""
+for arg in "$@"; do
+ if [ "$prev" = "-o" ]; then
+ output_file="$arg"
+ fi
+ case "$arg" in
+ http://*|https://*) url="$arg" ;;
+ esac
+ prev="$arg"
+done
+
+case "$url" in
+ */dispatches*)
+ [ -n "$output_file" ] && printf '' > "$output_file"
+ printf '%s' "${dispatchStatus}"
+ ;;
+ */runs/*/jobs*)
+ [ -n "$output_file" ] && cat '${jobsFilePosix}' > "$output_file"
+ printf '%s' "200"
+ ;;
+ */runs/${RUN_ID}*)
+ [ -n "$output_file" ] && cat '${runFilePosix}' > "$output_file"
+ printf '%s' "200"
+ ;;
+ */workflows/*/runs*)
+ [ -n "$output_file" ] && cat '${runsFilePosix}' > "$output_file"
+ printf '%s' "200"
+ ;;
+ */check-runs*)
+ [ -n "$output_file" ] && cat '${checkFilePosix}' > "$output_file"
+ printf '%s' "${checkRunStatus}"
+ ;;
+ *)
+ [ -n "$output_file" ] && printf '' > "$output_file"
+ printf '%s' "000"
+ ;;
+esac
+`,
+ );
+ }
+
+ beforeEach(() => {
+ tempDir = createTempDir("github-release-create-pr-dispatch-");
+ binDir = path.join(tempDir, "bin");
+ fs.mkdirSync(binDir);
+ fs.mkdirSync(path.join(tempDir, ".github", "workflows"), {
+ recursive: true,
+ });
+
+ // Mock sleep so polling loops don't actually wait
+ writeExecutable(path.join(binDir, "sleep"), "#!/bin/sh\n");
+
+ writeJqMock(binDir, tempDir);
+
+ fs.writeFileSync(
+ path.join(tempDir, ".github", "workflows", "dummy-ci.yml"),
+ [
+ "on:",
+ " workflow_dispatch:",
+ "jobs:",
+ " Dummy CI Check:",
+ " runs-on: ubuntu-latest",
+ ].join("\n"),
+ "utf8",
+ );
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test("full happy path: dispatch → find run → wait → create check run with exact job name", () => {
+ writeCurlMock();
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`,
+ cwd: tempDir,
+ binDir,
+ env: { ...BASE_ENV, CI_WORKFLOWS: "auto" },
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("Dispatching workflow: dummy-ci.yml");
+ expect(result.stdout).toContain(
+ "Workflow dispatched successfully: dummy-ci.yml",
+ );
+ // Resolving message goes to stderr (function is called via $() so only stdout is captured)
+ expect(result.stderr).toContain(
+ "Resolving workflow run for dispatched workflow: dummy-ci.yml",
+ );
+ expect(result.stdout).toContain(`Found dispatched workflow run: ${RUN_ID}`);
+ expect(result.stdout).toContain(
+ "Created check run 'Dummy CI Check' with conclusion 'success'",
+ );
+ expect(result.stdout).toContain(PR_HEAD_SHA);
+ });
+
+ test("dispatch call sends the changelog branch ref", () => {
+ const captureFile = path.join(tempDir, "curl-args.txt");
+ const captureFilePosix = captureFile.replace(/\\/g, "/");
+
+ const runsFile = path.join(tempDir, "mock-runs.json");
+ const runFile = path.join(tempDir, "mock-run.json");
+ const jobsFile = path.join(tempDir, "mock-jobs.json");
+ const checkFile = path.join(tempDir, "mock-check.json");
+ fs.writeFileSync(runsFile, RUNS_JSON, "utf8");
+ fs.writeFileSync(runFile, RUN_STATUS_JSON, "utf8");
+ fs.writeFileSync(jobsFile, JOBS_JSON, "utf8");
+ fs.writeFileSync(checkFile, '{"id":99}', "utf8");
+
+ const runsFilePosix = runsFile.replace(/\\/g, "/");
+ const runFilePosix = runFile.replace(/\\/g, "/");
+ const jobsFilePosix = jobsFile.replace(/\\/g, "/");
+ const checkFilePosix = checkFile.replace(/\\/g, "/");
+
+ writeExecutable(
+ path.join(binDir, "curl"),
+ `#!/bin/sh
+output_file=""
+url=""
+prev=""
+for arg in "$@"; do
+ if [ "$prev" = "-o" ]; then
+ output_file="$arg"
+ fi
+ case "$arg" in
+ http://*|https://*) url="$arg" ;;
+ esac
+ prev="$arg"
+done
+
+printf '%s\\n' "$*" >> '${captureFilePosix}'
+
+case "$url" in
+ */dispatches*)
+ [ -n "$output_file" ] && printf '' > "$output_file"
+ printf '%s' "204"
+ ;;
+ */runs/*/jobs*)
+ [ -n "$output_file" ] && cat '${jobsFilePosix}' > "$output_file"
+ printf '%s' "200"
+ ;;
+ */runs/${RUN_ID}*)
+ [ -n "$output_file" ] && cat '${runFilePosix}' > "$output_file"
+ printf '%s' "200"
+ ;;
+ */workflows/*/runs*)
+ [ -n "$output_file" ] && cat '${runsFilePosix}' > "$output_file"
+ printf '%s' "200"
+ ;;
+ */check-runs*)
+ [ -n "$output_file" ] && cat '${checkFilePosix}' > "$output_file"
+ printf '%s' "201"
+ ;;
+ *)
+ [ -n "$output_file" ] && printf '' > "$output_file"
+ printf '%s' "000"
+ ;;
+esac
+`,
+ );
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`,
+ cwd: tempDir,
+ binDir,
+ env: { ...BASE_ENV, CI_WORKFLOWS: "auto" },
+ });
+
+ expect(result.exitCode).toBe(0);
+
+ const capturedArgs = fs.readFileSync(captureFile, "utf8");
+ // The branch ref is in the -d payload which spans multiple lines when the
+ // jq-built JSON is pretty-printed; check the whole capture for the dispatch
+ // endpoint and for the raw branch value that appears in the {ref:} body.
+ expect(capturedArgs).toContain("dispatches");
+ expect(capturedArgs).toContain(BRANCH);
+ });
+
+ test("check-run creation includes the changelog PR head SHA", () => {
+ writeCurlMock();
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`,
+ cwd: tempDir,
+ binDir,
+ env: { ...BASE_ENV, CI_WORKFLOWS: "auto" },
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain(`for ${PR_HEAD_SHA}`);
+ });
+
+ test("fails when no workflow run can be found after polling", () => {
+ writeCurlMock();
+
+ // Override find_dispatched_workflow_run_id to return immediately without polling
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: String.raw`
+find_dispatched_workflow_run_id() {
+ echo "Resolving workflow run for dispatched workflow: $1" >&2
+ echo "::error::Could not find dispatched workflow run for '$1' on branch '$2' (SHA '$3', since '$4')." >&2
+ return 1
+}
+`,
+ functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`,
+ cwd: tempDir,
+ binDir,
+ env: { ...BASE_ENV, CI_WORKFLOWS: "auto" },
+ });
+
+ expect(result.exitCode).not.toBe(0);
+ // error messages go to stderr (same as the real function when called via $())
+ expect(result.stderr).toContain("Could not find dispatched workflow run");
+ });
+
+ test("dispatches nothing and succeeds when ci-workflows is none", () => {
+ writeCurlMock();
+
+ const result = runShellFunction({
+ scriptRelativePath: SCRIPT,
+ setup: "",
+ functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`,
+ cwd: tempDir,
+ binDir,
+ env: { ...BASE_ENV, CI_WORKFLOWS: "none" },
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("No CI workflows configured or discovered");
+ });
+});
diff --git a/src/__tests__/scripts/test-utils.ts b/src/__tests__/scripts/test-utils.ts
new file mode 100644
index 0000000..9ff37ff
--- /dev/null
+++ b/src/__tests__/scripts/test-utils.ts
@@ -0,0 +1,317 @@
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+export function createTempDir(prefix: string): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+export function writeExecutable(filePath: string, content: string): void {
+ fs.writeFileSync(filePath, content.replace(/\r\n/g, "\n"), "utf8");
+ fs.chmodSync(filePath, 0o755);
+}
+
+function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`;
+}
+
+function toShellPath(value: string): string {
+ return value.replace(/\\/g, "/");
+}
+
+function getCurrentPath(): string {
+ return process.env.PATH ?? process.env.Path ?? process.env.path ?? "";
+}
+
+function resolvePosixShell(): string {
+ const candidates = [
+ process.env.SHELL,
+ "/bin/sh",
+ "/usr/bin/sh",
+ "/usr/local/bin/sh",
+ "C:\\Program Files\\Git\\bin\\sh.exe",
+ "C:\\Program Files\\Git\\usr\\bin\\sh.exe",
+ "C:\\Program Files (x86)\\Git\\bin\\sh.exe",
+ "C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe",
+ "C:\\msys64\\usr\\bin\\sh.exe",
+ ].filter((candidate): candidate is string => Boolean(candidate));
+
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+
+ if (process.platform === "win32") {
+ const whereResult = spawnSync("where.exe", ["sh"], {
+ encoding: "utf8",
+ env: process.env,
+ });
+
+ if (whereResult.status === 0) {
+ const shellPath = whereResult.stdout
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .find((line) => line.length > 0 && fs.existsSync(line));
+
+ if (shellPath) {
+ return shellPath;
+ }
+ }
+ }
+
+ throw new Error(
+ "POSIX shell not found. Install Git Bash or run these script tests on Linux/macOS.",
+ );
+}
+
+function buildChildEnv(env: Record): NodeJS.ProcessEnv {
+ const childEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ ...env,
+ };
+
+ delete childEnv.Path;
+ delete childEnv.path;
+ childEnv.PATH = getCurrentPath();
+
+ return childEnv;
+}
+
+export function runSourcedShellScript(input: {
+ scriptRelativePath: string;
+ cwd: string;
+ binDir: string;
+ env: Record;
+}): void {
+ const sourceScriptPath = path.join(process.cwd(), input.scriptRelativePath);
+ const localScriptPath = path.join(input.cwd, "script-under-test.sh");
+
+ const scriptContent = fs
+ .readFileSync(sourceScriptPath, "utf8")
+ .replace(/\r\n/g, "\n");
+
+ fs.writeFileSync(localScriptPath, scriptContent, "utf8");
+ fs.chmodSync(localScriptPath, 0o755);
+
+ const shell = resolvePosixShell();
+ const relativeBinDir = toShellPath(path.relative(input.cwd, input.binDir));
+ const shellCommand = [
+ `PATH=${shellQuote(relativeBinDir)}:$PATH`,
+ "export PATH",
+ ". ./script-under-test.sh",
+ ].join("\n");
+
+ const result = spawnSync(shell, ["-c", shellCommand], {
+ cwd: input.cwd,
+ env: buildChildEnv(input.env),
+ encoding: "utf8",
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ if (result.status !== 0) {
+ throw new Error(
+ [
+ `Script failed: ${input.scriptRelativePath}`,
+ `Exit code: ${String(result.status)}`,
+ "STDOUT:",
+ result.stdout,
+ "STDERR:",
+ result.stderr,
+ ].join("\n"),
+ );
+ }
+}
+
+export type ShellFunctionResult = {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+};
+
+/**
+ * Sources a shell script (with CHANGELOG_UPDATED=false to skip its main body),
+ * then runs optional setup shell code to override functions, then calls the
+ * named function. Returns stdout/stderr/exitCode without throwing so tests can
+ * assert on both success and failure paths.
+ *
+ * setup runs AFTER sourcing so its function definitions override the script's.
+ */
+export function runShellFunction(input: {
+ scriptRelativePath: string;
+ setup: string;
+ functionCall: string;
+ cwd: string;
+ binDir: string;
+ env: Record;
+}): ShellFunctionResult {
+ const sourceScriptPath = path.join(process.cwd(), input.scriptRelativePath);
+ const localScriptPath = path.join(input.cwd, "script-under-test.sh");
+
+ const scriptContent = fs
+ .readFileSync(sourceScriptPath, "utf8")
+ .replace(/\r\n/g, "\n");
+
+ fs.writeFileSync(localScriptPath, scriptContent, "utf8");
+ fs.chmodSync(localScriptPath, 0o755);
+
+ const shell = resolvePosixShell();
+ const relativeBinDir = toShellPath(path.relative(input.cwd, input.binDir));
+ const shellCommand = [
+ `PATH=${shellQuote(relativeBinDir)}:$PATH`,
+ "export PATH",
+ "CHANGELOG_UPDATED=false",
+ "export CHANGELOG_UPDATED",
+ ". ./script-under-test.sh",
+ input.setup,
+ input.functionCall,
+ ].join("\n");
+
+ const result = spawnSync(shell, ["-c", shellCommand], {
+ cwd: input.cwd,
+ env: buildChildEnv(input.env),
+ encoding: "utf8",
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ return {
+ stdout: result.stdout ?? "",
+ stderr: result.stderr ?? "",
+ exitCode: result.status ?? 1,
+ };
+}
+
+/**
+ * Writes a Node.js-backed jq mock to binDir/jq so tests run on systems where
+ * the real jq binary is not installed (e.g. bare Windows). Handles all jq
+ * patterns used by create-pr.sh.
+ */
+export function writeJqMock(binDir: string, cwd: string): void {
+ const implPath = path.join(cwd, "jq-impl.js");
+ const implPathPosix = toShellPath(implPath);
+
+ const implCode = `'use strict';
+const chunks = [];
+process.stdin.on('data', d => chunks.push(d));
+process.stdin.on('end', () => main(chunks.join('')));
+
+function main(raw) {
+ const args = process.argv.slice(2);
+ const vars = {};
+ const positional = [];
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--arg') { vars[args[++i]] = args[++i]; }
+ else { positional.push(args[i]); }
+ }
+ const flags = positional.filter(a => /^-[a-zA-Z]/.test(a)).join('');
+ // Normalize: jq expressions in create-pr.sh are multi-line shell strings.
+ // Collapse all whitespace runs to a single space and trim so regex anchors work.
+ const rawExpr = positional.find(a => !a.startsWith('-')) || '.';
+ const expr = rawExpr.trim().replace(/\\s+/g, ' ');
+ const rawOut = flags.includes('r');
+ const noInput = flags.includes('n');
+ const exitStatus = flags.includes('e');
+ const input = noInput ? null : safeparse(raw);
+
+ if (expr === 'empty') { process.exit(safeparse(raw) !== null ? 0 : 1); }
+
+ if (expr.includes('@uri')) {
+ process.stdout.write(encodeURIComponent(vars['value'] || '') + '\\n');
+ return;
+ }
+
+ const m1 = expr.match(/^\\.([\\w]+) ?\\/\\/ ?(empty|"[^"]*"|null)$/);
+ if (m1) {
+ const v = input?.[m1[1]];
+ if (v == null) { if (m1[2] !== 'empty') write(m1[2].replace(/^"|"$/g, ''), true); }
+ else write(v, rawOut);
+ return;
+ }
+
+ if (expr.includes('| length > 0')) {
+ const f = expr.match(/^\\.([\\w]+)/)?.[1];
+ const arr = f ? input?.[f] : input;
+ const ok = Array.isArray(arr) && arr.length > 0;
+ write(ok, rawOut);
+ process.exit(ok ? 0 : exitStatus ? 1 : 0);
+ return;
+ }
+
+ // .array[]? | select(.field == $var) | .result
+ const m2 = expr.match(/^\\.([\\w]+)\\[\\]\\?? \\| select\\(\\.([\\w]+) == \\$([\\w]+)\\) \\| \\.([\\w]+)/);
+ if (m2) {
+ (input?.[m2[1]] || []).filter(it => String(it[m2[2]]) === String(vars[m2[3]])).forEach(it => write(it[m2[4]], rawOut));
+ return;
+ }
+
+ // .array[]? | select(.f1 == $v1 and .f2 >= $v2) | .result
+ const m3 = expr.match(/^\\.([\\w]+)\\[\\]\\?? \\| select\\(\\.([\\w]+) == \\$([\\w]+) and \\.([\\w]+) >= \\$([\\w]+)\\) \\| \\.([\\w]+)/);
+ if (m3) {
+ (input?.[m3[1]] || []).filter(it =>
+ String(it[m3[2]]) === String(vars[m3[3]]) && String(it[m3[4]]) >= String(vars[m3[5]])
+ ).forEach(it => write(it[m3[6]], rawOut));
+ return;
+ }
+
+ if (expr.includes('@tsv')) {
+ const f = expr.match(/^\\.([\\w]+)\\[\\]/)?.[1];
+ (f ? (input?.[f] || []) : []).forEach(it => {
+ process.stdout.write([it.name || '', it.conclusion || 'failure', it.html_url || ''].join('\\t') + '\\n');
+ });
+ return;
+ }
+
+ if (noInput) {
+ if (expr.includes('head_sha') && expr.includes('conclusion')) {
+ const result = {
+ name: vars.name, head_sha: vars.head_sha,
+ status: 'completed', conclusion: vars.conclusion,
+ output: { title: vars.name, summary: vars.summary }
+ };
+ if (vars.details_url) result.details_url = vars.details_url;
+ write(result, rawOut);
+ } else {
+ const result = {};
+ for (const [k, v] of Object.entries(vars)) result[k] = v;
+ write(result, rawOut);
+ }
+ return;
+ }
+
+ if (expr.includes('"') && expr.includes('\\\\(')) {
+ const f = expr.match(/^\\.([\\w]+)\\[\\]/)?.[1];
+ (f ? (input?.[f] || []) : []).forEach(it => {
+ const s = expr.replace(/^[^"]*"/, '').replace(/"[^"]*$/, '')
+ .replace(/\\\\\\(([^)]+)\\)/g, (_, p) => {
+ const val = p.trim().split('.').reduce((o, k) => k ? o?.[k] : o, it);
+ return val == null ? 'null' : String(val);
+ });
+ write(s, true);
+ });
+ return;
+ }
+
+ if (expr === '.') { write(input, rawOut); }
+}
+
+function safeparse(s) { try { return JSON.parse(s || 'null'); } catch { return null; } }
+function write(v, raw) {
+ if (v === null || v === undefined) return;
+ process.stdout.write((raw ? String(v) : JSON.stringify(v, null, 2)) + '\\n');
+}
+`;
+
+ fs.writeFileSync(implPath, implCode, "utf8");
+
+ writeExecutable(
+ path.join(binDir, "jq"),
+ `#!/bin/sh\nexec node '${implPathPosix}' "$@"\n`,
+ );
+}
diff --git a/src/__tests__/scripts/update-changelog-script.test.ts b/src/__tests__/scripts/update-changelog-script.test.ts
new file mode 100644
index 0000000..52e7c2b
--- /dev/null
+++ b/src/__tests__/scripts/update-changelog-script.test.ts
@@ -0,0 +1,173 @@
+import * as fs from "node:fs";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import {
+ createTempDir,
+ runSourcedShellScript,
+ writeExecutable,
+} from "./test-utils.js";
+
+describe("update-changelog.sh", () => {
+ let tempDir: string;
+ let binDir: string;
+
+ beforeEach(() => {
+ tempDir = createTempDir("github-release-update-changelog-");
+ binDir = path.join(tempDir, "bin");
+ fs.mkdirSync(binDir);
+
+ writeExecutable(
+ path.join(binDir, "git"),
+ `#!/bin/sh
+if [ "$1" = "fetch" ]; then
+ exit 0
+fi
+
+if [ "$1" = "diff" ] && [ "$2" = "--quiet" ]; then
+ exit 0
+fi
+
+printf '%s\\n' "unexpected git call: $*" >&2
+exit 1
+`,
+ );
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test("creates modern release body when version already exists in changelog", () => {
+ fs.writeFileSync(
+ path.join(tempDir, "CHANGELOG.md"),
+ [
+ "# Changelog",
+ "",
+ "## [[1.2.3](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3)] - 2026-01-01",
+ "### Added",
+ "",
+ "- Existing changelog entry",
+ "",
+ "## [unreleased]",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+
+ fs.writeFileSync(
+ path.join(tempDir, "commit_log.txt"),
+ "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)\n",
+ "utf8",
+ );
+
+ fs.writeFileSync(
+ path.join(tempDir, "contributors.txt"),
+ "@alice\n",
+ "utf8",
+ );
+
+ const githubEnv = path.join(tempDir, "github-env.txt");
+
+ runSourcedShellScript({
+ scriptRelativePath: "scripts/update-changelog.sh",
+ cwd: tempDir,
+ binDir,
+ env: {
+ CHANGELOG_FILE_PATH: "CHANGELOG.md",
+ VERSION: "1.2.3",
+ TAG: "v1.2.3",
+ TARGET_BRANCH: "main",
+ DRY_RUN: "false",
+ GITHUB_ENV: githubEnv,
+ GITHUB_SERVER_URL: "https://github.com",
+ GITHUB_REPOSITORY: "open-resource-discovery/github-release",
+ },
+ });
+
+ const releaseBody = fs.readFileSync(
+ path.join(tempDir, "changelog_content.txt"),
+ "utf8",
+ );
+
+ expect(releaseBody).toContain("### Added");
+ expect(releaseBody).toContain("- Existing changelog entry");
+ expect(releaseBody).toContain("------");
+ expect(releaseBody).toContain("## What's Changed");
+ expect(releaseBody).toContain(
+ "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)",
+ );
+ expect(releaseBody).not.toContain("### Contributors");
+ expect(fs.readFileSync(githubEnv, "utf8")).toContain(
+ "CHANGELOG_UPDATED=false",
+ );
+ });
+
+ test("creates modern release body when changelog version is new", () => {
+ fs.writeFileSync(
+ path.join(tempDir, "CHANGELOG.md"),
+ [
+ "# Changelog",
+ "",
+ "## [unreleased]",
+ "",
+ "### Added",
+ "",
+ "- New changelog entry",
+ "",
+ "## [[1.2.2](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.2)] - 2025-12-01",
+ "",
+ "- Old entry",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+
+ fs.writeFileSync(
+ path.join(tempDir, "commit_log.txt"),
+ "* New changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)\n",
+ "utf8",
+ );
+
+ fs.writeFileSync(
+ path.join(tempDir, "contributors.txt"),
+ "@alice\n",
+ "utf8",
+ );
+
+ const githubEnv = path.join(tempDir, "github-env.txt");
+
+ runSourcedShellScript({
+ scriptRelativePath: "scripts/update-changelog.sh",
+ cwd: tempDir,
+ binDir,
+ env: {
+ CHANGELOG_FILE_PATH: "CHANGELOG.md",
+ VERSION: "1.2.3",
+ TAG: "v1.2.3",
+ TARGET_BRANCH: "main",
+ DRY_RUN: "false",
+ GITHUB_ENV: githubEnv,
+ GITHUB_SERVER_URL: "https://github.com",
+ GITHUB_REPOSITORY: "open-resource-discovery/github-release",
+ },
+ });
+
+ const releaseBody = fs.readFileSync(
+ path.join(tempDir, "changelog_content.txt"),
+ "utf8",
+ );
+
+ expect(releaseBody).toContain("### Added");
+ expect(releaseBody).toContain("- New changelog entry");
+ expect(releaseBody).toContain("------");
+ expect(releaseBody).toMatch(/^## What's Changed$/m);
+ expect(releaseBody).not.toContain("## What's Changed (commits)");
+ expect(releaseBody).toContain(
+ "* New changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)",
+ );
+ expect(releaseBody).not.toContain("### Contributors");
+ expect(fs.readFileSync(githubEnv, "utf8")).toContain(
+ "CHANGELOG_UPDATED=true",
+ );
+ });
+});