diff --git a/.github/workflows/agent-review-pr.yml b/.github/workflows/agent-review-pr.yml new file mode 100644 index 0000000..9a17f7d --- /dev/null +++ b/.github/workflows/agent-review-pr.yml @@ -0,0 +1,194 @@ +name: Agent / Review PR + +on: + pull_request: + types: [opened, synchronize] + branches: [main, development] + + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + type: string + base_ref: + description: "Base branch of the PR (e.g. development)" + required: true + type: string + head_ref: + description: "Head branch of the PR" + required: true + type: string + +permissions: + contents: read + pull-requests: write + issues: write + id-token: write + +jobs: + tests: + if: github.base_ref == 'development' || (github.event_name == 'workflow_dispatch' && inputs.base_ref == 'development') + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: [20.x, 22.x, 24.x] + + steps: + - uses: actions/checkout@v6 + + - name: Checkout PR (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + run: gh pr checkout ${{ inputs.pr_number }} + env: + GH_TOKEN: ${{ github.token }} + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Verify (format, typecheck, test) + run: npm run verify + + review: + if: (github.base_ref == 'development' && github.event.action == 'opened') || (github.event_name == 'workflow_dispatch' && inputs.base_ref == 'development') + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + verdict: ${{ steps.verdict.outputs.verdict }} + + steps: + - name: Generate review bot token + id: review-bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.LLM_EXE_REVIEW_BOT_CLIENT_ID }} + private-key: ${{ secrets.LLM_EXE_REVIEW_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.review-bot-token.outputs.token }} + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Review PR + env: + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + HEAD_REF: ${{ github.event.pull_request.head.ref || inputs.head_ref }} + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ steps.review-bot-token.outputs.token }} + display_report: "false" + allowed_bots: "llm-exe-bot[bot]" + prompt: | + You are a senior engineer reviewing PR #${{ env.PR_NUMBER }} on the llm-exe GitHub Action repository. + + This repo is a GitHub Action wrapper around the llm-exe SDK. It runs committed dist/index.js + (not src/) so every code change must be accompanied by a rebuilt dist/. The action exposes + inputs/outputs defined in action.yml and is consumed by other GitHub workflows via + `uses: llm-exe/github-action@v1`. + + ## Your task + + 1. Read CLAUDE.md for project context. + 2. Fetch the PR diff: `gh pr diff ${{ env.PR_NUMBER }}` + 3. Read any changed source files in full. + 4. Check that dist/ was rebuilt if src/ changed: look for dist/ changes alongside src/ changes. + 5. Review for: correctness, security, type safety, action.yml contract changes, and dist freshness. + + ## Verdict + + After your review, post a PR comment with your findings. Then write your verdict to + /tmp/review-verdict.txt — one word only: + - `approve` — code is correct, dist is fresh (if src changed), no blocking issues + - `request-changes` — there are issues that must be fixed before merging + - `comment` — minor notes only, no blocking issues but not ready to formally approve + + If the branch name starts with `agent/` note that this is bot-authored code — hold it + to the same standards but expect mechanical patterns. + claude_args: | + --allowedTools "Bash,Read,Glob,Grep,WebFetch" + --max-turns 30 + --model ${{ vars.ANTHROPIC_OPUS_LATEST || 'claude-opus-4-6' }} + + - name: Read verdict + id: verdict + if: always() + run: | + if [ -f /tmp/review-verdict.txt ]; then + v=$(cat /tmp/review-verdict.txt | tr -d '[:space:]') + else + v="no-verdict" + fi + echo "verdict=$v" >> "$GITHUB_OUTPUT" + + decide: + needs: [tests, review] + if: always() && (github.base_ref == 'development' || (github.event_name == 'workflow_dispatch' && inputs.base_ref == 'development')) + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Generate review bot token + id: review-bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.LLM_EXE_REVIEW_BOT_CLIENT_ID }} + private-key: ${{ secrets.LLM_EXE_REVIEW_BOT_PRIVATE_KEY }} + + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Approve or skip + env: + GH_TOKEN: ${{ steps.review-bot-token.outputs.token }} + BOT_TOKEN: ${{ steps.bot-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + HEAD_REF: ${{ github.event.pull_request.head.ref || inputs.head_ref }} + REPOSITORY: ${{ github.repository }} + run: | + verdict="${{ needs.review.outputs.verdict }}" + tests_result="${{ needs.tests.result }}" + + echo "Review verdict : $verdict" + echo "Tests result : $tests_result" + + if [ "$verdict" = "approve" ] && [ "$tests_result" = "success" ]; then + gh pr review "$PR_NUMBER" \ + --approve \ + --body "Approved by reviewer agent (tests passing)." \ + --repo "$REPOSITORY" + + if echo "$HEAD_REF" | grep -q '^agent/'; then + IS_DRAFT=$(gh pr view "$PR_NUMBER" \ + --json isDraft --jq '.isDraft' \ + --repo "$REPOSITORY") + if [ "$IS_DRAFT" = "true" ]; then + GH_TOKEN="$BOT_TOKEN" gh pr ready "$PR_NUMBER" --repo "$REPOSITORY" + echo "PR marked as ready for review." + fi + fi + + echo "PR #$PR_NUMBER approved." + else + echo "No approval submitted: verdict='$verdict', tests='$tests_result'." + fi diff --git a/.github/workflows/auto-merge-main-pr.yml b/.github/workflows/auto-merge-main-pr.yml new file mode 100644 index 0000000..2bf3b18 --- /dev/null +++ b/.github/workflows/auto-merge-main-pr.yml @@ -0,0 +1,88 @@ +name: Release / Auto Merge + +on: + workflow_run: + workflows: + - "Release / Check Semver" + types: + - completed + pull_request: + types: + - ready_for_review + - synchronize + branches: + - main + +permissions: + id-token: write + checks: write + contents: write + pull-requests: write + actions: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'development' || github.event_name == 'pull_request' }} + + steps: + - uses: actions/checkout@v6 + + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Get PR number for development to main + run: | + PR_INFO=$(gh pr list --base main --head development --state open --json number,isDraft --jq '.[] | select(.isDraft == false) | .number') + echo "PR_NUMBER=$PR_INFO" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Wait for all checks to complete + if: env.PR_NUMBER != '' + run: | + MAX_ATTEMPTS=10 + ATTEMPT=0 + while [[ $ATTEMPT -lt $MAX_ATTEMPTS ]]; do + CHECKS_IN_PROGRESS=$(gh pr checks ${{ env.PR_NUMBER }} --json name,state --jq '[.[] | select(.name != "auto-merge") | select(.state == "IN_PROGRESS")] | length') + if [[ "$CHECKS_IN_PROGRESS" -eq "0" ]]; then + echo "All checks complete." + break + fi + echo "Checks still in progress... attempt $ATTEMPT/$MAX_ATTEMPTS" + ATTEMPT=$((ATTEMPT + 1)) + sleep 30 + done + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Verify all checks passed + if: env.PR_NUMBER != '' + run: | + FAILED_CHECKS=$(gh pr checks ${{ env.PR_NUMBER }} --json name,state --jq '[.[] | select(.name != "auto-merge") | select(.state == "FAILURE")] | length') + if [[ "$FAILED_CHECKS" -gt "0" ]]; then + echo "Some checks failed — aborting auto-merge." + exit 1 + fi + echo "All checks passed." + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Merge PR to main + if: env.PR_NUMBER != '' + run: | + gh pr merge ${{ env.PR_NUMBER }} --merge --admin --repo ${{ github.repository }} + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} diff --git a/.github/workflows/bot-respond.yml b/.github/workflows/bot-respond.yml new file mode 100644 index 0000000..754ecff --- /dev/null +++ b/.github/workflows/bot-respond.yml @@ -0,0 +1,114 @@ +name: Agent / Bot Respond + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + id-token: write + +jobs: + respond: + if: | + contains(github.event.comment.body, '@llm-exe-bot') && + github.event.comment.user.login != 'llm-exe-bot[bot]' && + ( + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' + ) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Configure git + run: | + git config --global user.name "llm-exe-bot[bot]" + git config --global user.email "${{ vars.APP_BOT_USER_ID }}+llm-exe-bot[bot]@users.noreply.github.com" + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.bot-token.outputs.token }} + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Respond + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ steps.bot-token.outputs.token }} + prompt: | + You are llm-exe-bot, a helpful assistant for the llm-exe GitHub Action repository. + You've been mentioned in a GitHub issue or PR comment by a maintainer or collaborator. + + Read CLAUDE.md for project context. This repo is a GitHub Action wrapper around the + llm-exe SDK. It runs committed dist/index.js (not src/) so code changes must include + a rebuilt dist/. + + ## Determine what's being asked + + Read the comment that mentioned you carefully. Decide which of the following applies: + + ### 1. PR review requested + If the maintainer wants you to review a pull request — any phrasing like "review this", + "re-review", "take another look", "check the PR", "can you review", etc. — AND the + comment is on a pull request: + - Fetch the PR's base and head branch: + `gh pr view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json baseRefName,headRefName` + - Dispatch the review pipeline: + `gh workflow run agent-review-pr.yml \ + --repo ${{ github.repository }} \ + -f pr_number="${{ github.event.issue.number }}" \ + -f base_ref="" \ + -f head_ref=""` + - Post a brief acknowledgment, e.g.: + "Review pipeline started — tests + agent review + approval will run shortly." + - Stop here. Do not also do a manual review. + + ### 2. Answer questions (read-only) + If the maintainer is asking a question or wants your opinion on something: + - Read any relevant source code + - Use `gh pr diff` or `gh pr view` to understand PR context + - Run `npm run verify` if needed to check the current state + - Reply with a concise, specific answer + + ### 3. Make changes (write mode) + If the maintainer is asking you to fix something, revise a PR, address review feedback, + or make code changes: + - If you're on a PR, check out the PR branch: `gh pr checkout ` + - Read the PR diff and any feedback + - Make the requested changes + - If you changed src/, rebuild dist/: `npm run build` + - Run `npm run verify` — everything must pass + - Commit with a descriptive message (do NOT add Co-Authored-By lines) + - Push to the existing PR branch + - Reply with a summary of what you changed + + ## Rules + - ONLY make changes when the maintainer explicitly asks you to + - If the request is ambiguous, ask for clarification — don't guess + - Stay scoped to what's asked. Don't refactor unrelated things. + - Do NOT create new PRs or branches — work on the existing PR branch + - Do NOT close issues or PRs unless explicitly told to + - Be conversational and concise. Reference actual code (file paths, line numbers) when relevant. + claude_args: | + --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch" + --max-turns 90 + --model ${{ vars.ANTHROPIC_OPUS_LATEST || 'claude-opus-4-6' }} diff --git a/.github/workflows/check-semantic-versioning.yml b/.github/workflows/check-semantic-versioning.yml new file mode 100644 index 0000000..6823b28 --- /dev/null +++ b/.github/workflows/check-semantic-versioning.yml @@ -0,0 +1,61 @@ +name: Release / Check Semver + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + enforce-semantic-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Fetch all tags + run: git fetch --tags + + - name: Get latest stable release tag + id: get_latest_tag + run: | + LATEST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG="v0.0.0" + fi + # Remove the "v" prefix + LATEST_TAG=${LATEST_TAG#v} + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + + - name: Get version from package.json + id: get_package_version + run: | + PACKAGE_VERSION=$(jq -r '.version' package.json) + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV + + - name: Compare versions + run: | + LATEST_TAG=${{ env.LATEST_TAG }} + PACKAGE_VERSION=${{ env.PACKAGE_VERSION }} + + # Convert versions to comparable numbers + convert_version() { + echo "$1" | awk -F. '{ printf("%d%03d%03d", $1,$2,$3); }' + } + + LATEST_TAG_NUM=$(convert_version $LATEST_TAG) + PACKAGE_VERSION_NUM=$(convert_version $PACKAGE_VERSION) + + if [ "$PACKAGE_VERSION_NUM" -le "$LATEST_TAG_NUM" ]; then + echo "Version in package.json ($PACKAGE_VERSION) is not greater than the latest release tag ($LATEST_TAG), please update it to match the next release." + exit 1 + fi + + - name: Success message + if: success() + run: echo "Version in package.json ($PACKAGE_VERSION) is greater than the latest release tag ($LATEST_TAG). Ready to merge to main!" diff --git a/.github/workflows/create-draft-release.yml b/.github/workflows/create-draft-release.yml new file mode 100644 index 0000000..5ead5dd --- /dev/null +++ b/.github/workflows/create-draft-release.yml @@ -0,0 +1,121 @@ +name: Release / Create Draft + +on: + workflow_dispatch: + pull_request: + types: + - closed + branches: + - main + +permissions: + contents: write + +jobs: + update-draft-releases: + name: "Delete old drafts and create new draft release" + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + + steps: + - uses: actions/checkout@v6 + + - name: Get all draft releases + id: get_drafts + run: | + gh api repos/${{ github.repository }}/releases \ + | jq '.[] | select(.draft == true) | .id' > release_ids.txt + + - name: Delete old draft releases + run: | + while read release_id; do + echo "Deleting draft release with ID: $release_id" + gh api -X DELETE repos/${{ github.repository }}/releases/$release_id + done < release_ids.txt + + - name: Determine next semantic version for release + run: | + PACKAGE_VERSION=$(jq -r '.version' package.json) + if [ -z "$PACKAGE_VERSION" ] || [ "$PACKAGE_VERSION" = "null" ]; then + echo "No version found in package.json" + exit 1 + fi + + # Validate MAJOR.MINOR.PATCH with optional semver pre-release suffix (e.g. -beta.0, -rc.1) + if [[ ! $PACKAGE_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid version format ($PACKAGE_VERSION). Expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-PRERELEASE" + exit 1 + fi + + # Preserve the full version (including any -beta.X / -rc.X suffix) in the tag + NEW_VERSION="v${PACKAGE_VERSION}" + echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV + + # A pre-release suffix (anything after a "-") makes this a GitHub pre-release + if [[ "$PACKAGE_VERSION" == *-* ]]; then + echo "IS_PRERELEASE=true" >> $GITHUB_ENV + else + echo "IS_PRERELEASE=false" >> $GITHUB_ENV + fi + + - name: Create Draft Release on GitHub + id: create_release + run: | + response=$(curl -s -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/releases \ + -d '{ + "tag_name": "'"${NEW_VERSION}"'", + "target_commitish": "main", + "draft": true, + "prerelease": '"${IS_PRERELEASE}"', + "make_latest": "'"$([ "${IS_PRERELEASE}" = "true" ] && echo false || echo legacy)"'", + "generate_release_notes": true + }') + + # Check if release creation succeeded + if echo "$response" | jq -e '.id' > /dev/null; then + release_url=$(echo $response | jq -r '.html_url') + release_id=$(echo $response | jq -r '.id') + body=$(echo "$response" | jq -r '.body') + + echo "release_url=${release_url}" >> $GITHUB_OUTPUT + echo "release_id=${release_id}" >> $GITHUB_OUTPUT + echo "$body" > release_body.txt + + echo "Draft release created: ${release_url}" + else + echo "Failed to create draft release" + echo "$response" | jq '.' + exit 1 + fi + + - name: Clean up release notes + id: clean_release_notes + run: | + # Remove automation-related commits and remove "by @username in" from PR references + CLEANED_BODY=$(sed '/chore: bump version/Id; /Draft PR for release/Id; /Bump Version on PR to Main/Id; /docs: sync/Id; /revert version bump after failed publish/Id' release_body.txt | sed -E 's/ by @[^ ]+ in/ /g') + echo "$CLEANED_BODY" > cleaned_body.txt + echo "Cleaned release notes:" + cat cleaned_body.txt + + - name: Update Draft Release with cleaned notes + run: | + CLEANED_BODY=$(jq -Rs '.' < cleaned_body.txt) + response=$(curl -s -X PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.create_release.outputs.release_id }} \ + -d "{\"tag_name\": \"${NEW_VERSION}\", \"target_commitish\": \"main\", \"body\": ${CLEANED_BODY}}") + + if echo "$response" | jq -e '.id' > /dev/null; then + echo "Release notes updated successfully" + echo "Draft release ready: ${{ steps.create_release.outputs.release_url }}" + else + echo "Warning: Failed to update release notes" + echo "$response" | jq '.' + fi diff --git a/.github/workflows/draft-main-pr.yml b/.github/workflows/draft-main-pr.yml new file mode 100644 index 0000000..9aaf545 --- /dev/null +++ b/.github/workflows/draft-main-pr.yml @@ -0,0 +1,253 @@ +name: Release / Draft PR + +on: + pull_request: + types: + - closed + branches: + - development + + release: + types: [published] + +permissions: + id-token: write + contents: write + pull-requests: write + +jobs: + draft-dev-to-main-pr: + if: ${{ github.event_name == 'release' || !(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'development' && github.event.pull_request.head.ref == 'bump-version-branch') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Get the latest release version + id: latest_release + run: | + LATEST_VERSION=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + if [ -z "$LATEST_VERSION" ]; then + LATEST_VERSION="v0.0.0" + fi + LATEST_VERSION=${LATEST_VERSION#v} + echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_ENV + + - name: Get package.json version + run: | + package_version=$(jq -r '.version' < package.json) + echo "Version in package.json: $package_version" + echo "PACKAGE_VERSION=$package_version" >> $GITHUB_ENV + + - name: Check version comparison + run: | + LATEST_VERSION=${{ env.LATEST_VERSION }} + PACKAGE_VERSION=${{ env.PACKAGE_VERSION }} + + if [[ "$PACKAGE_VERSION" == *-* ]]; then + echo "Pre-release version detected ($PACKAGE_VERSION) — skipping auto-bump." + echo "CURRENT_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV + echo "BUMP_VERSION=false" >> $GITHUB_ENV + echo "IS_PRERELEASE=true" >> $GITHUB_ENV + exit 0 + fi + echo "IS_PRERELEASE=false" >> $GITHUB_ENV + + convert_version() { + echo "$1" | awk -F. '{ printf("%d%03d%03d", $1,$2,$3); }' + } + + LATEST_VERSION_NUM=$(convert_version $LATEST_VERSION) + PACKAGE_VERSION_NUM=$(convert_version $PACKAGE_VERSION) + + if [ "$PACKAGE_VERSION_NUM" -le "$LATEST_VERSION_NUM" ]; then + echo "Version in package.json ($PACKAGE_VERSION) is not greater than latest tag ($LATEST_VERSION)." + echo "CURRENT_VERSION=$LATEST_VERSION" >> $GITHUB_ENV + echo "BUMP_VERSION=true" >> $GITHUB_ENV + else + echo "Version in package.json ($PACKAGE_VERSION) is greater than latest tag ($LATEST_VERSION)." + echo "CURRENT_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV + echo "BUMP_VERSION=false" >> $GITHUB_ENV + fi + + - name: Determine next semantic version + run: | + LATEST_TAG=${{ env.CURRENT_VERSION }} + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG="v${{ env.PACKAGE_VERSION }}" + fi + + if [ "${{ env.IS_PRERELEASE }}" = "true" ]; then + echo "NEW_VERSION=v${LATEST_TAG}" >> $GITHUB_ENV + exit 0 + fi + + if [[ $LATEST_TAG =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + else + echo "Invalid version format. Expected format: MAJOR.MINOR.PATCH" + exit 1 + fi + + if [ "${{ env.BUMP_VERSION }}" == 'false' ]; then + PATCH=$PATCH + else + PATCH=$((PATCH + 1)) + fi + echo "NEW_VERSION=v${MAJOR}.${MINOR}.${PATCH}" >> $GITHUB_ENV + + - name: Check if PR already exists + run: | + PR_URL=$(gh pr list --base main --head development --json url --jq '.[0].url') + if [ -z "$PR_URL" ]; then + echo "PR_EXISTS=false" >> $GITHUB_ENV + else + echo "PR_EXISTS=true" >> $GITHUB_ENV + echo "PR_URL=$PR_URL" >> $GITHUB_ENV + fi + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Get merged PR titles for release + run: | + echo "Fetching branches..." + git fetch origin main development + + MERGE_BASE=$(git merge-base origin/main origin/development) + echo "Merge base: $MERGE_BASE" + + PR_NUMBERS=$(git log $MERGE_BASE..origin/development --merges --pretty=format:"%s" | grep -oP 'Merge pull request #\K[0-9]+') || true + + PR_TITLES="" + if [ -n "$PR_NUMBERS" ]; then + while IFS= read -r pr_num; do + if [ -n "$pr_num" ]; then + if pr_info=$(gh pr view "$pr_num" --json number,title --jq '"- #\(.number): \(.title)"' 2>/dev/null); then + PR_TITLES="${PR_TITLES}${pr_info}\n" + fi + fi + done <<< "$PR_NUMBERS" + fi + + if [ -n "$PR_TITLES" ]; then + PR_COUNT=$(echo -e "$PR_TITLES" | grep -c "^- #" || echo "0") + else + PR_COUNT=0 + fi + + echo "PR_TITLES<> $GITHUB_ENV + echo -e "$PR_TITLES" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + echo "PR_COUNT=$PR_COUNT" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Check for new commits between branches + run: | + if git rev-list --count origin/main..origin/development | grep -q '^0$'; then + echo "No new commits to create a PR." + echo "NEW_COMMITS=false" >> $GITHUB_ENV + else + echo "NEW_COMMITS=true" >> $GITHUB_ENV + fi + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Bump version number (patch increment) + if: env.BUMP_VERSION == 'true' && env.NEW_COMMITS == 'true' + run: | + LATEST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG="v0.0.0" + fi + + if [[ $LATEST_TAG =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + else + echo "Invalid version format." + exit 1 + fi + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + + jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.tmp && mv package.tmp package.json + echo "Updated version in package.json to $NEW_VERSION" + + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add package.json + git commit -m "chore: bump version number on PR to main" + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Create or update bump-version branch + if: env.BUMP_VERSION == 'true' && env.NEW_COMMITS == 'true' + run: | + git checkout -b bump-version-branch || git checkout bump-version-branch + git pull origin bump-version-branch || echo "Branch does not exist remotely." + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Push bump-version branch + if: env.BUMP_VERSION == 'true' && env.NEW_COMMITS == 'true' + run: | + git push origin bump-version-branch || (echo "Retrying with force push..." && git push --force origin bump-version-branch) + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Create bump-version PR and auto-merge + if: env.BUMP_VERSION == 'true' && env.NEW_COMMITS == 'true' + run: | + gh pr create --title "Bump Version on PR to Main" --body "This PR bumps the version number" --base development --head bump-version-branch + PR_NUMBER=$(gh pr list --state open --head bump-version-branch --json number --jq '.[0].number') + gh pr merge $PR_NUMBER --admin --squash --delete-branch + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Create or update draft PR to main + if: env.NEW_COMMITS == 'true' + run: | + if [ "$PR_COUNT" -gt 0 ]; then + PR_BODY=$'## Changes in this release:\n\n'"${PR_TITLES}"$'\n\nThis release includes '"${PR_COUNT}"$' merged pull request(s) from the development branch.' + else + PR_BODY=$'## Changes in this release:\n\nThis release includes changes from the development branch.' + fi + + if [ ${#PR_BODY} -gt 65000 ]; then + PR_BODY="${PR_BODY:0:65000}"$'\n\n... (truncated due to length)' + fi + + if [ "$PR_EXISTS" = "true" ]; then + echo "Updating existing PR: $PR_URL" + gh pr edit "$PR_URL" --title "Draft PR for release version $NEW_VERSION" --body "$PR_BODY" + else + echo "Creating new draft PR..." + gh pr create \ + --base main \ + --head development \ + --title "Draft PR for release version $NEW_VERSION" \ + --body "$PR_BODY" \ + --draft + fi + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..73eb23b --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,236 @@ +name: Release / Publish + +on: + workflow_dispatch: + release: + types: + - published + +permissions: + contents: write + pull-requests: write + +jobs: + check-release-branch: + runs-on: ubuntu-latest + steps: + - name: Check if release is from main branch + if: github.event_name == 'release' && github.event.action == 'published' + run: | + BRANCH=$(jq -r .release.target_commitish "$GITHUB_EVENT_PATH") + if [ "$BRANCH" != "main" ]; then + echo "Releases should only be created from the main branch. This release is from $BRANCH." + exit 1 + fi + + publish-action: + name: Verify dist and promote floating tags + if: github.event_name == 'workflow_dispatch' || github.event_name == 'release' + needs: check-release-branch + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve release tag and prerelease flag + id: meta + run: | + if [ "${{ github.event_name }}" = "release" ]; then + TAG="${{ github.event.release.tag_name }}" + PRE="${{ github.event.release.prerelease }}" + else + TAG=$(gh release view --json tagName --jq .tagName) + PRE=$(gh release view --json isPrerelease --jq .isPrerelease) + fi + if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then + echo "Could not resolve a release tag." + exit 1 + fi + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "prerelease=$PRE" >> $GITHUB_OUTPUT + echo "Resolved tag=$TAG prerelease=$PRE" + + - name: Checkout released tag + run: git checkout --detach "${{ steps.meta.outputs.tag }}" + + - name: Use Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Verify dist matches source at released tag + run: | + npm run build + if ! git diff --quiet -- dist/; then + echo "::error::dist/ at ${{ steps.meta.outputs.tag }} does not match a clean build from src/." + git --no-pager diff --stat -- dist/ + exit 1 + fi + echo "dist/ verified for ${{ steps.meta.outputs.tag }}." + + - name: Promote floating major/minor tags + if: steps.meta.outputs.prerelease != 'true' + run: | + set -euo pipefail + TAG="${{ steps.meta.outputs.tag }}" + SHA=$(git rev-parse HEAD) + VER="${TAG#v}" + + # Only stable MAJOR.MINOR.PATCH releases move the floating tags. Pre-releases + # (e.g. v2.0.0-beta.0) keep their immutable tag but must never hijack @v2. + if [[ ! "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Non-stable version $TAG: leaving floating tags unchanged." + exit 0 + fi + + MAJOR="${VER%%.*}" + REST="${VER#*.}" + MINOR="${REST%%.*}" + + move_ref() { + local ref="$1" + if gh api "repos/$REPO/git/refs/tags/$ref" >/dev/null 2>&1; then + gh api -X PATCH "repos/$REPO/git/refs/tags/$ref" -f sha="$SHA" -F force=true >/dev/null + echo "Moved $ref -> $SHA" + else + gh api -X POST "repos/$REPO/git/refs" -f ref="refs/tags/$ref" -f sha="$SHA" >/dev/null + echo "Created $ref -> $SHA" + fi + } + + move_ref "v$MAJOR" + move_ref "v$MAJOR.$MINOR" + echo "Consumers on @v$MAJOR and @v$MAJOR.$MINOR now resolve to $TAG." + + - name: Prerelease note + if: steps.meta.outputs.prerelease == 'true' + run: echo "Prerelease ${{ steps.meta.outputs.tag }} published; floating tags left unchanged." + + # Reverts the GitHub release back to draft if dist verification or tag promotion + # fails, so a broken release is never left published. + revert-to-draft: + if: always() && github.event_name == 'release' && needs.publish-action.result == 'failure' + needs: [publish-action] + name: Revert Release to Draft + runs-on: ubuntu-latest + steps: + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.bot-token.outputs.token }} + + - name: Revert release to draft + run: | + RELEASE_ID=$(jq -r .release.id "$GITHUB_EVENT_PATH") + ORIGINAL_BODY=$(jq -r .release.body "$GITHUB_EVENT_PATH") + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + { + echo "> [!WARNING]" + echo "> **Release Publishing Failed**" + echo "> This release was automatically reverted to draft because dist verification or tag promotion failed." + echo "> Review the [workflow logs](${RUN_URL}) and fix the issue before publishing again." + echo "---" + echo "$ORIGINAL_BODY" + } > release_body.txt + + BODY_JSON=$(jq -Rs '.' < release_body.txt) + response=$(curl -s -X PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ steps.bot-token.outputs.token }}" \ + https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID \ + -d "{\"draft\": true, \"body\": ${BODY_JSON}}") + if echo "$response" | jq -e '.id' > /dev/null; then + echo "Release reverted to draft with original notes preserved" + else + echo "Warning: Failed to revert release" + echo "$response" | jq '.' + exit 1 + fi + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Delete release tag + run: | + TAG_NAME=$(jq -r .release.tag_name "$GITHUB_EVENT_PATH") + echo "Deleting git tag: $TAG_NAME" + if gh api -X DELETE "repos/${{ github.repository }}/git/refs/tags/$TAG_NAME"; then + echo "Tag $TAG_NAME deleted — version bump logic will no longer treat this version as released" + else + echo "Could not delete tag $TAG_NAME" + fi + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + + - name: Revert version bump in development and update draft PR title + run: | + TAG_NAME=$(jq -r .release.tag_name "$GITHUB_EVENT_PATH") + FAILED_VERSION="${TAG_NAME#v}" + + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git fetch origin development + + CURRENT_DEV_VERSION=$(git show origin/development:package.json | jq -r '.version') + echo "Failed version: $FAILED_VERSION | Current version on development: $CURRENT_DEV_VERSION" + + if [ "$CURRENT_DEV_VERSION" != "$FAILED_VERSION" ]; then + echo "development was bumped to v$CURRENT_DEV_VERSION after the failed release — reverting to v$FAILED_VERSION" + + git checkout -b revert-version-bump origin/development + jq --arg v "$FAILED_VERSION" '.version = $v' package.json > package.tmp && mv package.tmp package.json + git add package.json + git commit -m "chore: revert version bump after failed publish of v$FAILED_VERSION" + git push origin revert-version-bump --force + + EXISTING=$(gh pr list --state open --head revert-version-bump --base development --json number --jq '.[0].number') + if [ -z "$EXISTING" ]; then + gh pr create \ + --title "chore: revert version bump after failed publish of v$FAILED_VERSION" \ + --body "The v$FAILED_VERSION publish failed. Reverting package.json so the next release attempt re-uses v$FAILED_VERSION." \ + --base development \ + --head revert-version-bump + fi + + REVERT_PR=$(gh pr list --state open --head revert-version-bump --base development --json number --jq '.[0].number') + gh pr merge "$REVERT_PR" --admin --squash --delete-branch --repo ${{ github.repository }} + echo "development reverted to v$FAILED_VERSION" + else + echo "development already at v$FAILED_VERSION — no version revert needed" + fi + + # Update the draft dev→main PR title immediately (draft-main-pr.yml will also + # update it when the revert PR merges, but this ensures it happens right away) + EXPECTED_TITLE="Draft PR for release version v$FAILED_VERSION" + PR_INFO=$(gh pr list --base main --head development --state open --json number,title --jq '.[0]') + if [ -n "$PR_INFO" ] && [ "$PR_INFO" != "null" ]; then + PR_NUMBER=$(echo "$PR_INFO" | jq -r '.number') + PR_TITLE=$(echo "$PR_INFO" | jq -r '.title') + if [ "$PR_TITLE" != "$EXPECTED_TITLE" ]; then + gh pr edit "$PR_NUMBER" --title "$EXPECTED_TITLE" --repo ${{ github.repository }} + echo "Updated dev→main PR #$PR_NUMBER title to: $EXPECTED_TITLE" + else + echo "PR title already correct: $PR_TITLE" + fi + else + echo "No open dev→main PR found — title update skipped" + fi + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..39992c3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,68 @@ +name: CI / Tests + +on: + workflow_dispatch: + pull_request: + branches: ["main", "development"] + push: + branches: ["main"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20.x, 22.x, 24.x] + steps: + - uses: actions/checkout@v6 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Verify (format, typecheck, test) + run: npm run verify + + dist: + name: Verify dist is in sync with source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # The Action runs the committed dist/index.js, not src/. This job rebuilds the + # bundle and fails if the committed output drifts from source, so a release can + # never ship a stale bundle. + - name: Use Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Rebuild bundle + run: npm run build + + - name: Fail if committed dist/ is stale + run: | + if ! git diff --quiet -- dist/; then + echo "::error::dist/ is out of date with src/. Run 'npm run build' and commit the result." + git --no-pager diff --stat -- dist/ + exit 1 + fi + echo "dist/ matches a clean build from src/." diff --git a/.github/workflows/update-prs-with-development.yml b/.github/workflows/update-prs-with-development.yml new file mode 100644 index 0000000..e7df36a --- /dev/null +++ b/.github/workflows/update-prs-with-development.yml @@ -0,0 +1,52 @@ +name: CI / Update PRs + +on: + # push: + # branches: [development] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-prs: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + token: ${{ steps.bot-token.outputs.token }} + + - name: Update open PRs targeting development + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + run: | + PR_NUMBERS=$(gh pr list --base development --state open --json number --jq '.[].number') + + if [ -z "$PR_NUMBERS" ]; then + echo "No open PRs targeting development." + exit 0 + fi + + for PR in $PR_NUMBERS; do + IS_DRAFT=$(gh pr view "$PR" --json isDraft --jq '.isDraft') + if [ "$IS_DRAFT" = "true" ]; then + echo "PR #$PR is a draft - skipping." + continue + fi + echo "Attempting to update PR #$PR..." + if gh pr update-branch "$PR" 2>&1; then + echo "PR #$PR updated successfully." + else + echo "PR #$PR skipped (conflicts or already up to date)." + fi + done diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..74b26f5 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,35 @@ +# Releasing + +This Action is released with the same draft-then-publish flow as the [`llm-exe`](https://github.com/llm-exe/llm-exe) SDK. Consumers pin a floating major (`uses: llm-exe/github-action@v1`) or minor (`@v1.4`) tag and automatically get the latest patch; the immutable `vX.Y.Z` tag is also available for exact pins. + +## What ships + +A GitHub Action runs the committed `dist/index.js`, not `src/`. CI rebuilds the bundle on every PR and fails if `dist/` drifts from source, so the bundle must be built and committed before a release. Build it with `npm run build`. + +## Versioning + +`package.json` `version` is the single source of truth. The floating tags follow: + +- `v1.4.5` immutable, one per release, gets a GitHub Release. +- `v1.4` moves to the latest `v1.4.x`. +- `v1` moves to the latest `v1.x.x`. + +Pre-releases (`v2.0.0-beta.0`) get an immutable tag and a GitHub pre-release, but never move the floating `v2` / `v2.0` tags. + +## Steps + +1. Open a PR into `main` that bumps `package.json` `version` to the next version and includes a freshly built `dist/` (`npm run build`). +2. On the PR, two gates must pass: + - **CI / Tests** runs `npm run verify` on Node 20 and 22 and checks `dist/` is in sync. + - **Release / Check Semver** confirms `package.json` version is greater than the latest stable `vX.Y.Z` tag. +3. Merge the PR. **Release / Create Draft** builds a draft GitHub Release tagged `vX.Y.Z` with cleaned, auto-generated notes. +4. Review the draft release notes, then click **Publish**. +5. **Release / Publish** checks out the released tag, re-verifies `dist/`, and re-points the floating `v1` and `v1.4` tags to the released commit. If anything fails, the release is reverted to draft. + +## First release + +There are no `vX.Y.Z` tags yet (only a legacy `v1`), so the semver check treats the latest stable version as `0.0.0`. Set `package.json` `version` to your first release (for example `1.0.0`) in step 1. Publishing it creates `v1.0.0` and moves the existing `v1` tag onto that commit. + +## Local fallback + +`npm run release -- v1.4.5` (see `scripts/release.sh`) builds, commits, tags, and pushes from your machine. It does not create a GitHub Release or move the floating tags, so prefer the workflow flow above for normal releases. diff --git a/package.json b/package.json index ade00a1..21811ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@llm-exe/github-action", - "version": "0.0.0", + "version": "1.0.0", "private": true, "description": "GitHub Action for running llm-exe executors in workflows.", "main": "dist/index.js",