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 "
No contributors found
" > 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" + 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
- - $full_name
- $full_name -
-
" -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", + ); + }); +});