diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..316558d
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,46 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(pwd)",
+ "Bash(ls*)",
+ "Bash(dir*)",
+ "Bash(Get-ChildItem*)",
+ "Bash(Get-Content*)",
+ "Bash(Select-String*)",
+ "Bash(Test-Path*)",
+ "Bash(npm install*)",
+ "Bash(npm ci*)",
+ "Bash(npm run *)",
+ "Bash(npm test*)",
+ "Bash(npx *)",
+ "Bash(node *)",
+ "Bash(git status*)",
+ "Bash(git diff*)",
+ "Bash(git log*)",
+ "Bash(git branch*)",
+ "Bash(Start-Sleep -Seconds 5)",
+ "Bash(powershell -Command \"docker ps\")"
+ ],
+ "deny": [
+ "Bash(git commit*)",
+ "Bash(git push*)",
+ "Bash(git tag*)",
+ "Bash(git reset --hard*)",
+ "Bash(rm -rf*)",
+ "Bash(Remove-Item * -Recurse*)",
+ "Bash(del *)",
+ "Bash(rmdir *)"
+ ],
+ "ask": [
+ "Bash(git add*)",
+ "Bash(git restore*)",
+ "Bash(git checkout*)",
+ "Bash(git stash*)",
+ "Bash(mkdir*)",
+ "Bash(New-Item*)",
+ "Bash(Copy-Item*)",
+ "Bash(Move-Item*)"
+ ],
+ "defaultMode": "default"
+ }
+}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e47c6bb..af7c112 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -67,3 +67,19 @@ jobs:
uses: ./
with:
dry-run: true
+
+ docker-smoke:
+ name: Docker Smoke Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build Docker image
+ run: docker build --no-cache -t github-release-action:test .
+
+ - name: Run smoke test
+ run: |
+ output="$(docker run --rm github-release-action:test --smoke-test)"
+ echo "$output"
+ echo "$output" | grep -qF "GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1"
+ echo "$output" | grep -qF "TypeScript Docker runtime smoke test passed."
diff --git a/.gitignore b/.gitignore
index d07b5c1..13de11b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,13 @@ yarn-error.log
# GitHub Actions cache
.github/workflows/node_modules/
+
+# Claude Code local state (worktrees, shell snapshots, plans, session data)
+.claude/worktrees/
+.claude/shell-snapshots/
+.claude/plans/
+.claude/projects/
+.claude/todos/
+.claude/history/
+.claude/*.local.json
+.claude/settings.local.json
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 c52ec77..b4e320a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,6 +14,7 @@ COPY . .
# Compile TypeScript files
RUN npm run build
+RUN test -f /app/dist/src/main.js
# Remove unnecessary files after build
RUN npm prune --production
@@ -38,5 +39,5 @@ RUN apk add --no-cache git jq curl
RUN chmod +x /app/scripts/*.sh
RUN chmod +x /app/dist/src/release.js
-# Set the entrypoint script
-ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]
+# Set the entrypoint to the compiled TypeScript runtime
+ENTRYPOINT ["node", "/app/dist/src/main.js"]
diff --git a/action.yml b/action.yml
index 91239c5..f4cb37f 100644
--- a/action.yml
+++ b/action.yml
@@ -38,6 +38,20 @@ 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"
+
+outputs:
+ release-url:
+ description: "URL of the created GitHub release."
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__/config.test.ts b/src/__tests__/config.test.ts
new file mode 100644
index 0000000..22107cc
--- /dev/null
+++ b/src/__tests__/config.test.ts
@@ -0,0 +1,224 @@
+import { describe, expect, test } from "@jest/globals";
+import { readActionConfig } from "../config.js";
+
+const BASE_ENV = {
+ "INPUT_GITHUB-TOKEN": "ghp_test_token",
+ GITHUB_SERVER_URL: "https://github.com",
+ GITHUB_API_URL: "https://api.github.com",
+ GITHUB_REPOSITORY: "owner/repo",
+ GITHUB_ACTOR: "octocat",
+ GITHUB_WORKSPACE: "/github/workspace",
+};
+
+describe("readActionConfig", () => {
+ describe("defaults", () => {
+ test("applies all default values when no optional inputs are set", () => {
+ const config = readActionConfig(BASE_ENV);
+ expect(config.dryRun).toBe(false);
+ expect(config.releaseDraft).toBe(false);
+ expect(config.releasePrerelease).toBe(false);
+ expect(config.releaseTitlePrefix).toBe("");
+ expect(config.tagTemplate).toBe("v");
+ expect(config.changelogFilePath).toBe("CHANGELOG.md");
+ expect(config.versionOverride).toBeUndefined();
+ });
+
+ test("reads GitHub token from INPUT_GITHUB-TOKEN", () => {
+ const config = readActionConfig(BASE_ENV);
+ expect(config.githubToken).toBe("ghp_test_token");
+ });
+
+ test("falls back to GITHUB_TOKEN when INPUT_GITHUB-TOKEN is not set", () => {
+ const env = { ...BASE_ENV, GITHUB_TOKEN: "fallback_token" };
+ delete (env as Record)["INPUT_GITHUB-TOKEN"];
+ const config = readActionConfig(env);
+ expect(config.githubToken).toBe("fallback_token");
+ });
+
+ test("throws when neither token env var is set", () => {
+ const env: Record = { ...BASE_ENV };
+ delete (env as Record)["INPUT_GITHUB-TOKEN"];
+ expect(() => readActionConfig(env)).toThrow(
+ "GITHUB_TOKEN is required but not set.",
+ );
+ });
+
+ test("throws when INPUT_GITHUB-TOKEN is empty and GITHUB_TOKEN is not set", () => {
+ const env = { ...BASE_ENV, "INPUT_GITHUB-TOKEN": "" };
+ expect(() => readActionConfig(env)).toThrow(
+ "GITHUB_TOKEN is required but not set.",
+ );
+ });
+ });
+
+ describe("ciWorkflows parsing", () => {
+ test("defaults to auto when INPUT_CI-WORKFLOWS is not set", () => {
+ const config = readActionConfig(BASE_ENV);
+ expect(config.ciWorkflows).toEqual({ mode: "auto" });
+ });
+
+ test("returns auto for empty string", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": "",
+ });
+ expect(config.ciWorkflows).toEqual({ mode: "auto" });
+ });
+
+ test("returns auto for explicit 'auto' value", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": "auto",
+ });
+ expect(config.ciWorkflows).toEqual({ mode: "auto" });
+ });
+
+ test("returns disabled for 'none'", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": "none",
+ });
+ expect(config.ciWorkflows).toEqual({ mode: "disabled" });
+ });
+
+ test("returns disabled for 'false'", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": "false",
+ });
+ expect(config.ciWorkflows).toEqual({ mode: "disabled" });
+ });
+
+ test("returns explicit list for comma-separated values", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": "ci.yml,test.yml",
+ });
+ expect(config.ciWorkflows).toEqual({
+ mode: "explicit",
+ workflows: ["ci.yml", "test.yml"],
+ });
+ });
+
+ test("trims whitespace around workflow names", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": " ci.yml , test.yml ",
+ });
+ expect(config.ciWorkflows).toEqual({
+ mode: "explicit",
+ workflows: ["ci.yml", "test.yml"],
+ });
+ });
+
+ test("filters empty entries from comma-separated list", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ "INPUT_CI-WORKFLOWS": "ci.yml,,test.yml",
+ });
+ expect(config.ciWorkflows).toEqual({
+ mode: "explicit",
+ workflows: ["ci.yml", "test.yml"],
+ });
+ });
+ });
+
+ describe("boolean parsing", () => {
+ test("only exact 'true' enables dryRun", () => {
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_DRY-RUN": "true" }).dryRun,
+ ).toBe(true);
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_DRY-RUN": "TRUE" }).dryRun,
+ ).toBe(false);
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_DRY-RUN": "1" }).dryRun,
+ ).toBe(false);
+ });
+
+ test("only exact 'true' enables releaseDraft", () => {
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-DRAFT": "true" })
+ .releaseDraft,
+ ).toBe(true);
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-DRAFT": "TRUE" })
+ .releaseDraft,
+ ).toBe(false);
+ });
+
+ test("only exact 'true' enables releasePrerelease", () => {
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-PRERELEASE": "true" })
+ .releasePrerelease,
+ ).toBe(true);
+ expect(
+ readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-PRERELEASE": "1" })
+ .releasePrerelease,
+ ).toBe(false);
+ });
+ });
+
+ describe("GitHub Enterprise support", () => {
+ test("preserves custom server and API URLs", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ GITHUB_SERVER_URL: "https://ghe.example.com",
+ GITHUB_API_URL: "https://ghe.example.com/api/v3",
+ });
+ expect(config.githubServerUrl).toBe("https://ghe.example.com");
+ expect(config.githubApiUrl).toBe("https://ghe.example.com/api/v3");
+ });
+
+ test("throws when GITHUB_SERVER_URL is missing", () => {
+ const env: Record = { ...BASE_ENV };
+ delete (env as Record).GITHUB_SERVER_URL;
+ expect(() => readActionConfig(env)).toThrow(
+ "GITHUB_SERVER_URL is required but not set.",
+ );
+ });
+
+ test("throws when GITHUB_API_URL is missing", () => {
+ const env: Record = { ...BASE_ENV };
+ delete (env as Record).GITHUB_API_URL;
+ expect(() => readActionConfig(env)).toThrow(
+ "GITHUB_API_URL is required but not set.",
+ );
+ });
+
+ test("preserves GITHUB_REPOSITORY", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ GITHUB_REPOSITORY: "myorg/myrepo",
+ });
+ expect(config.githubRepository).toBe("myorg/myrepo");
+ });
+ });
+
+ describe("optional context fields", () => {
+ test("versionOverride is set when INPUT_VERSION is provided", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ INPUT_VERSION: "2.3.4",
+ });
+ expect(config.versionOverride).toBe("2.3.4");
+ });
+
+ test("githubRefName and githubBaseRef are undefined when not set", () => {
+ const config = readActionConfig(BASE_ENV);
+ expect(config.githubRefName).toBeUndefined();
+ expect(config.githubBaseRef).toBeUndefined();
+ });
+
+ test("githubWorkflowRef is set when present", () => {
+ const config = readActionConfig({
+ ...BASE_ENV,
+ GITHUB_WORKFLOW_REF:
+ "owner/repo/.github/workflows/release.yml@refs/heads/main",
+ });
+ expect(config.githubWorkflowRef).toBe(
+ "owner/repo/.github/workflows/release.yml@refs/heads/main",
+ );
+ });
+ });
+});
diff --git a/src/__tests__/git/git.test.ts b/src/__tests__/git/git.test.ts
new file mode 100644
index 0000000..526287f
--- /dev/null
+++ b/src/__tests__/git/git.test.ts
@@ -0,0 +1,199 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import {
+ add,
+ branchExistsRemote,
+ checkoutExistingBranch,
+ cloneWorkspace,
+ commit,
+ execGit,
+ hasUnstagedChanges,
+ requireGit,
+ revParse,
+ tagExists,
+ tagList,
+} from "../../git/git.js";
+
+describe("git", () => {
+ let repoDir: string;
+
+ beforeEach(() => {
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-test-"));
+ requireGit(["init", "-b", "main"], { cwd: repoDir });
+ requireGit(["config", "user.name", "test-actor"], { cwd: repoDir });
+ requireGit(
+ ["config", "user.email", "test-actor@users.noreply.github.com"],
+ { cwd: repoDir },
+ );
+ fs.writeFileSync(path.join(repoDir, "file.txt"), "hello\n", "utf8");
+ add("file.txt", { cwd: repoDir });
+ commit("initial commit", { cwd: repoDir });
+ });
+
+ afterEach(() => {
+ fs.rmSync(repoDir, { recursive: true, force: true });
+ });
+
+ describe("requireGit", () => {
+ test("throws an error including command, exit code, and stderr on failure", () => {
+ expect(() =>
+ requireGit(["rev-parse", "--verify", "refs/heads/does-not-exist"], {
+ cwd: repoDir,
+ }),
+ ).toThrow(/exit code/);
+ });
+
+ test("returns trimmed stdout on success", () => {
+ const output = requireGit(["rev-parse", "--abbrev-ref", "HEAD"], {
+ cwd: repoDir,
+ });
+ expect(output).toBe("main");
+ });
+ });
+
+ describe("execGit", () => {
+ test("never throws, returns exitCode for failures", () => {
+ const result = execGit(["rev-parse", "--verify", "refs/heads/missing"], {
+ cwd: repoDir,
+ });
+ expect(result.exitCode).not.toBe(0);
+ });
+ });
+
+ describe("tagExists", () => {
+ test("returns false for a tag that does not exist", () => {
+ expect(tagExists("v9.9.9", { cwd: repoDir })).toBe(false);
+ });
+
+ test("returns true for a tag that exists", () => {
+ requireGit(["tag", "v1.0.0"], { cwd: repoDir });
+ expect(tagExists("v1.0.0", { cwd: repoDir })).toBe(true);
+ });
+
+ test("supports tag names containing slashes", () => {
+ requireGit(["tag", "v/1.2.3"], { cwd: repoDir });
+ expect(tagExists("v/1.2.3", { cwd: repoDir })).toBe(true);
+ expect(tagList({ cwd: repoDir })).toContain("v/1.2.3");
+ });
+ });
+
+ describe("revParse", () => {
+ test("returns undefined for a missing ref instead of throwing", () => {
+ expect(revParse("refs/heads/missing", { cwd: repoDir })).toBeUndefined();
+ });
+
+ test("returns the resolved sha for an existing ref", () => {
+ const sha = revParse("HEAD", { cwd: repoDir });
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
+ });
+ });
+
+ describe("commit", () => {
+ test("does not throw when there is nothing to commit", () => {
+ expect(() =>
+ commit("empty commit attempt", { cwd: repoDir }),
+ ).not.toThrow();
+ });
+ });
+
+ describe("hasUnstagedChanges", () => {
+ test("returns false when file is unchanged", () => {
+ expect(hasUnstagedChanges("file.txt", { cwd: repoDir })).toBe(false);
+ });
+
+ test("returns true when file has unstaged modifications", () => {
+ fs.writeFileSync(path.join(repoDir, "file.txt"), "changed\n", "utf8");
+ expect(hasUnstagedChanges("file.txt", { cwd: repoDir })).toBe(true);
+ });
+ });
+
+ describe("branchExistsRemote", () => {
+ test("returns false when there is no remote configured", () => {
+ expect(branchExistsRemote("some-branch", { cwd: repoDir })).toBe(false);
+ });
+ });
+
+ describe("checkoutExistingBranch", () => {
+ test("checks out a branch that exists on the remote but has no local ref", () => {
+ requireGit(["checkout", "-b", "feature-branch"], { cwd: repoDir });
+ fs.writeFileSync(
+ path.join(repoDir, "file.txt"),
+ "from feature\n",
+ "utf8",
+ );
+ add("file.txt", { cwd: repoDir });
+ commit("feature commit", { cwd: repoDir });
+ requireGit(["checkout", "main"], { cwd: repoDir });
+
+ const localDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-local-"));
+ fs.rmSync(localDir, { recursive: true, force: true });
+ requireGit(["clone", repoDir, localDir]);
+
+ expect(
+ requireGit(["branch", "--list", "feature-branch"], { cwd: localDir }),
+ ).toBe("");
+
+ checkoutExistingBranch("feature-branch", { cwd: localDir });
+
+ expect(
+ requireGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: localDir }),
+ ).toBe("feature-branch");
+ expect(
+ fs.readFileSync(path.join(localDir, "file.txt"), "utf8").trim(),
+ ).toBe("from feature");
+
+ fs.rmSync(localDir, { recursive: true, force: true });
+ });
+
+ test("resets an existing local branch to match the remote", () => {
+ requireGit(["checkout", "-b", "feature-branch"], { cwd: repoDir });
+ requireGit(["checkout", "main"], { cwd: repoDir });
+
+ const localDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-local-"));
+ fs.rmSync(localDir, { recursive: true, force: true });
+ requireGit(["clone", repoDir, localDir]);
+ requireGit(
+ ["checkout", "-b", "feature-branch", "origin/feature-branch"],
+ {
+ cwd: localDir,
+ },
+ );
+ fs.writeFileSync(
+ path.join(localDir, "file.txt"),
+ "local divergent change\n",
+ "utf8",
+ );
+ add("file.txt", { cwd: localDir });
+ commit("local-only commit", { cwd: localDir });
+ requireGit(["checkout", "main"], { cwd: localDir });
+
+ checkoutExistingBranch("feature-branch", { cwd: localDir });
+
+ const localSha = requireGit(["rev-parse", "feature-branch"], {
+ cwd: localDir,
+ });
+ const remoteSha = requireGit(["rev-parse", "main"], { cwd: repoDir });
+ expect(localSha).toBe(remoteSha);
+
+ fs.rmSync(localDir, { recursive: true, force: true });
+ });
+ });
+
+ describe("cloneWorkspace", () => {
+ test("copies workspace contents to a new directory", () => {
+ const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-clone-"));
+ fs.rmSync(targetDir, { recursive: true, force: true });
+
+ cloneWorkspace(repoDir, targetDir);
+
+ expect(fs.existsSync(path.join(targetDir, "file.txt"))).toBe(true);
+ expect(fs.readFileSync(path.join(targetDir, "file.txt"), "utf8")).toBe(
+ "hello\n",
+ );
+
+ fs.rmSync(targetDir, { recursive: true, force: true });
+ });
+ });
+});
diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts
new file mode 100644
index 0000000..529bd4f
--- /dev/null
+++ b/src/__tests__/main.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, jest, test } from "@jest/globals";
+
+describe("main", () => {
+ test("--smoke-test prints the runtime marker and success message, then returns without reading GitHub env vars", async () => {
+ const originalArgv = process.argv;
+ process.argv = ["node", "main.js", "--smoke-test"];
+
+ const writeSpy = jest
+ .spyOn(process.stdout, "write")
+ .mockImplementation(() => true);
+
+ try {
+ const { main } = await import("../main.js");
+ await expect(main()).resolves.toBeUndefined();
+
+ const loggedLines = writeSpy.mock.calls.map((call) => String(call[0]));
+ expect(
+ loggedLines.some((line) =>
+ line.includes("GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1"),
+ ),
+ ).toBe(true);
+ expect(
+ loggedLines.some((line) =>
+ line.includes("TypeScript Docker runtime smoke test passed."),
+ ),
+ ).toBe(true);
+ } finally {
+ writeSpy.mockRestore();
+ process.argv = originalArgv;
+ }
+ });
+});
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__/mocks/git-port.ts b/src/__tests__/mocks/git-port.ts
new file mode 100644
index 0000000..3e6d915
--- /dev/null
+++ b/src/__tests__/mocks/git-port.ts
@@ -0,0 +1,38 @@
+import type { GitPort } from "../../git/git.js";
+
+function notConfigured(methodName: string): never {
+ throw new Error(
+ `FakeGitPort.${methodName} was called without a configured implementation.`,
+ );
+}
+
+export function createFakeGitPort(overrides: Partial = {}): GitPort {
+ const base: GitPort = {
+ configSafeDirectory: () => notConfigured("configSafeDirectory"),
+ configUser: () => notConfigured("configUser"),
+ configGitHttpAuth: () => notConfigured("configGitHttpAuth"),
+ fetchBranches: () => notConfigured("fetchBranches"),
+ fetchTags: () => notConfigured("fetchTags"),
+ fetchBranchesAndTags: () => notConfigured("fetchBranchesAndTags"),
+ fetchTargetBranch: () => notConfigured("fetchTargetBranch"),
+ tagList: () => notConfigured("tagList"),
+ listTagsSortedByVersionDescending: () =>
+ notConfigured("listTagsSortedByVersionDescending"),
+ revParse: () => notConfigured("revParse"),
+ tagExists: () => notConfigured("tagExists"),
+ getHeadSha: () => notConfigured("getHeadSha"),
+ gitLog: () => notConfigured("gitLog"),
+ hasDiffAgainstRef: () => notConfigured("hasDiffAgainstRef"),
+ hasUnstagedChanges: () => notConfigured("hasUnstagedChanges"),
+ pullTargetBranch: () => notConfigured("pullTargetBranch"),
+ checkoutBranchFromTarget: () => notConfigured("checkoutBranchFromTarget"),
+ checkoutExistingBranch: () => notConfigured("checkoutExistingBranch"),
+ branchExistsRemote: () => notConfigured("branchExistsRemote"),
+ add: () => notConfigured("add"),
+ commit: () => notConfigured("commit"),
+ pushBranch: () => notConfigured("pushBranch"),
+ cloneWorkspace: () => notConfigured("cloneWorkspace"),
+ };
+
+ return { ...base, ...overrides };
+}
diff --git a/src/__tests__/mocks/github-client.ts b/src/__tests__/mocks/github-client.ts
new file mode 100644
index 0000000..92c985e
--- /dev/null
+++ b/src/__tests__/mocks/github-client.ts
@@ -0,0 +1,28 @@
+import type { GitHubClient } from "../../github/client.js";
+
+function notConfigured(methodName: string): never {
+ throw new Error(
+ `FakeGitHubClient.${methodName} was called without a configured implementation.`,
+ );
+}
+
+export function createFakeGitHubClient(
+ overrides: Partial = {},
+): GitHubClient {
+ const base: GitHubClient = {
+ getReleaseByTag: () => notConfigured("getReleaseByTag"),
+ createRelease: () => notConfigured("createRelease"),
+ getCommit: () => notConfigured("getCommit"),
+ listPullRequestsAssociatedWithCommit: () =>
+ notConfigured("listPullRequestsAssociatedWithCommit"),
+ createPullRequest: () => notConfigured("createPullRequest"),
+ findOpenPullRequestByHead: () => notConfigured("findOpenPullRequestByHead"),
+ createWorkflowDispatch: () => notConfigured("createWorkflowDispatch"),
+ listWorkflowRuns: () => notConfigured("listWorkflowRuns"),
+ getWorkflowRun: () => notConfigured("getWorkflowRun"),
+ listJobsForWorkflowRun: () => notConfigured("listJobsForWorkflowRun"),
+ createCheckRun: () => notConfigured("createCheckRun"),
+ };
+
+ return { ...base, ...overrides };
+}
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__/release/actionState.test.ts b/src/__tests__/release/actionState.test.ts
new file mode 100644
index 0000000..a683332
--- /dev/null
+++ b/src/__tests__/release/actionState.test.ts
@@ -0,0 +1,140 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import type { ActionConfig } from "../../config.js";
+import {
+ exportChangelogState,
+ exportInputState,
+ exportPrState,
+ exportSetupState,
+} from "../../release/actionState.js";
+import type { ChangelogPrResult } from "../../release/createChangelogPr.js";
+import type { ReleaseSetup } from "../../release/setupRelease.js";
+
+function buildConfig(overrides: Partial = {}): ActionConfig {
+ return {
+ githubToken: "super-secret-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "auto" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "owner/repo",
+ githubActor: "octocat",
+ githubWorkspace: "/workspace",
+ ...overrides,
+ };
+}
+
+function buildSetup(overrides: Partial = {}): ReleaseSetup {
+ return {
+ version: "1.2.3",
+ tag: "v1.2.3",
+ releaseTitle: "v1.2.3",
+ tagExists: false,
+ releaseExists: false,
+ targetBranch: "main",
+ ...overrides,
+ };
+}
+
+describe("actionState", () => {
+ let githubEnvPath: string;
+ let originalGithubEnv: string | undefined;
+
+ beforeEach(() => {
+ originalGithubEnv = process.env.GITHUB_ENV;
+ githubEnvPath = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), "github-env-")),
+ "env.txt",
+ );
+ fs.writeFileSync(githubEnvPath, "", "utf8");
+ process.env.GITHUB_ENV = githubEnvPath;
+ });
+
+ afterEach(() => {
+ fs.rmSync(path.dirname(githubEnvPath), { recursive: true, force: true });
+
+ if (originalGithubEnv === undefined) {
+ delete process.env.GITHUB_ENV;
+ } else {
+ process.env.GITHUB_ENV = originalGithubEnv;
+ }
+ });
+
+ test("exportInputState writes all expected input variables and never GITHUB_TOKEN", () => {
+ exportInputState(
+ buildConfig({
+ dryRun: true,
+ ciWorkflows: { mode: "explicit", workflows: ["b.yml", "a.yml"] },
+ versionOverride: "9.9.9",
+ }),
+ );
+
+ const content = fs.readFileSync(githubEnvPath, "utf8");
+
+ expect(content).toContain("DRY_RUN=true\n");
+ expect(content).toContain("CHANGELOG_FILE_PATH=CHANGELOG.md\n");
+ expect(content).toContain("TAG_TEMPLATE=v\n");
+ expect(content).toContain("RELEASE_DRAFT=false\n");
+ expect(content).toContain("RELEASE_PRERELEASE=false\n");
+ expect(content).toContain("RELEASE_TITLE_PREFIX=\n");
+ expect(content).toContain("VERSION_OVERRIDE=9.9.9\n");
+ expect(content).toContain("CI_WORKFLOWS=b.yml,a.yml\n");
+ expect(content).not.toContain("super-secret-token");
+ expect(content).not.toContain("GITHUB_TOKEN");
+ });
+
+ test("exportInputState serializes auto and disabled CI workflow modes", () => {
+ exportInputState(buildConfig({ ciWorkflows: { mode: "auto" } }));
+ exportInputState(buildConfig({ ciWorkflows: { mode: "disabled" } }));
+
+ const content = fs.readFileSync(githubEnvPath, "utf8");
+ expect(content).toContain("CI_WORKFLOWS=auto\n");
+ expect(content).toContain("CI_WORKFLOWS=none\n");
+ });
+
+ test("exportSetupState writes setup variables, including LATEST_TAG only when present", () => {
+ exportSetupState(buildSetup());
+
+ let content = fs.readFileSync(githubEnvPath, "utf8");
+ expect(content).toContain("VERSION=1.2.3\n");
+ expect(content).toContain("TAG=v1.2.3\n");
+ expect(content).toContain("RELEASE_TITLE=v1.2.3\n");
+ expect(content).toContain("TAG_EXISTS=false\n");
+ expect(content).toContain("RELEASE_EXISTS=false\n");
+ expect(content).toContain("TARGET_BRANCH=main\n");
+ expect(content).not.toContain("LATEST_TAG=");
+
+ fs.writeFileSync(githubEnvPath, "", "utf8");
+ exportSetupState(buildSetup({ latestTag: "v1.0.0" }));
+ content = fs.readFileSync(githubEnvPath, "utf8");
+ expect(content).toContain("LATEST_TAG=v1.0.0\n");
+ });
+
+ test("exportChangelogState writes CHANGELOG_UPDATED", () => {
+ exportChangelogState(true);
+ expect(fs.readFileSync(githubEnvPath, "utf8")).toContain(
+ "CHANGELOG_UPDATED=true\n",
+ );
+ });
+
+ test("exportPrState writes PR_URL and CHANGELOG_PR_HEAD_SHA", () => {
+ const result: ChangelogPrResult = {
+ prUrl: "https://github.com/owner/repo/pull/5",
+ headSha: "abc123",
+ };
+
+ exportPrState(result);
+
+ const content = fs.readFileSync(githubEnvPath, "utf8");
+ expect(content).toContain("PR_URL=https://github.com/owner/repo/pull/5\n");
+ expect(content).toContain("CHANGELOG_PR_HEAD_SHA=abc123\n");
+ });
+});
diff --git a/src/__tests__/release/collectCommits.test.ts b/src/__tests__/release/collectCommits.test.ts
new file mode 100644
index 0000000..8e1a9f2
--- /dev/null
+++ b/src/__tests__/release/collectCommits.test.ts
@@ -0,0 +1,373 @@
+import { describe, expect, test } from "@jest/globals";
+import type { ActionConfig } from "../../config.js";
+import { collectCommits } from "../../release/collectCommits.js";
+import type { ReleaseSetup } from "../../release/setupRelease.js";
+import { createFakeGitPort } from "../mocks/git-port.js";
+import { createFakeGitHubClient } from "../mocks/github-client.js";
+
+const FIELD_SEPARATOR = "\x1f";
+
+function buildConfig(overrides: Partial = {}): ActionConfig {
+ return {
+ githubToken: "test-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "auto" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "open-resource-discovery/github-release",
+ githubActor: "octocat",
+ githubWorkspace: "/workspace",
+ ...overrides,
+ };
+}
+
+function buildSetup(overrides: Partial = {}): ReleaseSetup {
+ return {
+ version: "1.1.0",
+ tag: "v1.1.0",
+ releaseTitle: "v1.1.0",
+ tagExists: false,
+ releaseExists: false,
+ targetBranch: "main",
+ ...overrides,
+ };
+}
+
+function commitLine(
+ sha: string,
+ shortSha: string,
+ authorName: string,
+ authorEmail: string,
+ subject: string,
+): string {
+ return [sha, shortSha, authorName, authorEmail, subject].join(
+ FIELD_SEPARATOR,
+ );
+}
+
+describe("collectCommits", () => {
+ test("falls back to 'No changes since last release' when there are no commits", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => ["v1.0.0"],
+ gitLog: () => "",
+ });
+ const client = createFakeGitHubClient();
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual(["* No changes since last release."]);
+ });
+
+ test("computes range against the previous tag and includes a Full Changelog link", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true, tag: "v1.1.0" });
+ let receivedRange = "";
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => ["v1.0.0"],
+ gitLog: (range) => {
+ receivedRange = range;
+ return "";
+ },
+ });
+ const client = createFakeGitHubClient();
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(receivedRange).toBe("v1.0.0..v1.1.0");
+ expect(result.fullChangelogLine).toBe(
+ "**Full Changelog**: [v1.0.0...v1.1.0](https://github.com/open-resource-discovery/github-release/compare/v1.0.0...v1.1.0)",
+ );
+ });
+
+ test("supports tags containing a slash in range computation and compare URL", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true, tag: "v/1.1.0" });
+ let receivedRange = "";
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => ["v/1.0.0"],
+ gitLog: (range) => {
+ receivedRange = range;
+ return "";
+ },
+ });
+ const client = createFakeGitHubClient();
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(receivedRange).toBe("v/1.0.0..v/1.1.0");
+ expect(result.fullChangelogLine).toContain("compare/v/1.0.0...v/1.1.0");
+ });
+
+ test("prefers the PR link over the raw commit link", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ commitLine("sha1", "abc1234", "Alice", "alice@example.com", "Fix bug"),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.resolve({ login: "alice" }),
+ listPullRequestsAssociatedWithCommit: () =>
+ Promise.resolve([
+ {
+ number: 42,
+ title: "Fix the bug",
+ html_url: "https://github.com/owner/repo/pull/42",
+ user: { login: "alice" },
+ },
+ ]),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Fix the bug by @alice in [#42](https://github.com/owner/repo/pull/42)",
+ ]);
+ });
+
+ test("falls back to the commit link when no PR is associated and none is parseable", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ commitLine("sha1", "abc1234", "Bob", "bob@example.com", "Quick fix"),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.resolve({}),
+ listPullRequestsAssociatedWithCommit: () => Promise.resolve([]),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Quick fix by Bob in [abc1234](https://github.com/open-resource-discovery/github-release/commit/sha1)",
+ ]);
+ });
+
+ test("the same GitHub login can appear on multiple separate lines (no cross-line dedup)", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ [
+ commitLine(
+ "sha-one",
+ "1111111",
+ "Alice",
+ "alice-work@example.com",
+ "First change",
+ ),
+ commitLine(
+ "sha-two",
+ "2222222",
+ "Alice",
+ "alice-private@example.com",
+ "Second change",
+ ),
+ ].join("\n"),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.resolve({ login: "alice" }),
+ listPullRequestsAssociatedWithCommit: () => Promise.resolve([]),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* 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)",
+ ]);
+ });
+
+ test("deduplicates PRs by PR number", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ [
+ commitLine(
+ "sha1",
+ "abc1111",
+ "Alice",
+ "alice@example.com",
+ "Commit A",
+ ),
+ commitLine(
+ "sha2",
+ "abc2222",
+ "Alice",
+ "alice@example.com",
+ "Commit B",
+ ),
+ ].join("\n"),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.resolve({ login: "alice" }),
+ listPullRequestsAssociatedWithCommit: () =>
+ Promise.resolve([
+ {
+ number: 7,
+ title: "Shared PR",
+ html_url: "https://github.com/owner/repo/pull/7",
+ user: { login: "alice" },
+ },
+ ]),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Shared PR by @alice in [#7](https://github.com/owner/repo/pull/7)",
+ ]);
+ });
+
+ test("does not mention bot contributors but still lists their commits by author name", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ commitLine(
+ "sha-bot",
+ "3333333",
+ "dependabot[bot]",
+ "dependabot[bot]@users.noreply.github.com",
+ "Dependency update",
+ ),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.resolve({ login: "dependabot" }),
+ listPullRequestsAssociatedWithCommit: () => Promise.resolve([]),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Dependency update by dependabot[bot] in [3333333](https://github.com/open-resource-discovery/github-release/commit/sha-bot)",
+ ]);
+ });
+
+ test("full changelog link is omitted when there is no previous tag", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () => "",
+ });
+ const client = createFakeGitHubClient();
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.fullChangelogLine).toBeUndefined();
+ });
+
+ test("getCommit rejection still produces a commit line, falling back without a login", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ commitLine("sha1", "abc1234", "Carol", "carol@example.com", "Fix it"),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.reject(new Error("API unavailable")),
+ listPullRequestsAssociatedWithCommit: () => Promise.resolve([]),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Fix it by Carol in [abc1234](https://github.com/open-resource-discovery/github-release/commit/sha1)",
+ ]);
+ });
+
+ test("PR lookup rejection falls back to the PR number parsed from the commit subject", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ commitLine(
+ "sha1",
+ "abc1234",
+ "Dana",
+ "dana@example.com",
+ "Add feature (#99)",
+ ),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.resolve({ login: "dana" }),
+ listPullRequestsAssociatedWithCommit: () =>
+ Promise.reject(new Error("API unavailable")),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Add feature (#99) by @dana in [#99](https://github.com/open-resource-discovery/github-release/pull/99)",
+ ]);
+ });
+
+ test("both getCommit and PR lookup rejecting still produces a commit-link fallback", async () => {
+ const config = buildConfig();
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => undefined,
+ tagList: () => [],
+ gitLog: () =>
+ commitLine("sha1", "abc1234", "Eve", "eve@example.com", "Plain fix"),
+ });
+ const client = createFakeGitHubClient({
+ getCommit: () => Promise.reject(new Error("down")),
+ listPullRequestsAssociatedWithCommit: () =>
+ Promise.reject(new Error("down")),
+ });
+
+ const result = await collectCommits(config, setup, git, client);
+
+ expect(result.commitLogLines).toEqual([
+ "* Plain fix by Eve in [abc1234](https://github.com/open-resource-discovery/github-release/commit/sha1)",
+ ]);
+ });
+
+ test("dry-run skips fetchBranchesAndTags", async () => {
+ let fetchCalled = false;
+ const config = buildConfig({ dryRun: true });
+ const setup = buildSetup({ tagExists: true });
+ const git = createFakeGitPort({
+ fetchBranchesAndTags: () => {
+ fetchCalled = true;
+ },
+ tagList: () => [],
+ gitLog: () => "",
+ });
+ const client = createFakeGitHubClient();
+
+ await collectCommits(config, setup, git, client);
+
+ expect(fetchCalled).toBe(false);
+ });
+});
diff --git a/src/__tests__/release/createChangelogPr.test.ts b/src/__tests__/release/createChangelogPr.test.ts
new file mode 100644
index 0000000..68e0a91
--- /dev/null
+++ b/src/__tests__/release/createChangelogPr.test.ts
@@ -0,0 +1,551 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { afterEach, describe, expect, jest, test } from "@jest/globals";
+import type { ActionConfig } from "../../config.js";
+import { GitHubApiError } from "../../github/client.js";
+import { createChangelogPr } from "../../release/createChangelogPr.js";
+import type { ReleaseSetup } from "../../release/setupRelease.js";
+import type { ChangelogResult } from "../../release/updateChangelog.js";
+import type { GitPort } from "../../git/git.js";
+import { createFakeGitPort } from "../mocks/git-port.js";
+import { createFakeGitHubClient } from "../mocks/github-client.js";
+
+function buildConfig(overrides: Partial = {}): ActionConfig {
+ return {
+ githubToken: "test-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "disabled" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "owner/repo",
+ githubActor: "octocat",
+ githubWorkspace: "/workspace",
+ ...overrides,
+ };
+}
+
+function buildSetup(overrides: Partial = {}): ReleaseSetup {
+ return {
+ version: "1.2.3",
+ tag: "v1.2.3",
+ releaseTitle: "v1.2.3",
+ tagExists: false,
+ releaseExists: false,
+ targetBranch: "main",
+ ...overrides,
+ };
+}
+
+function buildChangelog(
+ overrides: Partial = {},
+): ChangelogResult {
+ return {
+ updated: true,
+ changelogFileContent: "## [unreleased]\n",
+ releaseBody: "Release body",
+ ...overrides,
+ };
+}
+
+function baseGitPort(
+ overrides: Parameters[0] = {},
+): GitPort {
+ return createFakeGitPort({
+ cloneWorkspace: () => undefined,
+ fetchTargetBranch: () => undefined,
+ branchExistsRemote: () => false,
+ checkoutBranchFromTarget: () => undefined,
+ checkoutExistingBranch: () => undefined,
+ add: () => undefined,
+ commit: () => undefined,
+ getHeadSha: () => "local-head-sha",
+ pushBranch: () => undefined,
+ ...overrides,
+ });
+}
+
+const SUCCESSFUL_RUN = {
+ id: 1,
+ head_sha: "remote-head-sha",
+ head_branch: "release-changelog-update/1.2.3",
+ status: "completed",
+ conclusion: "success",
+ created_at: "2026-01-01T00:00:00Z",
+ html_url: "https://github.com/owner/repo/actions/runs/1",
+};
+
+describe("createChangelogPr", () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test("uses branch name format release-changelog-update/", async () => {
+ let receivedBranchName = "";
+ const git = baseGitPort({
+ checkoutBranchFromTarget: (branchName) => {
+ receivedBranchName = branchName;
+ },
+ });
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ });
+
+ await createChangelogPr(
+ buildConfig(),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(receivedBranchName).toBe("release-changelog-update/1.2.3");
+ });
+
+ test("sends the correct PR creation payload", async () => {
+ let captured: unknown;
+ const git = baseGitPort();
+ const client = createFakeGitHubClient({
+ createPullRequest: (input) => {
+ captured = input;
+ return Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ });
+ },
+ });
+
+ await createChangelogPr(
+ buildConfig(),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(captured).toEqual({
+ owner: "owner",
+ repo: "repo",
+ title: "chore: update changelog for version 1.2.3",
+ head: "release-changelog-update/1.2.3",
+ base: "main",
+ body: "This PR updates the changelog for the new version 1.2.3. Please review and merge it to proceed with the release process.",
+ });
+ });
+
+ test("recovers from a 422 already-exists response by looking up the open PR", async () => {
+ const git = baseGitPort();
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.reject(new GitHubApiError("Validation failed", 422)),
+ findOpenPullRequestByHead: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/9",
+ head: { sha: "existing-head-sha" },
+ }),
+ });
+
+ const result = await createChangelogPr(
+ buildConfig(),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(result.prUrl).toBe("https://github.com/owner/repo/pull/9");
+ expect(result.headSha).toBe("existing-head-sha");
+ });
+
+ test("throws clearly on a non-422 PR creation failure", async () => {
+ const git = baseGitPort();
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.reject(new GitHubApiError("Internal error", 500)),
+ });
+
+ await expect(
+ createChangelogPr(
+ buildConfig(),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ ),
+ ).rejects.toThrow("Internal error");
+ });
+
+ test("captures head SHA from the local commit and falls back when API omits it", async () => {
+ const git = baseGitPort({ getHeadSha: () => "captured-before-push" });
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "" },
+ }),
+ });
+
+ const result = await createChangelogPr(
+ buildConfig(),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(result.headSha).toBe("captured-before-push");
+ });
+
+ test("auto discovery finds workflow_dispatch workflows and skips the release workflow itself", async () => {
+ const git = baseGitPort({
+ cloneWorkspace: (_source, target) => {
+ const workflowsDir = path.join(target, ".github", "workflows");
+ fs.mkdirSync(workflowsDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(workflowsDir, "release.yml"),
+ "on:\n workflow_dispatch:\n",
+ "utf8",
+ );
+ fs.writeFileSync(
+ path.join(workflowsDir, "ci.yml"),
+ "on:\n workflow_dispatch:\n",
+ "utf8",
+ );
+ fs.writeFileSync(
+ path.join(workflowsDir, "push-only.yml"),
+ "on:\n push:\n",
+ "utf8",
+ );
+ },
+ });
+
+ const dispatchedWorkflows: string[] = [];
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: (_owner, _repo, workflowFileName) => {
+ dispatchedWorkflows.push(workflowFileName);
+ return Promise.resolve();
+ },
+ listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]),
+ getWorkflowRun: () =>
+ Promise.resolve({ status: "completed", conclusion: "success" }),
+ listJobsForWorkflowRun: () =>
+ Promise.resolve([
+ { name: "Dummy CI Check", conclusion: "success", html_url: null },
+ ]),
+ createCheckRun: () => Promise.resolve(),
+ });
+
+ const config = buildConfig({
+ ciWorkflows: { mode: "auto" },
+ githubWorkflowRef:
+ "owner/repo/.github/workflows/release.yml@refs/heads/main",
+ });
+
+ await createChangelogPr(
+ config,
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(dispatchedWorkflows).toEqual(["ci.yml"]);
+ });
+
+ test("explicit workflow list bypasses discovery", async () => {
+ const git = baseGitPort();
+ const dispatchedWorkflows: string[] = [];
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: (_owner, _repo, workflowFileName) => {
+ dispatchedWorkflows.push(workflowFileName);
+ return Promise.resolve();
+ },
+ listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]),
+ getWorkflowRun: () =>
+ Promise.resolve({ status: "completed", conclusion: "success" }),
+ listJobsForWorkflowRun: () =>
+ Promise.resolve([
+ { name: "Explicit Check", conclusion: "success", html_url: null },
+ ]),
+ createCheckRun: () => Promise.resolve(),
+ });
+
+ const config = buildConfig({
+ ciWorkflows: { mode: "explicit", workflows: ["explicit.yml"] },
+ });
+
+ await createChangelogPr(
+ config,
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(dispatchedWorkflows).toEqual(["explicit.yml"]);
+ });
+
+ test("disabled mode dispatches nothing", async () => {
+ const git = baseGitPort();
+ let dispatchCalled = false;
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: () => {
+ dispatchCalled = true;
+ return Promise.resolve();
+ },
+ });
+
+ await createChangelogPr(
+ buildConfig({ ciWorkflows: { mode: "disabled" } }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(dispatchCalled).toBe(false);
+ });
+
+ test("dispatch call includes the branch ref", async () => {
+ const git = baseGitPort();
+ let receivedRef = "";
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: (_owner, _repo, _workflowFileName, ref) => {
+ receivedRef = ref;
+ return Promise.resolve();
+ },
+ listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]),
+ getWorkflowRun: () =>
+ Promise.resolve({ status: "completed", conclusion: "success" }),
+ listJobsForWorkflowRun: () =>
+ Promise.resolve([
+ { name: "Check", conclusion: "success", html_url: null },
+ ]),
+ createCheckRun: () => Promise.resolve(),
+ });
+
+ await createChangelogPr(
+ buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(receivedRef).toBe("release-changelog-update/1.2.3");
+ });
+
+ test("run lookup falls back to branch+created_at when head_sha does not match", async () => {
+ const git = baseGitPort();
+ const createCheckRunCalls: { name: string; conclusion: string }[] = [];
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: () => Promise.resolve(),
+ listWorkflowRuns: () =>
+ Promise.resolve([
+ {
+ id: 2,
+ head_sha: "some-other-sha",
+ head_branch: "release-changelog-update/1.2.3",
+ status: "completed",
+ conclusion: "success",
+ created_at: "2099-01-01T00:00:00Z",
+ html_url: "https://github.com/owner/repo/actions/runs/2",
+ },
+ ]),
+ getWorkflowRun: () =>
+ Promise.resolve({ status: "completed", conclusion: "success" }),
+ listJobsForWorkflowRun: () =>
+ Promise.resolve([
+ { name: "Check", conclusion: "success", html_url: null },
+ ]),
+ createCheckRun: (_owner, _repo, input) => {
+ createCheckRunCalls.push({
+ name: input.name,
+ conclusion: input.conclusion,
+ });
+ return Promise.resolve();
+ },
+ });
+
+ await createChangelogPr(
+ buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(createCheckRunCalls).toEqual([
+ { name: "Check", conclusion: "success" },
+ ]);
+ });
+
+ test("check run is created with the exact job name and the changelog PR head SHA", async () => {
+ const git = baseGitPort();
+ const createCheckRunCalls: { name: string; head_sha: string }[] = [];
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: () => Promise.resolve(),
+ listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]),
+ getWorkflowRun: () =>
+ Promise.resolve({ status: "completed", conclusion: "success" }),
+ listJobsForWorkflowRun: () =>
+ Promise.resolve([
+ { name: "Dummy CI Check", conclusion: "success", html_url: null },
+ ]),
+ createCheckRun: (_owner, _repo, input) => {
+ createCheckRunCalls.push({
+ name: input.name,
+ head_sha: input.head_sha,
+ });
+ return Promise.resolve();
+ },
+ });
+
+ await createChangelogPr(
+ buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(createCheckRunCalls).toEqual([
+ { name: "Dummy CI Check", head_sha: "remote-head-sha" },
+ ]);
+ });
+
+ test("a failing job still creates a check run and fails the overall call", async () => {
+ const git = baseGitPort();
+ const createCheckRunCalls: { name: string; conclusion: string }[] = [];
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: () => Promise.resolve(),
+ listWorkflowRuns: () =>
+ Promise.resolve([{ ...SUCCESSFUL_RUN, conclusion: "failure" }]),
+ getWorkflowRun: () =>
+ Promise.resolve({ status: "completed", conclusion: "failure" }),
+ listJobsForWorkflowRun: () =>
+ Promise.resolve([
+ { name: "Failing Check", conclusion: "failure", html_url: null },
+ ]),
+ createCheckRun: (_owner, _repo, input) => {
+ createCheckRunCalls.push({
+ name: input.name,
+ conclusion: input.conclusion,
+ });
+ return Promise.resolve();
+ },
+ });
+
+ await expect(
+ createChangelogPr(
+ buildConfig({
+ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] },
+ }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ ),
+ ).rejects.toThrow(
+ "Dispatched CI job 'Failing Check' finished with conclusion 'failure'.",
+ );
+
+ expect(createCheckRunCalls).toEqual([
+ { name: "Failing Check", conclusion: "failure" },
+ ]);
+ });
+
+ test("no workflow run found after exhausting retries throws clearly", async () => {
+ jest.useFakeTimers();
+
+ const git = baseGitPort();
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/1",
+ head: { sha: "remote-head-sha" },
+ }),
+ createWorkflowDispatch: () => Promise.resolve(),
+ listWorkflowRuns: () => Promise.resolve([]),
+ });
+
+ const resultPromise = createChangelogPr(
+ buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ const expectation = expect(resultPromise).rejects.toThrow(
+ /Timed out while waiting for dispatched workflow run/,
+ );
+
+ await jest.advanceTimersByTimeAsync(60 * 5000 + 1000);
+ await expectation;
+ }, 20000);
+
+ test("dry-run PR URL uses the configured GitHub Enterprise server URL, not github.com", async () => {
+ const git = baseGitPort();
+ const client = createFakeGitHubClient();
+
+ const result = await createChangelogPr(
+ buildConfig({
+ dryRun: true,
+ githubServerUrl: "https://github.example-corp.com",
+ }),
+ buildSetup(),
+ buildChangelog(),
+ git,
+ client,
+ );
+
+ expect(result.prUrl).toBe(
+ "https://github.example-corp.com/owner/repo/pull/dry-run-placeholder",
+ );
+ });
+});
diff --git a/src/__tests__/release/createRelease.test.ts b/src/__tests__/release/createRelease.test.ts
new file mode 100644
index 0000000..f7fc0a5
--- /dev/null
+++ b/src/__tests__/release/createRelease.test.ts
@@ -0,0 +1,155 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import type { ActionConfig } from "../../config.js";
+import { createReleaseForTag } from "../../release/createRelease.js";
+import type { ReleaseSetup } from "../../release/setupRelease.js";
+import { createFakeGitHubClient } from "../mocks/github-client.js";
+
+function buildConfig(overrides: Partial = {}): ActionConfig {
+ return {
+ githubToken: "test-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "auto" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "open-resource-discovery/github-release",
+ githubActor: "octocat",
+ githubWorkspace: "/workspace",
+ ...overrides,
+ };
+}
+
+function buildSetup(overrides: Partial = {}): ReleaseSetup {
+ return {
+ version: "1.2.3",
+ tag: "v1.2.3",
+ releaseTitle: "v1.2.3",
+ tagExists: false,
+ releaseExists: false,
+ targetBranch: "main",
+ ...overrides,
+ };
+}
+
+describe("createReleaseForTag", () => {
+ const originalGithubOutput = process.env.GITHUB_OUTPUT;
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-release-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+
+ if (originalGithubOutput === undefined) {
+ delete process.env.GITHUB_OUTPUT;
+ } else {
+ process.env.GITHUB_OUTPUT = originalGithubOutput;
+ }
+ });
+
+ test("requires a non-empty tag", async () => {
+ const client = createFakeGitHubClient();
+
+ await expect(
+ createReleaseForTag(
+ buildConfig(),
+ buildSetup({ tag: "" }),
+ "body",
+ client,
+ ),
+ ).rejects.toThrow("TAG is required but not set.");
+ });
+
+ test("sends the correct create-release payload", async () => {
+ let captured: unknown;
+ const client = createFakeGitHubClient({
+ createRelease: (input) => {
+ captured = input;
+ return Promise.resolve({
+ html_url:
+ "https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3",
+ });
+ },
+ });
+
+ await createReleaseForTag(
+ buildConfig(),
+ buildSetup(),
+ "Release body",
+ client,
+ );
+
+ expect(captured).toEqual({
+ owner: "open-resource-discovery",
+ repo: "github-release",
+ tag_name: "v1.2.3",
+ target_commitish: "main",
+ name: "v1.2.3",
+ body: "Release body",
+ draft: false,
+ prerelease: false,
+ });
+ });
+
+ test("respects draft and prerelease flags", async () => {
+ let captured: { draft: boolean; prerelease: boolean } | undefined;
+ const client = createFakeGitHubClient({
+ createRelease: (input) => {
+ captured = { draft: input.draft, prerelease: input.prerelease };
+ return Promise.resolve({ html_url: "https://example.com/release" });
+ },
+ });
+
+ await createReleaseForTag(
+ buildConfig({ releaseDraft: true, releasePrerelease: true }),
+ buildSetup(),
+ "body",
+ client,
+ );
+
+ expect(captured).toEqual({ draft: true, prerelease: true });
+ });
+
+ test("writes release-url to GITHUB_OUTPUT", async () => {
+ const githubOutput = path.join(tempDir, "github-output.txt");
+ fs.writeFileSync(githubOutput, "", "utf8");
+ process.env.GITHUB_OUTPUT = githubOutput;
+
+ const client = createFakeGitHubClient({
+ createRelease: () =>
+ Promise.resolve({ html_url: "https://example.com/releases/v1.2.3" }),
+ });
+
+ const url = await createReleaseForTag(
+ buildConfig(),
+ buildSetup(),
+ "body",
+ client,
+ );
+
+ expect(url).toBe("https://example.com/releases/v1.2.3");
+ expect(fs.readFileSync(githubOutput, "utf8")).toBe(
+ "release-url=https://example.com/releases/v1.2.3\n",
+ );
+ });
+
+ test("bubbles up API failures clearly", async () => {
+ const client = createFakeGitHubClient({
+ createRelease: () => Promise.reject(new Error("GitHub API error")),
+ });
+
+ await expect(
+ createReleaseForTag(buildConfig(), buildSetup(), "body", client),
+ ).rejects.toThrow("GitHub API error");
+ });
+});
diff --git a/src/__tests__/release/pipeline.test.ts b/src/__tests__/release/pipeline.test.ts
new file mode 100644
index 0000000..bf4c4e8
--- /dev/null
+++ b/src/__tests__/release/pipeline.test.ts
@@ -0,0 +1,234 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import type { ActionConfig } from "../../config.js";
+import type { GitPort } from "../../git/git.js";
+import { runPipeline } from "../../release/pipeline.js";
+import { createFakeGitPort } from "../mocks/git-port.js";
+import { createFakeGitHubClient } from "../mocks/github-client.js";
+
+function buildConfig(
+ workspaceDir: string,
+ overrides: Partial = {},
+): ActionConfig {
+ return {
+ githubToken: "test-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "disabled" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "owner/repo",
+ githubActor: "octocat",
+ githubWorkspace: workspaceDir,
+ ...overrides,
+ };
+}
+
+function baseGitPort(
+ overrides: Parameters[0] = {},
+): GitPort {
+ return createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ tagList: () => [],
+ gitLog: () => "",
+ fetchBranches: () => undefined,
+ hasDiffAgainstRef: () => false,
+ hasUnstagedChanges: () => false,
+ cloneWorkspace: () => undefined,
+ fetchTargetBranch: () => undefined,
+ branchExistsRemote: () => false,
+ checkoutBranchFromTarget: () => undefined,
+ add: () => undefined,
+ commit: () => undefined,
+ getHeadSha: () => "head-sha",
+ pushBranch: () => undefined,
+ ...overrides,
+ });
+}
+
+describe("runPipeline", () => {
+ let workspaceDir: string;
+
+ beforeEach(() => {
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipeline-"));
+ fs.writeFileSync(
+ path.join(workspaceDir, "package.json"),
+ JSON.stringify({ version: "1.3.0" }),
+ "utf8",
+ );
+ });
+
+ afterEach(() => {
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
+ });
+
+ test("changelog updated: creates PR, throws with PR URL, never creates a release", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", "New stuff", ""].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir);
+ let createReleaseCalled = false;
+
+ const git = baseGitPort();
+ const client = createFakeGitHubClient({
+ createPullRequest: () =>
+ Promise.resolve({
+ html_url: "https://github.com/owner/repo/pull/5",
+ head: { sha: "remote-sha" },
+ }),
+ createRelease: () => {
+ createReleaseCalled = true;
+ return Promise.resolve({ html_url: "https://example.com" });
+ },
+ });
+
+ await expect(runPipeline(config, { git, client })).rejects.toThrow(
+ /Please review and merge the changelog PR.*pull\/5/,
+ );
+
+ expect(createReleaseCalled).toBe(false);
+ });
+
+ test("dry-run with an updated changelog resolves successfully without mutating anything remote", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", "New stuff", ""].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir, { dryRun: true });
+ let pushBranchCalled = false;
+ let createPullRequestCalled = false;
+ let createReleaseCalled = false;
+
+ const git = baseGitPort({
+ pushBranch: () => {
+ pushBranchCalled = true;
+ },
+ });
+ const client = createFakeGitHubClient({
+ createPullRequest: () => {
+ createPullRequestCalled = true;
+ return Promise.reject(new Error("should not be called"));
+ },
+ createRelease: () => {
+ createReleaseCalled = true;
+ return Promise.resolve({ html_url: "https://example.com" });
+ },
+ });
+
+ await expect(runPipeline(config, { git, client })).resolves.toBeUndefined();
+
+ expect(pushBranchCalled).toBe(false);
+ expect(createPullRequestCalled).toBe(false);
+ expect(createReleaseCalled).toBe(false);
+ });
+
+ test("changelog unchanged and release does not exist: creates the release", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ [
+ "# Changelog",
+ "",
+ "## [[1.3.0](https://github.com/owner/repo/releases/tag/v1.3.0)] - 2026-01-01",
+ "",
+ "Already released",
+ "",
+ "## [unreleased]",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir);
+ let createPullRequestCalled = false;
+ let createReleaseUrl: string | undefined;
+
+ const git = baseGitPort();
+ const client = createFakeGitHubClient({
+ createPullRequest: () => {
+ createPullRequestCalled = true;
+ return Promise.reject(new Error("should not be called"));
+ },
+ createRelease: () => {
+ createReleaseUrl = "https://github.com/owner/repo/releases/tag/v1.3.0";
+ return Promise.resolve({ html_url: createReleaseUrl });
+ },
+ });
+
+ await runPipeline(config, { git, client });
+
+ expect(createPullRequestCalled).toBe(false);
+ expect(createReleaseUrl).toBe(
+ "https://github.com/owner/repo/releases/tag/v1.3.0",
+ );
+ });
+
+ test("release already exists: throws early, never collects commits or creates a PR/release", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", ""].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir);
+
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => true,
+ listTagsSortedByVersionDescending: () => [],
+ // tagList/gitLog intentionally NOT configured — if collectCommits ran,
+ // the fake's "not configured" guard would throw a different error.
+ });
+ const client = createFakeGitHubClient({
+ getReleaseByTag: () => Promise.resolve({ id: 1 }),
+ });
+
+ await expect(runPipeline(config, { git, client })).rejects.toThrow(
+ "Release for tag v1.3.0 already exists.",
+ );
+ });
+
+ test("dry-run still throws when a release already exists (no dry-run exception)", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", ""].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir, { dryRun: true });
+
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ tagExists: () => true,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient({
+ getReleaseByTag: () => Promise.resolve({ id: 1 }),
+ });
+
+ await expect(runPipeline(config, { git, client })).rejects.toThrow(
+ "Release for tag v1.3.0 already exists.",
+ );
+ });
+});
diff --git a/src/__tests__/release/renderReleaseNotes.test.ts b/src/__tests__/release/renderReleaseNotes.test.ts
new file mode 100644
index 0000000..a009c76
--- /dev/null
+++ b/src/__tests__/release/renderReleaseNotes.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "@jest/globals";
+import {
+ FALLBACK_DESCRIPTION,
+ renderReleaseBody,
+} from "../../release/renderReleaseNotes.js";
+
+describe("renderReleaseBody", () => {
+ test("renders the normal case with a description and commit list", () => {
+ const body = renderReleaseBody("Some description.", [
+ "* First change by @alice in [#1](https://example.com/pull/1)",
+ ]);
+
+ expect(body).toBe(
+ [
+ "Some description.",
+ "",
+ "------",
+ "",
+ "## What's Changed",
+ "* First change by @alice in [#1](https://example.com/pull/1)",
+ ].join("\n"),
+ );
+ });
+
+ test("uses the fallback description constant when given an empty description", () => {
+ const body = renderReleaseBody(FALLBACK_DESCRIPTION, [
+ "* No changes since last release.",
+ ]);
+
+ expect(body).toContain("This release includes the changes below.");
+ });
+
+ test("includes the Full Changelog link when provided", () => {
+ const body = renderReleaseBody(
+ "Description",
+ ["* Change"],
+ "**Full Changelog**: [v1.0.0...v1.1.0](https://example.com/compare/v1.0.0...v1.1.0)",
+ );
+
+ expect(body).toContain(
+ "**Full Changelog**: [v1.0.0...v1.1.0](https://example.com/compare/v1.0.0...v1.1.0)",
+ );
+ expect(
+ body.endsWith(
+ "**Full Changelog**: [v1.0.0...v1.1.0](https://example.com/compare/v1.0.0...v1.1.0)",
+ ),
+ ).toBe(true);
+ });
+
+ test("omits the Full Changelog section entirely when not provided", () => {
+ const body = renderReleaseBody("Description", ["* Change"]);
+
+ expect(body).not.toContain("Full Changelog");
+ });
+
+ test("heading is exactly '## What's Changed'", () => {
+ const body = renderReleaseBody("Description", ["* Change"]);
+
+ expect(body).toMatch(/^## What's Changed$/m);
+ });
+
+ test("never renders the legacy '## What's Changed (commits)' heading", () => {
+ const body = renderReleaseBody("Description", ["* Change"]);
+
+ expect(body).not.toContain("## What's Changed (commits)");
+ });
+
+ test("never renders an HTML contributor table", () => {
+ const body = renderReleaseBody("Description", [
+ "* Change by @alice in [#1](https://example.com/pull/1)",
+ ]);
+
+ expect(body).not.toContain(" = {}): ActionConfig {
+ return {
+ githubToken: "test-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "auto" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "open-resource-discovery/github-release",
+ githubActor: "octocat",
+ githubWorkspace: "",
+ ...overrides,
+ };
+}
+
+describe("setupRelease", () => {
+ let workspaceDir: string;
+
+ beforeEach(() => {
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-release-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
+ });
+
+ test("version override wins over package.json", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "package.json"),
+ JSON.stringify({ version: "0.0.1" }),
+ "utf8",
+ );
+
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "9.9.9",
+ });
+
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient();
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.version).toBe("9.9.9");
+ expect(setup.tag).toBe("v9.9.9");
+ });
+
+ test("falls back to package.json version when no override exists", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "package.json"),
+ JSON.stringify({ version: "1.4.0" }),
+ "utf8",
+ );
+
+ const config = buildConfig({ githubWorkspace: workspaceDir });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient();
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.version).toBe("1.4.0");
+ });
+
+ test("throws a clear error when no version can be resolved", async () => {
+ const config = buildConfig({ githubWorkspace: workspaceDir });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ });
+ const client = createFakeGitHubClient();
+
+ await expect(setupRelease(config, git, client)).rejects.toThrow(
+ 'Mandatory "version" parameter has not been specified.',
+ );
+ });
+
+ test("supports tag templates containing a slash", async () => {
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.2.3",
+ tagTemplate: "v/",
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient();
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.tag).toBe("v/1.2.3");
+ });
+
+ test("detects an existing tag", async () => {
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.2.3",
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: (tag) => tag === "v1.2.3",
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient({
+ getReleaseByTag: () => Promise.resolve(undefined),
+ });
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.tagExists).toBe(true);
+ });
+
+ test("detects an existing release when the tag and release both exist", async () => {
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.2.3",
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: (tag) => tag === "v1.2.3",
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient({
+ getReleaseByTag: () => Promise.resolve({ id: 42 }),
+ });
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.releaseExists).toBe(true);
+ });
+
+ test("does not call the release API when the tag does not exist", async () => {
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.2.3",
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ let callCount = 0;
+ const client = createFakeGitHubClient({
+ getReleaseByTag: () => {
+ callCount += 1;
+ return Promise.resolve(undefined);
+ },
+ });
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.releaseExists).toBe(false);
+ expect(callCount).toBe(0);
+ });
+
+ test("detects the latest tag matching the tag template", async () => {
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.2.3",
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => ["v1.1.0", "v1.0.0"],
+ });
+ const client = createFakeGitHubClient();
+
+ const setup = await setupRelease(config, git, client);
+
+ expect(setup.latestTag).toBe("v1.1.0");
+ });
+
+ test("target branch prefers githubBaseRef over githubRefName over main", async () => {
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient();
+
+ const withBaseRef = await setupRelease(
+ buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.0.0",
+ githubBaseRef: "base-branch",
+ githubRefName: "ref-branch",
+ }),
+ git,
+ client,
+ );
+ expect(withBaseRef.targetBranch).toBe("base-branch");
+
+ const withRefNameOnly = await setupRelease(
+ buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.0.0",
+ githubRefName: "ref-branch",
+ }),
+ git,
+ client,
+ );
+ expect(withRefNameOnly.targetBranch).toBe("ref-branch");
+
+ const fallback = await setupRelease(
+ buildConfig({ githubWorkspace: workspaceDir, versionOverride: "1.0.0" }),
+ git,
+ client,
+ );
+ expect(fallback.targetBranch).toBe("main");
+ });
+
+ test("dry-run skips fetchBranchesAndTags", async () => {
+ let fetchCalled = false;
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.0.0",
+ dryRun: true,
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => {
+ fetchCalled = true;
+ },
+ tagExists: () => false,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ const client = createFakeGitHubClient();
+
+ await setupRelease(config, git, client);
+
+ expect(fetchCalled).toBe(false);
+ });
+
+ test("respects GitHub Enterprise API URL when checking release existence", async () => {
+ const config = buildConfig({
+ githubWorkspace: workspaceDir,
+ versionOverride: "1.2.3",
+ githubServerUrl: "https://ghe.example.com",
+ githubApiUrl: "https://ghe.example.com/api/v3",
+ });
+ const git = createFakeGitPort({
+ configSafeDirectory: () => undefined,
+ configUser: () => undefined,
+ configGitHttpAuth: () => undefined,
+ fetchBranchesAndTags: () => undefined,
+ tagExists: () => true,
+ listTagsSortedByVersionDescending: () => [],
+ });
+ let receivedApiCall = false;
+ const client = createFakeGitHubClient({
+ getReleaseByTag: () => {
+ receivedApiCall = true;
+ return Promise.resolve(undefined);
+ },
+ });
+
+ await setupRelease(config, git, client);
+
+ expect(receivedApiCall).toBe(true);
+ });
+});
diff --git a/src/__tests__/release/updateChangelog.test.ts b/src/__tests__/release/updateChangelog.test.ts
new file mode 100644
index 0000000..4416600
--- /dev/null
+++ b/src/__tests__/release/updateChangelog.test.ts
@@ -0,0 +1,284 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import type { ActionConfig } from "../../config.js";
+import type { GitPort } from "../../git/git.js";
+import type { CollectedReleaseData } from "../../release/collectCommits.js";
+import type { ReleaseSetup } from "../../release/setupRelease.js";
+import { updateChangelog } from "../../release/updateChangelog.js";
+import { createFakeGitPort } from "../mocks/git-port.js";
+
+function buildConfig(
+ workspaceDir: string,
+ overrides: Partial = {},
+): ActionConfig {
+ return {
+ githubToken: "test-token",
+ dryRun: false,
+ releaseDraft: false,
+ releasePrerelease: false,
+ releaseTitlePrefix: "",
+ tagTemplate: "v",
+ changelogFilePath: "CHANGELOG.md",
+ versionOverride: undefined,
+ ciWorkflows: { mode: "auto" },
+ githubServerUrl: "https://github.com",
+ githubApiUrl: "https://api.github.com",
+ githubRepository: "open-resource-discovery/github-release",
+ githubActor: "octocat",
+ githubWorkspace: workspaceDir,
+ ...overrides,
+ };
+}
+
+function buildSetup(overrides: Partial = {}): ReleaseSetup {
+ return {
+ version: "1.2.3",
+ tag: "v1.2.3",
+ releaseTitle: "v1.2.3",
+ tagExists: false,
+ releaseExists: false,
+ targetBranch: "main",
+ ...overrides,
+ };
+}
+
+function buildCollected(
+ overrides: Partial = {},
+): CollectedReleaseData {
+ return {
+ commitLogLines: [
+ "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)",
+ ],
+ ...overrides,
+ };
+}
+
+function defaultGitPort(
+ diffAgainstRefResult = false,
+ hasUnstaged = false,
+): GitPort {
+ return createFakeGitPort({
+ fetchBranches: () => undefined,
+ hasDiffAgainstRef: () => diffAgainstRefResult,
+ hasUnstagedChanges: () => hasUnstaged,
+ pullTargetBranch: () => undefined,
+ add: () => undefined,
+ commit: () => undefined,
+ });
+}
+
+describe("updateChangelog", () => {
+ let workspaceDir: string;
+
+ beforeEach(() => {
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "update-changelog-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
+ });
+
+ test("existing version path: extracts description, returns updated=false", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "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",
+ );
+
+ const config = buildConfig(workspaceDir);
+ const setup = buildSetup();
+ const collected = buildCollected();
+ const git = defaultGitPort();
+
+ const result = await updateChangelog(config, setup, collected, git);
+
+ expect(result.updated).toBe(false);
+ expect(result.changelogFileContent).toBeUndefined();
+ expect(result.releaseBody).toContain("### Added");
+ expect(result.releaseBody).toContain("- Existing changelog entry");
+ expect(result.releaseBody).toContain("------");
+ expect(result.releaseBody).toMatch(/^## What's Changed$/m);
+ expect(result.releaseBody).toContain(
+ "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)",
+ );
+ });
+
+ test("new version path: returns updated=true with new changelog content", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "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",
+ );
+
+ const config = buildConfig(workspaceDir);
+ const setup = buildSetup();
+ const collected = buildCollected({
+ commitLogLines: [
+ "* New changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)",
+ ],
+ });
+ const git = defaultGitPort();
+
+ const result = await updateChangelog(config, setup, collected, git);
+
+ expect(result.updated).toBe(true);
+ expect(result.changelogFileContent).toBeDefined();
+ expect(result.changelogFileContent).toContain("## [unreleased]");
+ expect(result.changelogFileContent).toContain(
+ "## [[1.2.3](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3)]",
+ );
+ expect(result.changelogFileContent).toContain(
+ "## [[1.2.2](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.2)]",
+ );
+ expect(result.changelogFileContent).toContain("- Old entry");
+
+ expect(result.releaseBody).toContain("### Added");
+ expect(result.releaseBody).toContain("- New changelog entry");
+ expect(result.releaseBody).toMatch(/^## What's Changed$/m);
+ expect(result.releaseBody).not.toContain("## What's Changed (commits)");
+ });
+
+ test("falls back to default description when section body is blank", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ [
+ "# Changelog",
+ "",
+ "## [unreleased]",
+ "",
+ "## [[1.0.0](url)] - 2025-01-01",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir);
+ const setup = buildSetup({ version: "1.2.3" });
+ const collected = buildCollected();
+ const git = defaultGitPort();
+
+ const result = await updateChangelog(config, setup, collected, git);
+
+ expect(result.releaseBody).toContain(
+ "This release includes the changes below.",
+ );
+ });
+
+ test("never renders the legacy '(commits)' heading or HTML tables", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", "Some description", ""].join(
+ "\n",
+ ),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir);
+ const setup = buildSetup();
+ const collected = buildCollected();
+ const git = defaultGitPort();
+
+ const result = await updateChangelog(config, setup, collected, git);
+
+ expect(result.releaseBody).not.toContain("## What's Changed (commits)");
+ expect(result.releaseBody).not.toContain(" {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", "Description", ""].join("\n"),
+ "utf8",
+ );
+
+ const config = buildConfig(workspaceDir);
+ const setup = buildSetup();
+ const collected = buildCollected({
+ fullChangelogLine:
+ "**Full Changelog**: [v1.2.2...v1.2.3](https://github.com/owner/repo/compare/v1.2.2...v1.2.3)",
+ });
+ const git = defaultGitPort();
+
+ const result = await updateChangelog(config, setup, collected, git);
+
+ expect(result.releaseBody).toContain(
+ "**Full Changelog**: [v1.2.2...v1.2.3](https://github.com/owner/repo/compare/v1.2.2...v1.2.3)",
+ );
+ });
+
+ test("pulls latest target branch when changelog is outdated and not dry-run", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", "Description", ""].join("\n"),
+ "utf8",
+ );
+
+ let pullCalled = false;
+ const config = buildConfig(workspaceDir);
+ const setup = buildSetup();
+ const collected = buildCollected();
+ const git = createFakeGitPort({
+ fetchBranches: () => undefined,
+ hasDiffAgainstRef: () => true,
+ hasUnstagedChanges: () => false,
+ pullTargetBranch: () => {
+ pullCalled = true;
+ },
+ });
+
+ await updateChangelog(config, setup, collected, git);
+
+ expect(pullCalled).toBe(true);
+ });
+
+ test("dry-run skips pulling even when changelog is outdated", async () => {
+ fs.writeFileSync(
+ path.join(workspaceDir, "CHANGELOG.md"),
+ ["# Changelog", "", "## [unreleased]", "", "Description", ""].join("\n"),
+ "utf8",
+ );
+
+ let pullCalled = false;
+ const config = buildConfig(workspaceDir, { dryRun: true });
+ const setup = buildSetup();
+ const collected = buildCollected();
+ const git = createFakeGitPort({
+ fetchBranches: () => undefined,
+ hasDiffAgainstRef: () => true,
+ hasUnstagedChanges: () => false,
+ pullTargetBranch: () => {
+ pullCalled = true;
+ },
+ });
+
+ await updateChangelog(config, setup, collected, git);
+
+ expect(pullCalled).toBe(false);
+ });
+});
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",
+ );
+ });
+});
diff --git a/src/__tests__/utils/env.test.ts b/src/__tests__/utils/env.test.ts
new file mode 100644
index 0000000..bbc5ce2
--- /dev/null
+++ b/src/__tests__/utils/env.test.ts
@@ -0,0 +1,103 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import {
+ exportEnv,
+ getBooleanEnv,
+ getEnv,
+ getRequiredEnv,
+} from "../../utils/env.js";
+
+describe("getEnv", () => {
+ test("returns value when set", () => {
+ const env = { MY_VAR: "hello" };
+ expect(getEnv("MY_VAR", env)).toBe("hello");
+ });
+
+ test("returns undefined when not set", () => {
+ expect(getEnv("MISSING", {})).toBeUndefined();
+ });
+
+ test("returns undefined for empty string", () => {
+ const env = { MY_VAR: "" };
+ expect(getEnv("MY_VAR", env)).toBeUndefined();
+ });
+});
+
+describe("getRequiredEnv", () => {
+ test("returns value when set", () => {
+ const env = { MY_VAR: "value" };
+ expect(getRequiredEnv("MY_VAR", env)).toBe("value");
+ });
+
+ test("throws when not set", () => {
+ expect(() => getRequiredEnv("MISSING", {})).toThrow(
+ "MISSING is required but not set.",
+ );
+ });
+
+ test("throws when empty string", () => {
+ expect(() => getRequiredEnv("MY_VAR", { MY_VAR: "" })).toThrow(
+ "MY_VAR is required but not set.",
+ );
+ });
+});
+
+describe("getBooleanEnv", () => {
+ test("returns true only for exact string 'true'", () => {
+ expect(getBooleanEnv("FLAG", false, { FLAG: "true" })).toBe(true);
+ });
+
+ test("returns false for 'TRUE' (case-sensitive)", () => {
+ expect(getBooleanEnv("FLAG", false, { FLAG: "TRUE" })).toBe(false);
+ });
+
+ test("returns false for '1'", () => {
+ expect(getBooleanEnv("FLAG", false, { FLAG: "1" })).toBe(false);
+ });
+
+ test("returns false for 'yes'", () => {
+ expect(getBooleanEnv("FLAG", false, { FLAG: "yes" })).toBe(false);
+ });
+
+ test("uses default when not set", () => {
+ expect(getBooleanEnv("FLAG", true, {})).toBe(true);
+ expect(getBooleanEnv("FLAG", false, {})).toBe(false);
+ });
+
+ test("uses default when empty string", () => {
+ expect(getBooleanEnv("FLAG", true, { FLAG: "" })).toBe(true);
+ });
+});
+
+describe("exportEnv", () => {
+ let tempDir: string;
+ let githubEnvFile: string;
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "env-test-"));
+ githubEnvFile = path.join(tempDir, "github.env");
+ fs.writeFileSync(githubEnvFile, "", "utf8");
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test("appends NAME=value to GITHUB_ENV file", () => {
+ exportEnv("MY_VAR", "hello", { GITHUB_ENV: githubEnvFile });
+ expect(fs.readFileSync(githubEnvFile, "utf8")).toBe("MY_VAR=hello\n");
+ });
+
+ test("appends multiple entries", () => {
+ const env = { GITHUB_ENV: githubEnvFile };
+ exportEnv("FIRST", "a", env);
+ exportEnv("SECOND", "b", env);
+ expect(fs.readFileSync(githubEnvFile, "utf8")).toBe("FIRST=a\nSECOND=b\n");
+ });
+
+ test("does nothing when GITHUB_ENV is not set", () => {
+ expect(() => exportEnv("MY_VAR", "val", {})).not.toThrow();
+ });
+});
diff --git a/src/__tests__/utils/log.test.ts b/src/__tests__/utils/log.test.ts
new file mode 100644
index 0000000..bd9eb45
--- /dev/null
+++ b/src/__tests__/utils/log.test.ts
@@ -0,0 +1,98 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ jest,
+ test,
+} from "@jest/globals";
+import { addMask, error, info, notice, warning } from "../../utils/log.js";
+
+describe("log", () => {
+ let stdoutOutput: string[];
+ let stderrOutput: string[];
+
+ beforeEach(() => {
+ stdoutOutput = [];
+ stderrOutput = [];
+ jest
+ .spyOn(process.stdout, "write")
+ .mockImplementation((chunk: unknown): boolean => {
+ stdoutOutput.push(String(chunk));
+ return true;
+ });
+ jest
+ .spyOn(process.stderr, "write")
+ .mockImplementation((chunk: unknown): boolean => {
+ stderrOutput.push(String(chunk));
+ return true;
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe("info", () => {
+ test("writes message to stdout with newline", () => {
+ info("hello world");
+ expect(stdoutOutput).toEqual(["hello world\n"]);
+ });
+ });
+
+ describe("notice", () => {
+ test("wraps message with ::notice:: prefix", () => {
+ notice("something happened");
+ expect(stdoutOutput).toEqual(["::notice::something happened\n"]);
+ });
+
+ test("escapes percent signs", () => {
+ notice("50% done");
+ expect(stdoutOutput).toEqual(["::notice::50%25 done\n"]);
+ });
+
+ test("escapes newlines", () => {
+ notice("line1\nline2");
+ expect(stdoutOutput).toEqual(["::notice::line1%0Aline2\n"]);
+ });
+
+ test("escapes carriage returns", () => {
+ notice("line1\rline2");
+ expect(stdoutOutput).toEqual(["::notice::line1%0Dline2\n"]);
+ });
+ });
+
+ describe("warning", () => {
+ test("wraps message with ::warning:: prefix on stdout", () => {
+ warning("be careful");
+ expect(stdoutOutput).toEqual(["::warning::be careful\n"]);
+ });
+ });
+
+ describe("error", () => {
+ test("writes ::error:: prefix to stderr", () => {
+ error("something failed");
+ expect(stderrOutput).toEqual(["::error::something failed\n"]);
+ expect(stdoutOutput).toHaveLength(0);
+ });
+
+ test("escapes newlines in error message", () => {
+ error("line1\nline2");
+ expect(stderrOutput).toEqual(["::error::line1%0Aline2\n"]);
+ });
+ });
+
+ describe("addMask", () => {
+ test("emits ::add-mask:: and returns the value", () => {
+ const result = addMask("secret-token");
+ expect(result).toBe("secret-token");
+ expect(stdoutOutput).toEqual(["::add-mask::secret-token\n"]);
+ });
+
+ test("does not emit anything for empty string", () => {
+ const result = addMask("");
+ expect(result).toBe("");
+ expect(stdoutOutput).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/__tests__/utils/repository.test.ts b/src/__tests__/utils/repository.test.ts
new file mode 100644
index 0000000..f34bf43
--- /dev/null
+++ b/src/__tests__/utils/repository.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "@jest/globals";
+import { parseRepositoryCoordinates } from "../../utils/repository.js";
+
+describe("parseRepositoryCoordinates", () => {
+ test("splits a valid owner/repo string", () => {
+ expect(parseRepositoryCoordinates("octocat/hello-world")).toEqual({
+ owner: "octocat",
+ repo: "hello-world",
+ });
+ });
+
+ test.each(["", "owner", "owner/", "/repo", "owner/repo/extra"])(
+ "throws for malformed value %p",
+ (value) => {
+ expect(() => parseRepositoryCoordinates(value)).toThrow(
+ /GITHUB_REPOSITORY must be in the form "owner\/repo"/,
+ );
+ },
+ );
+});
diff --git a/src/__tests__/utils/retry.test.ts b/src/__tests__/utils/retry.test.ts
new file mode 100644
index 0000000..0a1a100
--- /dev/null
+++ b/src/__tests__/utils/retry.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, test } from "@jest/globals";
+import { retryUntil } from "../../utils/retry.js";
+
+const noSleep = (): Promise => Promise.resolve();
+
+describe("retryUntil", () => {
+ test("returns immediately when operation succeeds on first attempt", async () => {
+ const result = await retryUntil(
+ () => Promise.resolve("value" as string | undefined),
+ {
+ maxAttempts: 3,
+ intervalMs: 0,
+ description: "test",
+ sleep: noSleep,
+ },
+ );
+ expect(result).toBe("value");
+ });
+
+ test("retries and returns when operation eventually succeeds", async () => {
+ let calls = 0;
+ const result = await retryUntil(
+ () => {
+ calls += 1;
+ const value: string | undefined = calls >= 3 ? "done" : undefined;
+ return Promise.resolve(value);
+ },
+ { maxAttempts: 5, intervalMs: 0, description: "test", sleep: noSleep },
+ );
+ expect(result).toBe("done");
+ expect(calls).toBe(3);
+ });
+
+ test("throws after maxAttempts when operation never succeeds", async () => {
+ await expect(
+ retryUntil(() => Promise.resolve(undefined), {
+ maxAttempts: 3,
+ intervalMs: 0,
+ description: "workflow run",
+ sleep: noSleep,
+ }),
+ ).rejects.toThrow(
+ "Timed out while waiting for workflow run after 3 attempts.",
+ );
+ });
+
+ test("treats null as not-yet-found", async () => {
+ let calls = 0;
+ const result = await retryUntil(
+ () => {
+ calls += 1;
+ const value: number | null = calls >= 2 ? 42 : null;
+ return Promise.resolve(value);
+ },
+ { maxAttempts: 5, intervalMs: 0, description: "test", sleep: noSleep },
+ );
+ expect(result).toBe(42);
+ });
+
+ test("treats false as not-yet-found", async () => {
+ let calls = 0;
+ const result = await retryUntil(
+ () => {
+ calls += 1;
+ const value: string | false = calls >= 2 ? "found" : false;
+ return Promise.resolve(value);
+ },
+ { maxAttempts: 5, intervalMs: 0, description: "test", sleep: noSleep },
+ );
+ expect(result).toBe("found");
+ });
+
+ test("calls onRetry on each failed attempt except the last", async () => {
+ const retryCalls: number[] = [];
+ await expect(
+ retryUntil(() => Promise.resolve(undefined), {
+ maxAttempts: 3,
+ intervalMs: 0,
+ description: "x",
+ sleep: noSleep,
+ onRetry: (attempt) => {
+ retryCalls.push(attempt);
+ },
+ }),
+ ).rejects.toThrow();
+ expect(retryCalls).toEqual([1, 2]);
+ });
+});
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..2e915df
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,92 @@
+import {
+ getBooleanEnv,
+ getEnv,
+ getRequiredEnv,
+ type Env,
+} from "./utils/env.js";
+
+export type CiWorkflowsConfig =
+ | {
+ mode: "auto";
+ }
+ | {
+ mode: "disabled";
+ }
+ | {
+ mode: "explicit";
+ workflows: string[];
+ };
+
+export type ActionConfig = {
+ githubToken: string;
+ dryRun: boolean;
+ releaseDraft: boolean;
+ releasePrerelease: boolean;
+ releaseTitlePrefix: string;
+ tagTemplate: string;
+ changelogFilePath: string;
+ versionOverride?: string;
+ ciWorkflows: CiWorkflowsConfig;
+ githubServerUrl: string;
+ githubApiUrl: string;
+ githubRepository: string;
+ githubActor: string;
+ githubWorkspace: string;
+ githubRefName?: string;
+ githubBaseRef?: string;
+ githubWorkflowRef?: string;
+};
+
+function parseCiWorkflows(value: string | undefined): CiWorkflowsConfig {
+ const normalized = value?.trim();
+
+ if (normalized === undefined || normalized === "" || normalized === "auto") {
+ return { mode: "auto" };
+ }
+
+ if (normalized === "none" || normalized === "false") {
+ return { mode: "disabled" };
+ }
+
+ const workflows = normalized
+ .split(",")
+ .map((workflow) => workflow.trim())
+ .filter((workflow) => workflow.length > 0);
+
+ if (workflows.length === 0) {
+ return { mode: "auto" };
+ }
+
+ return {
+ mode: "explicit",
+ workflows,
+ };
+}
+
+export function readActionConfig(env: Env = process.env): ActionConfig {
+ const githubToken =
+ getEnv("INPUT_GITHUB-TOKEN", env) ?? getRequiredEnv("GITHUB_TOKEN", env);
+
+ const versionOverride = getEnv("INPUT_VERSION", env);
+
+ return {
+ githubToken,
+ dryRun: getBooleanEnv("INPUT_DRY-RUN", false, env),
+ releaseDraft: getBooleanEnv("INPUT_RELEASE-DRAFT", false, env),
+ releasePrerelease: getBooleanEnv("INPUT_RELEASE-PRERELEASE", false, env),
+ releaseTitlePrefix: getEnv("INPUT_RELEASE-TITLE-PREFIX", env) ?? "",
+ tagTemplate: getEnv("INPUT_TAG-TEMPLATE", env) ?? "v",
+ changelogFilePath:
+ getEnv("INPUT_CHANGELOG-FILE-PATH", env) ?? "CHANGELOG.md",
+ versionOverride,
+ ciWorkflows: parseCiWorkflows(getEnv("INPUT_CI-WORKFLOWS", env)),
+ githubServerUrl: getRequiredEnv("GITHUB_SERVER_URL", env),
+ githubApiUrl: getRequiredEnv("GITHUB_API_URL", env),
+ githubRepository: getRequiredEnv("GITHUB_REPOSITORY", env),
+ githubActor: getRequiredEnv("GITHUB_ACTOR", env),
+ githubWorkspace: getRequiredEnv("GITHUB_WORKSPACE", env),
+ githubRefName: getEnv("GITHUB_REF_NAME", env),
+ githubBaseRef: getEnv("GITHUB_BASE_REF", env),
+ githubWorkflowRef: getEnv("GITHUB_WORKFLOW_REF", env),
+ };
+}
diff --git a/src/git/git.ts b/src/git/git.ts
new file mode 100644
index 0000000..32eb3eb
--- /dev/null
+++ b/src/git/git.ts
@@ -0,0 +1,294 @@
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+
+export type GitCommandResult = {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+};
+
+export type GitOptions = {
+ cwd?: string;
+};
+
+export function execGit(
+ args: string[],
+ options: GitOptions = {},
+): GitCommandResult {
+ const result = spawnSync("git", args, {
+ cwd: options.cwd,
+ encoding: "utf8",
+ });
+
+ if (result.error) {
+ throw new Error(
+ `Failed to execute "git ${args.join(" ")}": ${result.error.message}`,
+ );
+ }
+
+ return {
+ stdout: result.stdout ?? "",
+ stderr: result.stderr ?? "",
+ exitCode: result.status ?? 1,
+ };
+}
+
+export function requireGit(args: string[], options: GitOptions = {}): string {
+ const result = execGit(args, options);
+
+ if (result.exitCode !== 0) {
+ throw new Error(
+ [
+ `git ${args.join(" ")} failed with exit code ${result.exitCode}.`,
+ `stdout: ${result.stdout.trim()}`,
+ `stderr: ${result.stderr.trim()}`,
+ ].join("\n"),
+ );
+ }
+
+ return result.stdout.trim();
+}
+
+export interface GitPort {
+ configSafeDirectory(workspace: string): void;
+ configUser(actor: string): void;
+ configGitHttpAuth(githubServerUrl: string, token: string): void;
+ fetchBranches(options?: GitOptions): void;
+ fetchTags(options?: GitOptions): void;
+ fetchBranchesAndTags(options?: GitOptions): void;
+ fetchTargetBranch(targetBranch: string, options?: GitOptions): void;
+ tagList(options?: GitOptions): string[];
+ listTagsSortedByVersionDescending(options?: GitOptions): string[];
+ revParse(ref: string, options?: GitOptions): string | undefined;
+ tagExists(tag: string, options?: GitOptions): boolean;
+ getHeadSha(options?: GitOptions): string;
+ gitLog(
+ range: string,
+ format: string,
+ maxCount: number,
+ options?: GitOptions,
+ ): string;
+ hasDiffAgainstRef(
+ ref: string,
+ filePath: string,
+ options?: GitOptions,
+ ): boolean;
+ hasUnstagedChanges(filePath: string, options?: GitOptions): boolean;
+ pullTargetBranch(targetBranch: string, options?: GitOptions): void;
+ checkoutBranchFromTarget(
+ branchName: string,
+ targetBranch: string,
+ options?: GitOptions,
+ ): void;
+ checkoutExistingBranch(branchName: string, options?: GitOptions): void;
+ branchExistsRemote(branchName: string, options?: GitOptions): boolean;
+ add(filePath: string, options?: GitOptions): void;
+ commit(message: string, options?: GitOptions): void;
+ pushBranch(branchName: string, options?: GitOptions): void;
+ cloneWorkspace(source: string, target: string): void;
+}
+
+export function configSafeDirectory(workspace: string): void {
+ requireGit(["config", "--global", "--add", "safe.directory", workspace]);
+}
+
+export function configUser(actor: string): void {
+ requireGit(["config", "--global", "user.name", actor]);
+ requireGit([
+ "config",
+ "--global",
+ "user.email",
+ `${actor}@users.noreply.github.com`,
+ ]);
+}
+
+export function configGitHttpAuth(
+ githubServerUrl: string,
+ token: string,
+): void {
+ const host = githubServerUrl.replace(/^https?:\/\//, "");
+ requireGit([
+ "config",
+ "--global",
+ `url.https://x-access-token:${token}@${host}/.insteadOf`,
+ `https://${host}/`,
+ ]);
+}
+
+export function fetchBranches(options: GitOptions = {}): void {
+ requireGit(
+ ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"],
+ options,
+ );
+}
+
+export function fetchTags(options: GitOptions = {}): void {
+ requireGit(
+ ["fetch", "--prune", "--prune-tags", "origin", "+refs/tags/*:refs/tags/*"],
+ options,
+ );
+}
+
+export function fetchBranchesAndTags(options: GitOptions = {}): void {
+ fetchBranches(options);
+ fetchTags(options);
+}
+
+export function fetchTargetBranch(
+ targetBranch: string,
+ options: GitOptions = {},
+): void {
+ requireGit(["fetch", "origin", targetBranch], options);
+}
+
+export function tagList(options: GitOptions = {}): string[] {
+ const output = requireGit(["tag", "--list"], options);
+ return output.split("\n").filter((line) => line.length > 0);
+}
+
+export function listTagsSortedByVersionDescending(
+ options: GitOptions = {},
+): string[] {
+ const output = requireGit(
+ ["tag", "--list", "--sort=-version:refname"],
+ options,
+ );
+ return output.split("\n").filter((line) => line.length > 0);
+}
+
+export function revParse(
+ ref: string,
+ options: GitOptions = {},
+): string | undefined {
+ const result = execGit(["rev-parse", "--verify", ref], options);
+
+ if (result.exitCode !== 0) {
+ return undefined;
+ }
+
+ return result.stdout.trim();
+}
+
+export function tagExists(tag: string, options: GitOptions = {}): boolean {
+ return revParse(`refs/tags/${tag}`, options) !== undefined;
+}
+
+export function getHeadSha(options: GitOptions = {}): string {
+ return requireGit(["rev-parse", "HEAD"], options);
+}
+
+export function gitLog(
+ range: string,
+ format: string,
+ maxCount: number,
+ options: GitOptions = {},
+): string {
+ const result = execGit(
+ ["log", range, `--max-count=${maxCount}`, `--pretty=format:${format}`],
+ options,
+ );
+
+ if (result.exitCode !== 0) {
+ throw new Error(
+ [
+ `git log ${range} failed with exit code ${result.exitCode}.`,
+ `stderr: ${result.stderr.trim()}`,
+ ].join("\n"),
+ );
+ }
+
+ return result.stdout;
+}
+
+export function hasDiffAgainstRef(
+ ref: string,
+ filePath: string,
+ options: GitOptions = {},
+): boolean {
+ const result = execGit(["diff", "--quiet", ref, "--", filePath], options);
+ return result.exitCode !== 0;
+}
+
+export function hasUnstagedChanges(
+ filePath: string,
+ options: GitOptions = {},
+): boolean {
+ const result = execGit(["diff", "--quiet", "--", filePath], options);
+ return result.exitCode !== 0;
+}
+
+export function pullTargetBranch(
+ targetBranch: string,
+ options: GitOptions = {},
+): void {
+ requireGit(["pull", "origin", targetBranch], options);
+}
+
+export function checkoutBranchFromTarget(
+ branchName: string,
+ targetBranch: string,
+ options: GitOptions = {},
+): void {
+ requireGit(["checkout", "-b", branchName, `origin/${targetBranch}`], options);
+}
+
+export function checkoutExistingBranch(
+ branchName: string,
+ options: GitOptions = {},
+): void {
+ // The local clone may not have a ref for this branch at all (e.g. a fresh
+ // workspace copy that only ever fetched the target branch), so fetch it
+ // explicitly before checking it out instead of assuming a local/remote-
+ // tracking ref already exists.
+ requireGit(
+ ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`],
+ options,
+ );
+ // `checkout -B` creates the local branch if missing, or resets it to match
+ // origin if it already exists — avoids rebase conflicts in the ephemeral
+ // temp-dir clone this is always run against.
+ requireGit(["checkout", "-B", branchName, `origin/${branchName}`], options);
+}
+
+export function branchExistsRemote(
+ branchName: string,
+ options: GitOptions = {},
+): boolean {
+ const result = execGit(
+ ["ls-remote", "--exit-code", "--heads", "origin", branchName],
+ options,
+ );
+ return result.exitCode === 0;
+}
+
+export function add(filePath: string, options: GitOptions = {}): void {
+ requireGit(["add", "--", filePath], options);
+}
+
+export function commit(message: string, options: GitOptions = {}): void {
+ const result = execGit(["commit", "-m", message], options);
+
+ if (result.exitCode !== 0) {
+ const combinedOutput = `${result.stdout}\n${result.stderr}`;
+
+ if (/nothing to commit/i.test(combinedOutput)) {
+ return;
+ }
+
+ throw new Error(
+ [
+ `git commit failed with exit code ${result.exitCode}.`,
+ `stdout: ${result.stdout.trim()}`,
+ `stderr: ${result.stderr.trim()}`,
+ ].join("\n"),
+ );
+ }
+}
+
+export function pushBranch(branchName: string, options: GitOptions = {}): void {
+ requireGit(["push", "origin", branchName], options);
+}
+
+export function cloneWorkspace(source: string, target: string): void {
+ fs.cpSync(source, target, { recursive: true });
+}
diff --git a/src/github/client.ts b/src/github/client.ts
new file mode 100644
index 0000000..c50c486
--- /dev/null
+++ b/src/github/client.ts
@@ -0,0 +1,345 @@
+import { getOctokit } from "@actions/github";
+
+export type ReleaseInfo = { id: number };
+
+export type CreateReleaseInput = {
+ owner: string;
+ repo: string;
+ tag_name: string;
+ target_commitish: string;
+ name: string;
+ body: string;
+ draft: boolean;
+ prerelease: boolean;
+};
+
+export type CreateReleaseResult = { html_url: string };
+
+export type PullRequestSummary = {
+ number: number;
+ title: string;
+ html_url: string;
+ user: { login: string } | null;
+};
+
+export type CreatePullRequestInput = {
+ owner: string;
+ repo: string;
+ title: string;
+ head: string;
+ base: string;
+ body: string;
+};
+
+export type PullRequestRef = {
+ html_url: string;
+ head: { sha: string };
+};
+
+export type WorkflowRun = {
+ id: number;
+ head_sha: string;
+ head_branch: string | null;
+ status: string;
+ conclusion: string | null;
+ created_at: string;
+ html_url: string;
+};
+
+export type WorkflowJob = {
+ name: string;
+ conclusion: string | null;
+ html_url: string | null;
+};
+
+export type CheckRunConclusion =
+ | "success"
+ | "failure"
+ | "neutral"
+ | "cancelled"
+ | "skipped"
+ | "timed_out"
+ | "action_required";
+
+export type CreateCheckRunInput = {
+ name: string;
+ head_sha: string;
+ conclusion: CheckRunConclusion;
+ details_url?: string;
+ summary: string;
+};
+
+export class GitHubApiError extends Error {
+ public readonly status?: number;
+
+ public constructor(message: string, status?: number) {
+ super(message);
+ this.name = "GitHubApiError";
+ this.status = status;
+ }
+}
+
+export interface GitHubClient {
+ getReleaseByTag(
+ owner: string,
+ repo: string,
+ tag: string,
+ ): Promise;
+ createRelease(input: CreateReleaseInput): Promise;
+ getCommit(
+ owner: string,
+ repo: string,
+ sha: string,
+ ): Promise<{ login?: string }>;
+ listPullRequestsAssociatedWithCommit(
+ owner: string,
+ repo: string,
+ sha: string,
+ ): Promise;
+ createPullRequest(input: CreatePullRequestInput): Promise;
+ findOpenPullRequestByHead(
+ owner: string,
+ repo: string,
+ headOwnerColonBranch: string,
+ base: string,
+ ): Promise;
+ createWorkflowDispatch(
+ owner: string,
+ repo: string,
+ workflowFileName: string,
+ ref: string,
+ ): Promise;
+ listWorkflowRuns(
+ owner: string,
+ repo: string,
+ workflowFileName: string,
+ params: { branch: string; event: string; perPage: number },
+ ): Promise;
+ getWorkflowRun(
+ owner: string,
+ repo: string,
+ runId: number,
+ ): Promise<{ status: string; conclusion: string | null }>;
+ listJobsForWorkflowRun(
+ owner: string,
+ repo: string,
+ runId: number,
+ ): Promise;
+ createCheckRun(
+ owner: string,
+ repo: string,
+ input: CreateCheckRunInput,
+ ): Promise;
+}
+
+function getErrorStatus(error: unknown): number | undefined {
+ if (typeof error === "object" && error !== null && "status" in error) {
+ const status = error.status;
+
+ if (typeof status === "number") {
+ return status;
+ }
+ }
+
+ return undefined;
+}
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : "Unknown GitHub API error.";
+}
+
+export type GitHubClientConfig = {
+ githubToken: string;
+ githubApiUrl: string;
+};
+
+export function createGitHubClient(config: GitHubClientConfig): GitHubClient {
+ const octokit = getOctokit(config.githubToken, {
+ baseUrl: config.githubApiUrl,
+ });
+
+ return {
+ async getReleaseByTag(owner, repo, tag): Promise {
+ try {
+ const response = await octokit.rest.repos.getReleaseByTag({
+ owner,
+ repo,
+ tag,
+ });
+ return { id: response.data.id };
+ } catch (error: unknown) {
+ if (getErrorStatus(error) === 404) {
+ return undefined;
+ }
+ throw error;
+ }
+ },
+
+ async createRelease(input): Promise {
+ const response = await octokit.rest.repos.createRelease(input);
+ const htmlUrl: string | undefined = response.data.html_url;
+
+ if (htmlUrl === undefined || htmlUrl === "") {
+ throw new GitHubApiError("Release response is missing html_url.");
+ }
+
+ return { html_url: htmlUrl };
+ },
+
+ async getCommit(owner, repo, sha): Promise<{ login?: string }> {
+ const response = await octokit.rest.repos.getCommit({
+ owner,
+ repo,
+ ref: sha,
+ });
+ return { login: response.data.author?.login ?? undefined };
+ },
+
+ async listPullRequestsAssociatedWithCommit(
+ owner,
+ repo,
+ sha,
+ ): Promise {
+ const response =
+ await octokit.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner,
+ repo,
+ commit_sha: sha,
+ });
+
+ return response.data.map((pullRequest) => ({
+ number: pullRequest.number,
+ title: pullRequest.title,
+ html_url: pullRequest.html_url,
+ user: pullRequest.user ? { login: pullRequest.user.login } : null,
+ }));
+ },
+
+ async createPullRequest(input): Promise {
+ try {
+ const response = await octokit.rest.pulls.create(input);
+ return {
+ html_url: response.data.html_url,
+ head: { sha: response.data.head.sha },
+ };
+ } catch (error: unknown) {
+ throw new GitHubApiError(getErrorMessage(error), getErrorStatus(error));
+ }
+ },
+
+ async findOpenPullRequestByHead(
+ owner,
+ repo,
+ headOwnerColonBranch,
+ base,
+ ): Promise {
+ const response = await octokit.rest.pulls.list({
+ owner,
+ repo,
+ head: headOwnerColonBranch,
+ base,
+ state: "open",
+ per_page: 1,
+ });
+
+ const pullRequest = response.data[0];
+
+ if (!pullRequest) {
+ return undefined;
+ }
+
+ return {
+ html_url: pullRequest.html_url,
+ head: { sha: pullRequest.head.sha },
+ };
+ },
+
+ async createWorkflowDispatch(
+ owner,
+ repo,
+ workflowFileName,
+ ref,
+ ): Promise {
+ await octokit.rest.actions.createWorkflowDispatch({
+ owner,
+ repo,
+ workflow_id: workflowFileName,
+ ref,
+ });
+ },
+
+ async listWorkflowRuns(
+ owner,
+ repo,
+ workflowFileName,
+ params,
+ ): Promise {
+ const response = await octokit.rest.actions.listWorkflowRuns({
+ owner,
+ repo,
+ workflow_id: workflowFileName,
+ branch: params.branch,
+ event: params.event,
+ per_page: params.perPage,
+ });
+
+ return response.data.workflow_runs.map((run) => ({
+ id: run.id,
+ head_sha: run.head_sha,
+ head_branch: run.head_branch,
+ status: run.status ?? "",
+ conclusion: run.conclusion,
+ created_at: run.created_at,
+ html_url: run.html_url,
+ }));
+ },
+
+ async getWorkflowRun(
+ owner,
+ repo,
+ runId,
+ ): Promise<{ status: string; conclusion: string | null }> {
+ const response = await octokit.rest.actions.getWorkflowRun({
+ owner,
+ repo,
+ run_id: runId,
+ });
+
+ return {
+ status: response.data.status ?? "",
+ conclusion: response.data.conclusion,
+ };
+ },
+
+ async listJobsForWorkflowRun(owner, repo, runId): Promise {
+ const response = await octokit.rest.actions.listJobsForWorkflowRun({
+ owner,
+ repo,
+ run_id: runId,
+ per_page: 100,
+ });
+
+ return response.data.jobs.map((job) => ({
+ name: job.name,
+ conclusion: job.conclusion,
+ html_url: job.html_url,
+ }));
+ },
+
+ async createCheckRun(owner, repo, input): Promise {
+ await octokit.rest.checks.create({
+ owner,
+ repo,
+ name: input.name,
+ head_sha: input.head_sha,
+ status: "completed",
+ conclusion: input.conclusion,
+ details_url: input.details_url,
+ output: {
+ title: input.name,
+ summary: input.summary,
+ },
+ });
+ },
+ };
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..9e4c318
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,31 @@
+import { readActionConfig } from "./config.js";
+import { exportInputState } from "./release/actionState.js";
+import { runPipeline } from "./release/pipeline.js";
+import { addMask, error, info } from "./utils/log.js";
+
+export async function main(): Promise {
+ info("GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1");
+
+ if (process.argv.slice(2).includes("--smoke-test")) {
+ info("TypeScript Docker runtime smoke test passed.");
+ return;
+ }
+
+ const config = readActionConfig();
+ addMask(config.githubToken);
+ exportInputState(config);
+
+ await runPipeline(config);
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ main().catch((caughtError: unknown) => {
+ const message =
+ caughtError instanceof Error
+ ? caughtError.message
+ : "An unknown error occurred.";
+
+ error(message);
+ process.exit(1);
+ });
+}
diff --git a/src/release/actionState.ts b/src/release/actionState.ts
new file mode 100644
index 0000000..5599e59
--- /dev/null
+++ b/src/release/actionState.ts
@@ -0,0 +1,48 @@
+import type { ActionConfig, CiWorkflowsConfig } from "../config.js";
+import { exportEnv } from "../utils/env.js";
+import type { ChangelogPrResult } from "./createChangelogPr.js";
+import type { ReleaseSetup } from "./setupRelease.js";
+
+function serializeCiWorkflows(config: CiWorkflowsConfig): string {
+ switch (config.mode) {
+ case "auto":
+ return "auto";
+ case "disabled":
+ return "none";
+ case "explicit":
+ return config.workflows.join(",");
+ }
+}
+
+export function exportInputState(config: ActionConfig): void {
+ exportEnv("DRY_RUN", String(config.dryRun));
+ exportEnv("CHANGELOG_FILE_PATH", config.changelogFilePath);
+ exportEnv("TAG_TEMPLATE", config.tagTemplate);
+ exportEnv("RELEASE_DRAFT", String(config.releaseDraft));
+ exportEnv("RELEASE_PRERELEASE", String(config.releasePrerelease));
+ exportEnv("RELEASE_TITLE_PREFIX", config.releaseTitlePrefix);
+ exportEnv("VERSION_OVERRIDE", config.versionOverride ?? "");
+ exportEnv("CI_WORKFLOWS", serializeCiWorkflows(config.ciWorkflows));
+}
+
+export function exportSetupState(setup: ReleaseSetup): void {
+ exportEnv("VERSION", setup.version);
+ exportEnv("TAG", setup.tag);
+ exportEnv("RELEASE_TITLE", setup.releaseTitle);
+ exportEnv("TAG_EXISTS", String(setup.tagExists));
+ exportEnv("RELEASE_EXISTS", String(setup.releaseExists));
+ exportEnv("TARGET_BRANCH", setup.targetBranch);
+
+ if (setup.latestTag !== undefined) {
+ exportEnv("LATEST_TAG", setup.latestTag);
+ }
+}
+
+export function exportChangelogState(updated: boolean): void {
+ exportEnv("CHANGELOG_UPDATED", String(updated));
+}
+
+export function exportPrState(result: ChangelogPrResult): void {
+ exportEnv("PR_URL", result.prUrl);
+ exportEnv("CHANGELOG_PR_HEAD_SHA", result.headSha);
+}
diff --git a/src/release/collectCommits.ts b/src/release/collectCommits.ts
new file mode 100644
index 0000000..d5fc29d
--- /dev/null
+++ b/src/release/collectCommits.ts
@@ -0,0 +1,297 @@
+import type { ActionConfig } from "../config.js";
+import type { GitOptions, GitPort } from "../git/git.js";
+import type { GitHubClient, PullRequestSummary } from "../github/client.js";
+import { warning } from "../utils/log.js";
+import { parseRepositoryCoordinates } from "../utils/repository.js";
+import type { ReleaseSetup } from "./setupRelease.js";
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : "Unknown error";
+}
+
+export type CollectedReleaseData = {
+ commitLogLines: string[];
+ fullChangelogLine?: string;
+};
+
+const FIELD_SEPARATOR = "\x1f";
+const MAX_COMMITS = 30;
+
+type SemverTuple = [number, number, number];
+
+function extractSemver(tag: string): SemverTuple | undefined {
+ const match = /^[^0-9]*(\d+)\.(\d+)\.(\d+)/.exec(tag);
+
+ if (!match) {
+ return undefined;
+ }
+
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
+}
+
+function compareSemver(a: SemverTuple, b: SemverTuple): number {
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
+}
+
+function findPrevAndNextSemverTags(
+ existingTags: string[],
+ targetTag: string,
+): { prevSemverTag?: string; nextSemverTag?: string } {
+ const entries: { tag: string; semver: SemverTuple }[] = [];
+
+ for (const tag of existingTags) {
+ const semver = extractSemver(tag);
+ if (semver) {
+ entries.push({ tag, semver });
+ }
+ }
+
+ if (!existingTags.includes(targetTag)) {
+ const semver = extractSemver(targetTag);
+ if (semver) {
+ entries.push({ tag: targetTag, semver });
+ }
+ }
+
+ entries.sort((a, b) => compareSemver(a.semver, b.semver));
+
+ let prevSemverTag: string | undefined;
+ let nextSemverTag: string | undefined;
+ let found = false;
+
+ for (const entry of entries) {
+ if (entry.tag === targetTag) {
+ found = true;
+ continue;
+ }
+
+ if (!found) {
+ prevSemverTag = entry.tag;
+ } else if (nextSemverTag === undefined) {
+ nextSemverTag = entry.tag;
+ break;
+ }
+ }
+
+ return { prevSemverTag, nextSemverTag };
+}
+
+function computeCommitRange(
+ setup: ReleaseSetup,
+ prevSemverTag: string | undefined,
+ nextSemverTag: string | undefined,
+): string {
+ if (setup.tagExists) {
+ return prevSemverTag ? `${prevSemverTag}..${setup.tag}` : setup.tag;
+ }
+
+ if (prevSemverTag && nextSemverTag) {
+ return `${prevSemverTag}..${nextSemverTag}`;
+ }
+
+ if (prevSemverTag) {
+ return `${prevSemverTag}..HEAD`;
+ }
+
+ return "HEAD";
+}
+
+type ParsedCommit = {
+ sha: string;
+ shortSha: string;
+ authorName: string;
+ authorEmail: string;
+ subject: string;
+};
+
+function parseCommitLines(rawLog: string): ParsedCommit[] {
+ const commits: ParsedCommit[] = [];
+
+ for (const line of rawLog.split("\n")) {
+ if (line.length === 0) {
+ continue;
+ }
+
+ const [sha, shortSha, authorName, authorEmail, ...subjectParts] =
+ line.split(FIELD_SEPARATOR);
+
+ if (
+ sha === undefined ||
+ sha === "" ||
+ shortSha === undefined ||
+ authorName === undefined ||
+ authorEmail === undefined
+ ) {
+ continue;
+ }
+
+ commits.push({
+ sha,
+ shortSha,
+ authorName,
+ authorEmail,
+ subject: subjectParts.join(FIELD_SEPARATOR),
+ });
+ }
+
+ return commits;
+}
+
+type ParsedPrReference = {
+ prNumber?: string;
+ prTitle?: string;
+ isMergeCommit: boolean;
+};
+
+function parsePrReferenceFromSubject(subject: string): ParsedPrReference {
+ const mergeMatch = /[Mm]erge pull request #(\d+)/.exec(subject);
+
+ if (mergeMatch) {
+ return { prNumber: mergeMatch[1], isMergeCommit: true };
+ }
+
+ const inlineMatch = /\(#(\d+)\)/.exec(subject);
+
+ if (inlineMatch) {
+ return { prNumber: inlineMatch[1], isMergeCommit: false };
+ }
+
+ return { isMergeCommit: false };
+}
+
+async function buildCommitLine(
+ commit: ParsedCommit,
+ config: ActionConfig,
+ client: GitHubClient,
+ seenPrNumbers: Set,
+): Promise {
+ const { owner, repo } = parseRepositoryCoordinates(config.githubRepository);
+ const commitUrl = `${config.githubServerUrl}/${config.githubRepository}/commit/${commit.sha}`;
+
+ let commitLogin: string | undefined;
+
+ try {
+ const commitInfo = await client.getCommit(owner, repo, commit.sha);
+ commitLogin = commitInfo.login;
+ } catch (error: unknown) {
+ warning(
+ `Failed to resolve GitHub user for commit ${commit.sha}: ${getErrorMessage(error)}`,
+ );
+ }
+
+ let pullRequests: PullRequestSummary[] = [];
+
+ try {
+ pullRequests = await client.listPullRequestsAssociatedWithCommit(
+ owner,
+ repo,
+ commit.sha,
+ );
+ } catch (error: unknown) {
+ warning(
+ `Failed to resolve pull request for commit ${commit.sha}: ${getErrorMessage(error)}`,
+ );
+ }
+
+ const firstPullRequest = pullRequests[0];
+
+ let prNumber: string | undefined;
+ let prTitle: string | undefined;
+ let prUrl: string | undefined;
+ let prUserLogin: string | undefined;
+
+ if (firstPullRequest) {
+ prNumber = String(firstPullRequest.number);
+ prTitle = firstPullRequest.title;
+ prUrl = firstPullRequest.html_url;
+ prUserLogin = firstPullRequest.user?.login;
+ } else {
+ const parsedPr = parsePrReferenceFromSubject(commit.subject);
+
+ if (parsedPr.prNumber) {
+ prNumber = parsedPr.prNumber;
+ prUrl = `${config.githubServerUrl}/${config.githubRepository}/pull/${parsedPr.prNumber}`;
+ prTitle = parsedPr.isMergeCommit
+ ? `Pull request #${parsedPr.prNumber}`
+ : commit.subject;
+ }
+ }
+
+ const resolvedLogin =
+ commitLogin !== undefined && commitLogin !== "" ? commitLogin : prUserLogin;
+ const isBot =
+ commit.authorEmail.includes("[bot]") ||
+ (resolvedLogin !== undefined && resolvedLogin.includes("[bot]"));
+ const hasEligibleLogin =
+ resolvedLogin !== undefined && resolvedLogin !== "" && !isBot;
+
+ if (prNumber !== undefined && prUrl !== undefined) {
+ if (seenPrNumbers.has(prNumber)) {
+ return undefined;
+ }
+ seenPrNumbers.add(prNumber);
+
+ const title =
+ prTitle !== undefined && prTitle !== "" ? prTitle : commit.subject;
+
+ return hasEligibleLogin
+ ? `* ${title} by @${resolvedLogin} in [#${prNumber}](${prUrl})`
+ : `* ${title} in [#${prNumber}](${prUrl})`;
+ }
+
+ return hasEligibleLogin
+ ? `* ${commit.subject} by @${resolvedLogin} in [${commit.shortSha}](${commitUrl})`
+ : `* ${commit.subject} by ${commit.authorName} in [${commit.shortSha}](${commitUrl})`;
+}
+
+export async function collectCommits(
+ config: ActionConfig,
+ setup: ReleaseSetup,
+ git: GitPort,
+ client: GitHubClient,
+): Promise {
+ const gitOptions: GitOptions = { cwd: config.githubWorkspace };
+
+ if (!config.dryRun) {
+ git.fetchBranchesAndTags(gitOptions);
+ }
+
+ const existingTags = git.tagList(gitOptions);
+ const { prevSemverTag, nextSemverTag } = findPrevAndNextSemverTags(
+ existingTags,
+ setup.tag,
+ );
+ const commitRange = computeCommitRange(setup, prevSemverTag, nextSemverTag);
+
+ const fullChangelogLine = prevSemverTag
+ ? `**Full Changelog**: [${prevSemverTag}...${setup.tag}](${config.githubServerUrl}/${config.githubRepository}/compare/${prevSemverTag}...${setup.tag})`
+ : undefined;
+
+ const rawLog = git.gitLog(
+ commitRange,
+ `%H${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%s`,
+ MAX_COMMITS,
+ gitOptions,
+ );
+ const commits = parseCommitLines(rawLog);
+
+ if (commits.length === 0) {
+ return {
+ commitLogLines: ["* No changes since last release."],
+ fullChangelogLine,
+ };
+ }
+
+ const seenPrNumbers = new Set();
+ const commitLogLines: string[] = [];
+
+ for (const commit of commits) {
+ const line = await buildCommitLine(commit, config, client, seenPrNumbers);
+
+ if (line !== undefined) {
+ commitLogLines.push(line);
+ }
+ }
+
+ return { commitLogLines, fullChangelogLine };
+}
diff --git a/src/release/createChangelogPr.ts b/src/release/createChangelogPr.ts
new file mode 100644
index 0000000..a20f57d
--- /dev/null
+++ b/src/release/createChangelogPr.ts
@@ -0,0 +1,413 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import type { ActionConfig } from "../config.js";
+import type { GitOptions, GitPort } from "../git/git.js";
+import {
+ GitHubApiError,
+ type CheckRunConclusion,
+ type GitHubClient,
+} from "../github/client.js";
+import {
+ createTempDirectory,
+ removeDirectory,
+ writeTextFile,
+} from "../utils/files.js";
+import { info, warning } from "../utils/log.js";
+import { parseRepositoryCoordinates } from "../utils/repository.js";
+import { retryUntil } from "../utils/retry.js";
+import type { ReleaseSetup } from "./setupRelease.js";
+import type { ChangelogResult } from "./updateChangelog.js";
+
+export type ChangelogPrResult = {
+ prUrl: string;
+ headSha: string;
+};
+
+const RUN_LOOKUP_MAX_ATTEMPTS = 60;
+const RUN_LOOKUP_INTERVAL_MS = 5000;
+const RUN_WAIT_MAX_ATTEMPTS = 120;
+const RUN_WAIT_INTERVAL_MS = 10000;
+
+const VALID_CHECK_CONCLUSIONS: ReadonlySet = new Set([
+ "success",
+ "failure",
+ "neutral",
+ "cancelled",
+ "skipped",
+ "timed_out",
+ "action_required",
+]);
+
+function mapJobConclusionToCheckConclusion(
+ conclusion: string | null,
+): CheckRunConclusion {
+ if (conclusion !== null && VALID_CHECK_CONCLUSIONS.has(conclusion)) {
+ return conclusion as CheckRunConclusion;
+ }
+
+ return "failure";
+}
+
+function getCurrentReleaseWorkflowPath(
+ githubWorkflowRef: string | undefined,
+): string | undefined {
+ if (githubWorkflowRef === undefined) {
+ return undefined;
+ }
+
+ const match = /^[^/]*\/[^/]*\/(\.github\/workflows\/[^@]*)@/.exec(
+ githubWorkflowRef,
+ );
+
+ return match?.[1];
+}
+
+function workflowSupportsAutoDispatch(content: string): boolean {
+ const nonCommentContent = content
+ .split("\n")
+ .filter((line) => !/^\s*#/.test(line))
+ .join("\n");
+
+ return /(^|[^_a-zA-Z0-9-])workflow_dispatch([^_a-zA-Z0-9-]|$)/m.test(
+ nonCommentContent,
+ );
+}
+
+function resolveCiWorkflows(
+ config: ActionConfig,
+ workflowsDir: string,
+): string[] {
+ if (config.ciWorkflows.mode === "disabled") {
+ return [];
+ }
+
+ if (config.ciWorkflows.mode === "explicit") {
+ return [
+ ...new Set(
+ config.ciWorkflows.workflows.map((workflow) => path.basename(workflow)),
+ ),
+ ].sort();
+ }
+
+ const currentReleaseWorkflowPath = getCurrentReleaseWorkflowPath(
+ config.githubWorkflowRef,
+ );
+ const discovered: string[] = [];
+
+ if (fs.existsSync(workflowsDir)) {
+ for (const entry of fs.readdirSync(workflowsDir)) {
+ if (!entry.endsWith(".yml") && !entry.endsWith(".yaml")) {
+ continue;
+ }
+
+ const relativePath = `.github/workflows/${entry}`;
+
+ if (
+ currentReleaseWorkflowPath !== undefined &&
+ relativePath === currentReleaseWorkflowPath
+ ) {
+ info(`Skipping release workflow itself: ${relativePath}`);
+ continue;
+ }
+
+ const content = fs.readFileSync(path.join(workflowsDir, entry), "utf8");
+
+ if (workflowSupportsAutoDispatch(content)) {
+ discovered.push(entry);
+ }
+ }
+ }
+
+ return [...new Set(discovered)].sort();
+}
+
+function findDispatchedWorkflowRunId(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ workflowFile: string,
+ branchRef: string,
+ headSha: string,
+ dispatchStartedAt: string,
+): Promise {
+ return retryUntil(
+ async () => {
+ const runs = await client.listWorkflowRuns(owner, repo, workflowFile, {
+ branch: branchRef,
+ event: "workflow_dispatch",
+ perPage: 20,
+ });
+
+ const matchBySha = runs.find((run) => run.head_sha === headSha);
+
+ if (matchBySha) {
+ return matchBySha.id;
+ }
+
+ const matchByBranchAndTime = runs.find(
+ (run) =>
+ run.head_branch === branchRef && run.created_at >= dispatchStartedAt,
+ );
+
+ return matchByBranchAndTime?.id;
+ },
+ {
+ maxAttempts: RUN_LOOKUP_MAX_ATTEMPTS,
+ intervalMs: RUN_LOOKUP_INTERVAL_MS,
+ description: `dispatched workflow run for '${workflowFile}' on branch '${branchRef}' (SHA '${headSha}')`,
+ },
+ );
+}
+
+async function waitForWorkflowRunCompletion(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ runId: number,
+): Promise {
+ await retryUntil(
+ async () => {
+ const run = await client.getWorkflowRun(owner, repo, runId);
+ info(
+ `Workflow run ${runId} status: ${run.status} conclusion: ${
+ run.conclusion ?? "none"
+ }`,
+ );
+ return run.status === "completed" ? true : undefined;
+ },
+ {
+ maxAttempts: RUN_WAIT_MAX_ATTEMPTS,
+ intervalMs: RUN_WAIT_INTERVAL_MS,
+ description: `workflow run ${runId} to complete`,
+ },
+ );
+}
+
+async function mirrorWorkflowJobsAsCheckRuns(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ runId: number,
+ headSha: string,
+): Promise {
+ const jobs = await client.listJobsForWorkflowRun(owner, repo, runId);
+
+ if (jobs.length === 0) {
+ throw new Error(
+ `Workflow run ${runId} has no jobs. Cannot mirror required checks.`,
+ );
+ }
+
+ for (const job of jobs) {
+ const checkConclusion = mapJobConclusionToCheckConclusion(job.conclusion);
+
+ await client.createCheckRun(owner, repo, {
+ name: job.name,
+ head_sha: headSha,
+ conclusion: checkConclusion,
+ details_url: job.html_url ?? undefined,
+ summary: `Mirrored result from dispatched workflow run ${runId}.`,
+ });
+
+ info(
+ `Created check run '${job.name}' with conclusion '${checkConclusion}' for ${headSha}.`,
+ );
+
+ if (
+ checkConclusion !== "success" &&
+ checkConclusion !== "neutral" &&
+ checkConclusion !== "skipped"
+ ) {
+ throw new Error(
+ `Dispatched CI job '${job.name}' finished with conclusion '${
+ job.conclusion ?? "failure"
+ }'.`,
+ );
+ }
+ }
+}
+
+async function dispatchConfiguredCiWorkflows(
+ config: ActionConfig,
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ branchName: string,
+ headSha: string,
+ tempDir: string,
+): Promise {
+ const workflows = resolveCiWorkflows(
+ config,
+ path.join(tempDir, ".github", "workflows"),
+ );
+
+ if (workflows.length === 0) {
+ info("No CI workflows configured or discovered for dispatch.");
+ return;
+ }
+
+ info(`Dispatching CI workflows for branch: ${branchName}`);
+ info(
+ `Mirroring dispatched CI jobs as check runs for PR head SHA: ${headSha}`,
+ );
+
+ for (const workflowFile of workflows) {
+ info(`Dispatching workflow: ${workflowFile}`);
+ const dispatchStartedAt = new Date().toISOString();
+
+ await client.createWorkflowDispatch(owner, repo, workflowFile, branchName);
+ info(`Workflow dispatched successfully: ${workflowFile}`);
+
+ const runId = await findDispatchedWorkflowRunId(
+ client,
+ owner,
+ repo,
+ workflowFile,
+ branchName,
+ headSha,
+ dispatchStartedAt,
+ );
+ info(`Found dispatched workflow run: ${runId}`);
+
+ await waitForWorkflowRunCompletion(client, owner, repo, runId);
+ await mirrorWorkflowJobsAsCheckRuns(client, owner, repo, runId, headSha);
+ }
+}
+
+export async function createChangelogPr(
+ config: ActionConfig,
+ setup: ReleaseSetup,
+ changelog: ChangelogResult,
+ git: GitPort,
+ client: GitHubClient,
+): Promise {
+ const branchName = `release-changelog-update/${setup.version}`;
+ const tempDir = createTempDirectory("github-release-changelog-pr-");
+ const tempOptions: GitOptions = { cwd: tempDir };
+ const { owner, repo } = parseRepositoryCoordinates(config.githubRepository);
+
+ try {
+ info(`Cloning workspace to temporary directory: ${tempDir}`);
+ git.cloneWorkspace(config.githubWorkspace, tempDir);
+
+ git.fetchTargetBranch(setup.targetBranch, tempOptions);
+
+ if (git.branchExistsRemote(branchName, tempOptions)) {
+ git.checkoutExistingBranch(branchName, tempOptions);
+ } else {
+ info(`Creating new branch: ${branchName}`);
+
+ if (config.dryRun) {
+ info(`Dry-Run: Skipping 'git checkout -b ${branchName}'.`);
+ } else {
+ git.checkoutBranchFromTarget(
+ branchName,
+ setup.targetBranch,
+ tempOptions,
+ );
+ }
+ }
+
+ if (config.dryRun) {
+ info("Dry-Run: Skipping 'git add' and 'git commit'.");
+ } else {
+ if (changelog.changelogFileContent !== undefined) {
+ writeTextFile(
+ path.join(tempDir, config.changelogFilePath),
+ changelog.changelogFileContent,
+ );
+ }
+
+ git.add(config.changelogFilePath, tempOptions);
+ git.commit(
+ `chore: update changelog for version ${setup.version}`,
+ tempOptions,
+ );
+ }
+
+ const branchHeadSha = git.getHeadSha(tempOptions);
+ info(`Local branch HEAD SHA (before push): ${branchHeadSha}`);
+
+ let prUrl: string;
+ let headSha: string;
+
+ if (config.dryRun) {
+ info(`Dry-Run: Skipping 'git push origin ${branchName}'.`);
+ info("Dry-Run: Skipping PR creation.");
+ prUrl = `${config.githubServerUrl}/${config.githubRepository}/pull/dry-run-placeholder`;
+ headSha = branchHeadSha;
+ } else {
+ git.pushBranch(branchName, tempOptions);
+
+ const prTitle = `chore: update changelog for version ${setup.version}`;
+ const prBody = `This PR updates the changelog for the new version ${setup.version}. Please review and merge it to proceed with the release process.`;
+
+ try {
+ const created = await client.createPullRequest({
+ owner,
+ repo,
+ title: prTitle,
+ head: branchName,
+ base: setup.targetBranch,
+ body: prBody,
+ });
+ prUrl = created.html_url;
+ headSha = created.head.sha !== "" ? created.head.sha : branchHeadSha;
+
+ if (created.head.sha === "") {
+ warning(
+ "PR head SHA not returned by API — falling back to local git SHA",
+ );
+ }
+ } catch (error: unknown) {
+ if (error instanceof GitHubApiError && error.status === 422) {
+ const existing = await client.findOpenPullRequestByHead(
+ owner,
+ repo,
+ `${owner}:${branchName}`,
+ setup.targetBranch,
+ );
+
+ if (!existing) {
+ throw new Error(
+ `PR already exists for branch '${branchName}' but could not be found via the API.`,
+ { cause: error },
+ );
+ }
+
+ info(
+ `PR already exists: ${existing.html_url} (head SHA: ${existing.head.sha})`,
+ );
+ prUrl = existing.html_url;
+ headSha =
+ existing.head.sha !== "" ? existing.head.sha : branchHeadSha;
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ info(`Changelog PR head SHA: ${headSha}`);
+
+ if (config.dryRun) {
+ info("Dry-Run: Skipping CI workflow dispatch.");
+ } else {
+ await dispatchConfiguredCiWorkflows(
+ config,
+ client,
+ owner,
+ repo,
+ branchName,
+ headSha,
+ tempDir,
+ );
+ }
+
+ info("A pull request has been created for the changelog update.");
+ info(`PR URL: ${prUrl}`);
+
+ return { prUrl, headSha };
+ } finally {
+ removeDirectory(tempDir);
+ }
+}
diff --git a/src/release/createRelease.ts b/src/release/createRelease.ts
new file mode 100644
index 0000000..9662c56
--- /dev/null
+++ b/src/release/createRelease.ts
@@ -0,0 +1,40 @@
+import * as fs from "node:fs";
+import type { ActionConfig } from "../config.js";
+import type { GitHubClient } from "../github/client.js";
+import { info } from "../utils/log.js";
+import { parseRepositoryCoordinates } from "../utils/repository.js";
+import type { ReleaseSetup } from "./setupRelease.js";
+
+export async function createReleaseForTag(
+ config: ActionConfig,
+ setup: ReleaseSetup,
+ releaseBody: string,
+ client: GitHubClient,
+): Promise {
+ if (setup.tag === "") {
+ throw new Error("TAG is required but not set.");
+ }
+
+ const { owner, repo } = parseRepositoryCoordinates(config.githubRepository);
+
+ info(`Creating release for tag: ${setup.tag} in ${owner}/${repo}`);
+
+ const release = await client.createRelease({
+ owner,
+ repo,
+ tag_name: setup.tag,
+ target_commitish: setup.targetBranch,
+ name: setup.releaseTitle,
+ body: releaseBody,
+ draft: config.releaseDraft,
+ prerelease: config.releasePrerelease,
+ });
+
+ const githubOutput = process.env.GITHUB_OUTPUT;
+
+ if (githubOutput !== undefined && githubOutput !== "") {
+ fs.appendFileSync(githubOutput, `release-url=${release.html_url}\n`);
+ }
+
+ return release.html_url;
+}
diff --git a/src/release/pipeline.ts b/src/release/pipeline.ts
new file mode 100644
index 0000000..483db15
--- /dev/null
+++ b/src/release/pipeline.ts
@@ -0,0 +1,70 @@
+import type { ActionConfig } from "../config.js";
+import * as realGit from "../git/git.js";
+import type { GitPort } from "../git/git.js";
+import { createGitHubClient, type GitHubClient } from "../github/client.js";
+import { info } from "../utils/log.js";
+import {
+ exportChangelogState,
+ exportPrState,
+ exportSetupState,
+} from "./actionState.js";
+import { collectCommits } from "./collectCommits.js";
+import { createChangelogPr } from "./createChangelogPr.js";
+import { createReleaseForTag } from "./createRelease.js";
+import { setupRelease } from "./setupRelease.js";
+import { updateChangelog } from "./updateChangelog.js";
+
+export type PipelineDependencies = {
+ git?: GitPort;
+ client?: GitHubClient;
+};
+
+export async function runPipeline(
+ config: ActionConfig,
+ deps: PipelineDependencies = {},
+): Promise {
+ info("Starting GitHub Release Action TypeScript pipeline.");
+
+ const git = deps.git ?? realGit;
+ const client =
+ deps.client ??
+ createGitHubClient({
+ githubToken: config.githubToken,
+ githubApiUrl: config.githubApiUrl,
+ });
+
+ const setup = await setupRelease(config, git, client);
+ exportSetupState(setup);
+
+ if (setup.releaseExists) {
+ throw new Error(`Release for tag ${setup.tag} already exists.`);
+ }
+
+ const collected = await collectCommits(config, setup, git, client);
+ const changelogResult = await updateChangelog(config, setup, collected, git);
+ exportChangelogState(changelogResult.updated);
+
+ if (changelogResult.updated) {
+ const prResult = await createChangelogPr(
+ config,
+ setup,
+ changelogResult,
+ git,
+ client,
+ );
+ exportPrState(prResult);
+
+ if (config.dryRun) {
+ info("Dry-Run: Skipping release process. No PR was actually created.");
+ return;
+ }
+
+ throw new Error(
+ `Please review and merge the changelog PR before re-running the workflow: ${prResult.prUrl}`,
+ );
+ }
+
+ await createReleaseForTag(config, setup, changelogResult.releaseBody, client);
+
+ info("GitHub Release Action TypeScript pipeline completed.");
+}
diff --git a/src/release/renderReleaseNotes.ts b/src/release/renderReleaseNotes.ts
new file mode 100644
index 0000000..825cadb
--- /dev/null
+++ b/src/release/renderReleaseNotes.ts
@@ -0,0 +1,22 @@
+export const FALLBACK_DESCRIPTION = "This release includes the changes below.";
+
+export function renderReleaseBody(
+ description: string,
+ commitLogLines: string[],
+ fullChangelogLine?: string,
+): string {
+ const lines = [
+ description,
+ "",
+ "------",
+ "",
+ "## What's Changed",
+ commitLogLines.join("\n"),
+ ];
+
+ if (fullChangelogLine !== undefined && fullChangelogLine !== "") {
+ lines.push("", fullChangelogLine);
+ }
+
+ return lines.join("\n");
+}
diff --git a/src/release/setupRelease.ts b/src/release/setupRelease.ts
new file mode 100644
index 0000000..4a3ebd5
--- /dev/null
+++ b/src/release/setupRelease.ts
@@ -0,0 +1,147 @@
+import * as path from "node:path";
+import type { ActionConfig } from "../config.js";
+import type { GitOptions, GitPort } from "../git/git.js";
+import type { GitHubClient } from "../github/client.js";
+import { ensureTextFile, fileExists, readTextFile } from "../utils/files.js";
+import { info } from "../utils/log.js";
+import { parseRepositoryCoordinates } from "../utils/repository.js";
+
+export type ReleaseSetup = {
+ version: string;
+ tag: string;
+ releaseTitle: string;
+ tagExists: boolean;
+ releaseExists: boolean;
+ latestTag?: string;
+ targetBranch: string;
+};
+
+const DEFAULT_CHANGELOG_CONTENT =
+ "## [unreleased]\n\n### Added\n- Placeholder changelog\n";
+
+function ensureChangelogExists(config: ActionConfig): void {
+ const changelogPath = path.join(
+ config.githubWorkspace,
+ config.changelogFilePath,
+ );
+
+ if (fileExists(changelogPath)) {
+ return;
+ }
+
+ info(`File not found: ${config.changelogFilePath}`);
+ info("Creating a default changelog file...");
+
+ if (config.dryRun) {
+ info("Dry-Run: Skipping file creation.");
+ return;
+ }
+
+ ensureTextFile(changelogPath, DEFAULT_CHANGELOG_CONTENT);
+}
+
+function resolveVersion(config: ActionConfig): string {
+ if (config.versionOverride !== undefined) {
+ info(`Using custom version override: ${config.versionOverride}`);
+ return config.versionOverride;
+ }
+
+ const packageJsonPath = path.join(config.githubWorkspace, "package.json");
+
+ if (fileExists(packageJsonPath)) {
+ const parsed: unknown = JSON.parse(readTextFile(packageJsonPath));
+
+ if (
+ typeof parsed === "object" &&
+ parsed !== null &&
+ "version" in parsed &&
+ typeof parsed.version === "string" &&
+ (parsed as { version: string }).version !== ""
+ ) {
+ return (parsed as { version: string }).version;
+ }
+ }
+
+ throw new Error(
+ 'Mandatory "version" parameter has not been specified. Please check GitHub Action configuration.',
+ );
+}
+
+function resolveTag(tagTemplate: string, version: string): string {
+ return tagTemplate.replace("", version);
+}
+
+function resolveReleaseTitle(config: ActionConfig, version: string): string {
+ if (config.releaseTitlePrefix !== "") {
+ return `${config.releaseTitlePrefix} v${version}`;
+ }
+
+ return `v${version}`;
+}
+
+function resolveLatestTag(
+ git: GitPort,
+ tagTemplate: string,
+ options: GitOptions,
+): string | undefined {
+ const pattern = tagTemplate.replace("", "");
+ const patternRegex = new RegExp(pattern);
+ const sortedTags = git.listTagsSortedByVersionDescending(options);
+
+ return sortedTags.find((tag) => patternRegex.test(tag));
+}
+
+function resolveTargetBranch(config: ActionConfig): string {
+ return config.githubBaseRef ?? config.githubRefName ?? "main";
+}
+
+export async function setupRelease(
+ config: ActionConfig,
+ git: GitPort,
+ client: GitHubClient,
+): Promise {
+ git.configSafeDirectory(config.githubWorkspace);
+ git.configUser(config.githubActor);
+
+ if (config.githubToken !== "" && config.githubServerUrl !== "") {
+ git.configGitHttpAuth(config.githubServerUrl, config.githubToken);
+ }
+
+ ensureChangelogExists(config);
+
+ const version = resolveVersion(config);
+ const tag = resolveTag(config.tagTemplate, version);
+ const releaseTitle = resolveReleaseTitle(config, version);
+
+ info(`Version set to: ${version} (${tag})`);
+
+ const gitOptions: GitOptions = { cwd: config.githubWorkspace };
+
+ if (!config.dryRun) {
+ git.fetchBranchesAndTags(gitOptions);
+ }
+
+ const tagExists =
+ git.tagExists(tag, gitOptions) || git.tagExists(`ms/${tag}`, gitOptions);
+
+ let releaseExists = false;
+
+ if (tagExists) {
+ const { owner, repo } = parseRepositoryCoordinates(config.githubRepository);
+ const release = await client.getReleaseByTag(owner, repo, tag);
+ releaseExists = release !== undefined;
+ }
+
+ const latestTag = resolveLatestTag(git, config.tagTemplate, gitOptions);
+ const targetBranch = resolveTargetBranch(config);
+
+ return {
+ version,
+ tag,
+ releaseTitle,
+ tagExists,
+ releaseExists,
+ latestTag,
+ targetBranch,
+ };
+}
diff --git a/src/release/updateChangelog.ts b/src/release/updateChangelog.ts
new file mode 100644
index 0000000..7fb5f46
--- /dev/null
+++ b/src/release/updateChangelog.ts
@@ -0,0 +1,211 @@
+import * as path from "node:path";
+import type { ActionConfig } from "../config.js";
+import type { GitOptions, GitPort } from "../git/git.js";
+import { readTextFile } from "../utils/files.js";
+import { info } from "../utils/log.js";
+import type { CollectedReleaseData } from "./collectCommits.js";
+import {
+ FALLBACK_DESCRIPTION,
+ renderReleaseBody,
+} from "./renderReleaseNotes.js";
+import type { ReleaseSetup } from "./setupRelease.js";
+
+export type ChangelogResult = {
+ updated: boolean;
+ changelogFileContent?: string;
+ releaseBody: string;
+};
+
+function buildVersionHeadingRegexes(version: string): RegExp[] {
+ return [
+ new RegExp(`^## \\[\\[${version}\\]\\]`),
+ new RegExp(`^## \\[\\[${version}\\]\\(.*\\)\\]`),
+ new RegExp(`^## \\[${version}\\]`),
+ ];
+}
+
+function isBlank(value: string): boolean {
+ return value.trim() === "";
+}
+
+function findNextHeadingIndex(lines: string[], fromIndex: number): number {
+ for (let i = fromIndex; i < lines.length; i += 1) {
+ if (/^## \[/.test(lines[i])) {
+ return i;
+ }
+ }
+
+ return -1;
+}
+
+function extractExistingVersionDescription(
+ lines: string[],
+ version: string,
+): string | undefined {
+ const headingRegexes = buildVersionHeadingRegexes(version);
+ const startIndex = lines.findIndex((line) =>
+ headingRegexes.some((regex) => regex.test(line)),
+ );
+
+ if (startIndex === -1) {
+ return undefined;
+ }
+
+ const nextHeadingIndex = findNextHeadingIndex(lines, startIndex + 1);
+ const sliceEnd = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex;
+
+ return lines.slice(startIndex + 1, sliceEnd).join("\n");
+}
+
+function splitUnreleasedSection(lines: string[]): {
+ header: string;
+ unreleasedBody: string;
+ rest: string;
+} {
+ const unreleasedIndex = lines.findIndex((line) =>
+ /^## \[unreleased\]/.test(line),
+ );
+
+ if (unreleasedIndex === -1) {
+ return { header: lines.join("\n"), unreleasedBody: "", rest: "" };
+ }
+
+ const header = lines.slice(0, unreleasedIndex).join("\n");
+ const nextHeadingIndex = findNextHeadingIndex(lines, unreleasedIndex + 1);
+
+ if (nextHeadingIndex === -1) {
+ return {
+ header,
+ unreleasedBody: lines.slice(unreleasedIndex + 1).join("\n"),
+ rest: "",
+ };
+ }
+
+ return {
+ header,
+ unreleasedBody: lines
+ .slice(unreleasedIndex + 1, nextHeadingIndex)
+ .join("\n"),
+ rest: lines.slice(nextHeadingIndex).join("\n"),
+ };
+}
+
+function todayDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+function syncChangelogWithRemote(
+ config: ActionConfig,
+ setup: ReleaseSetup,
+ git: GitPort,
+ options: GitOptions,
+): void {
+ git.fetchBranches(options);
+
+ const isOutdated = git.hasDiffAgainstRef(
+ `origin/${setup.targetBranch}`,
+ config.changelogFilePath,
+ options,
+ );
+
+ if (isOutdated) {
+ info("Local CHANGELOG.md is outdated.");
+
+ if (config.dryRun) {
+ info(`Dry-Run: Skipping 'git pull origin ${setup.targetBranch}'.`);
+ } else {
+ info("Pulling latest changes...");
+ git.pullTargetBranch(setup.targetBranch, options);
+ }
+ } else {
+ info("CHANGELOG.md is up to date.");
+ }
+
+ const hasLocalChanges = git.hasUnstagedChanges(
+ config.changelogFilePath,
+ options,
+ );
+
+ if (!hasLocalChanges) {
+ info(`No changes in ${config.changelogFilePath}`);
+ return;
+ }
+
+ info("Saving changes before switching branches...");
+
+ if (config.dryRun) {
+ info("Dry-Run: Skipping 'git add' and 'git commit'.");
+ return;
+ }
+
+ git.add(config.changelogFilePath, options);
+ git.commit("chore: save changelog changes before branch switch", options);
+}
+
+export function updateChangelog(
+ config: ActionConfig,
+ setup: ReleaseSetup,
+ collected: CollectedReleaseData,
+ git: GitPort,
+): Promise {
+ const gitOptions: GitOptions = { cwd: config.githubWorkspace };
+ syncChangelogWithRemote(config, setup, git, gitOptions);
+
+ const changelogPath = path.join(
+ config.githubWorkspace,
+ config.changelogFilePath,
+ );
+ const lines = readTextFile(changelogPath).split("\n");
+
+ const existingDescription = extractExistingVersionDescription(
+ lines,
+ setup.version,
+ );
+
+ if (existingDescription !== undefined) {
+ info(
+ `Version ${setup.version} already exists in ${config.changelogFilePath}. Extracting description.`,
+ );
+
+ const description = isBlank(existingDescription)
+ ? FALLBACK_DESCRIPTION
+ : existingDescription;
+ const releaseBody = renderReleaseBody(
+ description,
+ collected.commitLogLines,
+ collected.fullChangelogLine,
+ );
+
+ return Promise.resolve({ updated: false, releaseBody });
+ }
+
+ info(
+ `Version ${setup.version} not found in ${config.changelogFilePath}. Updating changelog...`,
+ );
+
+ const { header, unreleasedBody, rest } = splitUnreleasedSection(lines);
+ const description = isBlank(unreleasedBody)
+ ? FALLBACK_DESCRIPTION
+ : unreleasedBody;
+
+ const versionLink = `${config.githubServerUrl}/${config.githubRepository}/releases/tag/${setup.tag}`;
+
+ const changelogFileContent = [
+ header,
+ "",
+ "## [unreleased]",
+ "",
+ `## [[${setup.version}](${versionLink})] - ${todayDate()}`,
+ description,
+ "",
+ rest,
+ ].join("\n");
+
+ const releaseBody = renderReleaseBody(
+ description,
+ collected.commitLogLines,
+ collected.fullChangelogLine,
+ );
+
+ return Promise.resolve({ updated: true, changelogFileContent, releaseBody });
+}
diff --git a/src/utils/env.ts b/src/utils/env.ts
new file mode 100644
index 0000000..8a48333
--- /dev/null
+++ b/src/utils/env.ts
@@ -0,0 +1,54 @@
+import * as fs from "node:fs";
+
+export type Env = NodeJS.ProcessEnv;
+
+export function getEnv(
+ name: string,
+ env: Env = process.env,
+): string | undefined {
+ const value = env[name];
+
+ if (value === undefined || value === "") {
+ return undefined;
+ }
+
+ return value;
+}
+
+export function getRequiredEnv(name: string, env: Env = process.env): string {
+ const value = getEnv(name, env);
+
+ if (value === undefined) {
+ throw new Error(`${name} is required but not set.`);
+ }
+
+ return value;
+}
+
+export function getBooleanEnv(
+ name: string,
+ defaultValue: boolean,
+ env: Env = process.env,
+): boolean {
+ const value = getEnv(name, env);
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return value === "true";
+}
+
+export function exportEnv(
+ name: string,
+ value: string,
+ env: Env = process.env,
+): void {
+ const githubEnv = getEnv("GITHUB_ENV", env);
+
+ if (githubEnv === undefined) {
+ return;
+ }
+
+ fs.appendFileSync(githubEnv, `${name}=${value}\n`, "utf8");
+}
diff --git a/src/utils/files.ts b/src/utils/files.ts
new file mode 100644
index 0000000..f75023b
--- /dev/null
+++ b/src/utils/files.ts
@@ -0,0 +1,41 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+export function fileExists(filePath: string): boolean {
+ return fs.existsSync(filePath);
+}
+
+export function readTextFile(filePath: string): string {
+ return fs.readFileSync(filePath, "utf8");
+}
+
+export function readTextFileIfExists(filePath: string): string | undefined {
+ if (!fileExists(filePath)) {
+ return undefined;
+ }
+
+ return readTextFile(filePath);
+}
+
+export function writeTextFile(filePath: string, content: string): void {
+ fs.writeFileSync(filePath, content, "utf8");
+}
+
+export function appendTextFile(filePath: string, content: string): void {
+ fs.appendFileSync(filePath, content, "utf8");
+}
+
+export function ensureTextFile(filePath: string, defaultContent: string): void {
+ if (!fileExists(filePath)) {
+ writeTextFile(filePath, defaultContent);
+ }
+}
+
+export function createTempDirectory(prefix: string): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+export function removeDirectory(filePath: string): void {
+ fs.rmSync(filePath, { recursive: true, force: true });
+}
diff --git a/src/utils/log.ts b/src/utils/log.ts
new file mode 100644
index 0000000..bd30a83
--- /dev/null
+++ b/src/utils/log.ts
@@ -0,0 +1,27 @@
+function escapeCommandValue(value: string): string {
+ return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
+}
+
+export function info(message: string): void {
+ process.stdout.write(`${message}\n`);
+}
+
+export function notice(message: string): void {
+ info(`::notice::${escapeCommandValue(message)}`);
+}
+
+export function warning(message: string): void {
+ info(`::warning::${escapeCommandValue(message)}`);
+}
+
+export function error(message: string): void {
+ process.stderr.write(`::error::${escapeCommandValue(message)}\n`);
+}
+
+export function addMask(value: string): string {
+ if (value !== "") {
+ info(`::add-mask::${escapeCommandValue(value)}`);
+ }
+
+ return value;
+}
diff --git a/src/utils/repository.ts b/src/utils/repository.ts
new file mode 100644
index 0000000..6a80ebe
--- /dev/null
+++ b/src/utils/repository.ts
@@ -0,0 +1,18 @@
+export type RepositoryCoordinates = {
+ owner: string;
+ repo: string;
+};
+
+export function parseRepositoryCoordinates(
+ githubRepository: string,
+): RepositoryCoordinates {
+ const parts = githubRepository.split("/");
+
+ if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
+ throw new Error(
+ `GITHUB_REPOSITORY must be in the form "owner/repo", got: "${githubRepository}".`,
+ );
+ }
+
+ return { owner: parts[0], repo: parts[1] };
+}
diff --git a/src/utils/retry.ts b/src/utils/retry.ts
new file mode 100644
index 0000000..614c023
--- /dev/null
+++ b/src/utils/retry.ts
@@ -0,0 +1,37 @@
+export type RetryOptions = {
+ maxAttempts: number;
+ intervalMs: number;
+ description: string;
+ sleep?: (milliseconds: number) => Promise;
+ onRetry?: (attempt: number) => void;
+};
+
+export async function sleep(milliseconds: number): Promise {
+ await new Promise((resolve) => {
+ setTimeout(resolve, milliseconds);
+ });
+}
+
+export async function retryUntil(
+ operation: (attempt: number) => Promise,
+ options: RetryOptions,
+): Promise {
+ const sleepFn = options.sleep ?? sleep;
+
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
+ const result = await operation(attempt);
+
+ if (result !== undefined && result !== null && result !== false) {
+ return result;
+ }
+
+ if (attempt < options.maxAttempts) {
+ options.onRetry?.(attempt);
+ await sleepFn(options.intervalMs);
+ }
+ }
+
+ throw new Error(
+ `Timed out while waiting for ${options.description} after ${options.maxAttempts} attempts.`,
+ );
+}