codexmate auto #45
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: 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 | |
| files: ${{ env.PACKAGE_TGZ }} | |
| generate_release_notes: true |