codexmate auto #76
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| run-name: "${{ github.event.repository.name }} ${{ inputs.tag || 'auto' }}" | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Tag (e.g., v0.0.1). Empty = use package.json version.' | |
| required: false | |
| default: '' | |
| permissions: | |
| contents: write | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - name: Fetch tags | |
| run: git fetch --tags --force | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '18' | |
| cache: 'npm' | |
| - name: Resolve release tag | |
| id: resolve | |
| env: | |
| INPUT_TAG: ${{ inputs.tag }} | |
| run: | | |
| node - <<'NODE' | |
| const { execSync } = require('child_process'); | |
| const fs = require('fs'); | |
| const inputTagRaw = (process.env.INPUT_TAG || '').trim(); | |
| const normalizeTag = (tag) => tag.startsWith('v') ? tag.slice(1) : tag; | |
| const isSemver = (version) => /^\d+\.\d+\.\d+$/.test(version); | |
| const parseSemver = (version) => { | |
| if (!isSemver(version)) return null; | |
| const [major, minor, patch] = version.split('.').map(n => Number(n)); | |
| return { major, minor, patch }; | |
| }; | |
| const compareSemver = (a, b) => { | |
| const pa = parseSemver(a); | |
| const pb = parseSemver(b); | |
| if (!pa || !pb) return 0; | |
| if (pa.major !== pb.major) return pa.major - pb.major; | |
| if (pa.minor !== pb.minor) return pa.minor - pb.minor; | |
| return pa.patch - pb.patch; | |
| }; | |
| const hasTag = (tag) => { | |
| if (!tag) return false; | |
| try { | |
| execSync(`git show-ref --tags --verify --quiet refs/tags/${tag}`, { stdio: 'ignore' }); | |
| return true; | |
| } catch (e) { | |
| return false; | |
| } | |
| }; | |
| let latestTag = ''; | |
| try { | |
| const tagOutput = execSync("git tag --list v* --sort=-v:refname", { encoding: 'utf8' }).trim(); | |
| latestTag = tagOutput.split(/\r?\n/).find(Boolean) || ''; | |
| } catch (e) { | |
| latestTag = ''; | |
| } | |
| const pkg = require('./package.json'); | |
| const pkgVersion = pkg.version; | |
| if (!isSemver(pkgVersion)) { | |
| console.error(`package.json version ${pkgVersion} is not a valid semver.`); | |
| process.exit(1); | |
| } | |
| const latestVersion = latestTag ? normalizeTag(latestTag) : ''; | |
| const latestSemver = latestVersion ? parseSemver(latestVersion) : null; | |
| let resolvedTag = ''; | |
| let expectedVersion = ''; | |
| let mode = ''; | |
| let baseVersion = ''; | |
| let baseSource = ''; | |
| let tagExists = false; | |
| if (inputTagRaw) { | |
| if (!/^v?\d+\.\d+\.\d+$/.test(inputTagRaw)) { | |
| console.error('Invalid tag format. Use vX.Y.Z or X.Y.Z.'); | |
| process.exit(1); | |
| } | |
| resolvedTag = inputTagRaw.startsWith('v') ? inputTagRaw : `v${inputTagRaw}`; | |
| expectedVersion = normalizeTag(resolvedTag); | |
| mode = 'manual'; | |
| tagExists = hasTag(resolvedTag); | |
| baseVersion = tagExists ? expectedVersion : ''; | |
| baseSource = tagExists ? 'input_tag' : 'input_tag_new'; | |
| } else { | |
| mode = 'auto'; | |
| if (latestTag && !latestSemver) { | |
| console.error(`Latest tag ${latestTag} is not a valid semver.`); | |
| process.exit(1); | |
| } | |
| if (latestSemver && compareSemver(pkgVersion, latestVersion) < 0) { | |
| console.error(`package.json version ${pkgVersion} is lower than latest tag ${latestTag}.`); | |
| process.exit(1); | |
| } | |
| resolvedTag = `v${pkgVersion}`; | |
| expectedVersion = pkgVersion; | |
| tagExists = hasTag(resolvedTag); | |
| baseVersion = latestVersion; | |
| baseSource = tagExists ? 'package_tag' : 'package_version'; | |
| } | |
| const envLines = [ | |
| `RELEASE_TAG=${resolvedTag}`, | |
| `RELEASE_VERSION=${expectedVersion}`, | |
| `RELEASE_MODE=${mode}`, | |
| `LATEST_TAG=${latestTag}`, | |
| `PACKAGE_VERSION=${pkgVersion}`, | |
| `BASE_VERSION=${baseVersion}`, | |
| `BASE_SOURCE=${baseSource}`, | |
| `TAG_EXISTS=${tagExists ? 'true' : 'false'}` | |
| ].join('\n') + '\n'; | |
| fs.appendFileSync(process.env.GITHUB_ENV, envLines); | |
| const outputLines = [ | |
| `release_tag=${resolvedTag}`, | |
| `release_version=${expectedVersion}`, | |
| `release_mode=${mode}`, | |
| `latest_tag=${latestTag}`, | |
| `package_version=${pkgVersion}`, | |
| `base_version=${baseVersion}`, | |
| `base_source=${baseSource}`, | |
| `tag_exists=${tagExists ? 'true' : 'false'}` | |
| ].join('\n') + '\n'; | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, outputLines); | |
| const summaryLines = [ | |
| '### Release Preview', | |
| `- mode: ${mode}`, | |
| `- input_tag: ${inputTagRaw || '(empty)'}`, | |
| `- latest_tag: ${latestTag || '(none)'}`, | |
| `- package_version: ${pkgVersion}`, | |
| `- base_version: ${baseVersion || '(none)'}`, | |
| `- base_source: ${baseSource || '(none)'}`, | |
| `- resolved_tag: ${resolvedTag}`, | |
| `- tag_exists: ${tagExists ? 'yes' : 'no'}`, | |
| `- expected_version: ${expectedVersion}` | |
| ].join('\n'); | |
| fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryLines + '\n'); | |
| console.log(`::notice title=Resolved Tag::${resolvedTag}`); | |
| NODE | |
| - name: Checkout target tag | |
| if: ${{ steps.resolve.outputs.tag_exists == 'true' }} | |
| env: | |
| RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} | |
| run: | | |
| git rev-parse "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1 | |
| git checkout "${RELEASE_TAG}" | |
| - name: Verify tag matches package.json version | |
| if: ${{ steps.resolve.outputs.tag_exists == 'true' }} | |
| env: | |
| RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} | |
| run: | | |
| node -e "const pkg=require('./package.json'); const tag=process.env.RELEASE_TAG; const expected='v'+pkg.version; if(tag!==expected){ console.error('Tag '+tag+' does not match package.json version '+expected); process.exit(1);} console.log('Tag matches '+expected);" | |
| - name: Verify package.json matches release tag | |
| if: ${{ steps.resolve.outputs.tag_exists != 'true' }} | |
| env: | |
| RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} | |
| run: | | |
| node -e "const pkg=require('./package.json'); const tag=process.env.RELEASE_TAG; const expected='v'+pkg.version; if(tag!==expected){ console.error('Current commit package.json '+expected+' does not match resolved release tag '+tag); process.exit(1);} console.log('Current package matches '+expected);" | |
| - name: Compute release name | |
| env: | |
| RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} | |
| run: | | |
| node -e "const p=require('./package.json'); const tag=process.env.RELEASE_TAG; const name=p.name.includes('/')? p.name.split('/')[1]: p.name; const value=name+' '+tag; console.log('RELEASE_NAME='+value);" >> "$GITHUB_ENV" | |
| - name: Pack npm artifact | |
| run: | | |
| name=$(node -e "const p=require('./package.json'); const n=p.name.replace('@','').replace('/','-'); process.stdout.write(n+'-'+p.version+'.tgz');") | |
| npm pack | |
| test -f "$name" | |
| echo "PACKAGE_TGZ=$name" >> "$GITHUB_ENV" | |
| - name: Pack standalone tarball | |
| run: | | |
| version=$(node -e "process.stdout.write(require('./package.json').version)") | |
| name="codexmate-${version}-standalone.tar.gz" | |
| npm install --omit=dev --no-fund --no-audit | |
| tar czf "$name" \ | |
| cli.js cli/ lib/ plugins/ web-ui.html web-ui/ \ | |
| node_modules/ package.json LICENSE README.md README.zh.md | |
| echo "STANDALONE_TGZ=$name" >> "$GITHUB_ENV" | |
| - name: Fetch contributors from GitHub API | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} | |
| LATEST_TAG: ${{ steps.resolve.outputs.latest_tag }} | |
| CONTRIBUTORS_FILE: release-contributors.txt | |
| run: | | |
| if [ -z "${LATEST_TAG}" ]; then | |
| echo "::notice title=No previous tag::Skipping contributors fetch for initial release." | |
| echo "" > "${CONTRIBUTORS_FILE}" | |
| exit 0 | |
| fi | |
| if ! command -v gh >/dev/null 2>&1; then | |
| echo "::error title=gh CLI not found::GitHub CLI is required." | |
| exit 1 | |
| fi | |
| tmp_logins=$(mktemp) | |
| trap 'rm -f "${tmp_logins}"' EXIT | |
| # Fetch PR authors in range using base...head comparison | |
| gh pr list \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --limit 500 \ | |
| --json author \ | |
| --jq '.[].author.login' 2>/dev/null | sort -u > "${tmp_logins}" || true | |
| # Fetch merged PRs in range using commits | |
| tmp_merged=$(mktemp) | |
| git log "${LATEST_TAG}...${RELEASE_TAG}" --pretty=format:%s \ | |
| | grep -oE '#[0-9]+' \ | |
| | sed 's/^#//' \ | |
| | sort -u \ | |
| | while read -r pr_number; do | |
| gh pr view "${pr_number}" --repo "${GITHUB_REPOSITORY}" --json author --jq '.author.login' 2>/dev/null || true | |
| done \ | |
| | sort -u > "${tmp_merged}" || true | |
| if [ -s "${tmp_merged}" ]; then | |
| cat "${tmp_merged}" > "${CONTRIBUTORS_FILE}" | |
| elif [ -s "${tmp_logins}" ]; then | |
| cat "${tmp_logins}" > "${CONTRIBUTORS_FILE}" | |
| else | |
| echo "::notice title=No contributors found::No contributors in this range." | |
| echo "" > "${CONTRIBUTORS_FILE}" | |
| fi | |
| - name: Generate release notes from actual commit range | |
| env: | |
| RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} | |
| TAG_EXISTS: ${{ steps.resolve.outputs.tag_exists }} | |
| RELEASE_CHANGELOG_FILE: release-changelog.md | |
| CONTRIBUTORS_FILE: release-contributors.txt | |
| run: | | |
| if [ "${TAG_EXISTS}" = "true" ] && [ ! -f tools/release/changelog.js ]; then | |
| echo "::notice title=Release changelog skipped::tools/release/changelog.js is not present in existing tag ${RELEASE_TAG}." | |
| exit 0 | |
| fi | |
| if [ ! -f tools/release/changelog.js ]; then | |
| echo "::notice title=Release changelog skipped::tools/release/changelog.js is not present in this ref." | |
| exit 0 | |
| fi | |
| node tools/release/changelog.js | |
| test -s "${RELEASE_CHANGELOG_FILE}" | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ steps.resolve.outputs.release_tag }} | |
| target_commitish: ${{ github.sha }} | |
| name: ${{ env.RELEASE_NAME }} | |
| prerelease: false | |
| draft: false | |
| body_path: ${{ env.RELEASE_CHANGELOG_FILE }} | |
| files: | | |
| ${{ env.PACKAGE_TGZ }} | |
| ${{ env.STANDALONE_TGZ }} | |
| generate_release_notes: false |