|
| 1 | +# Creates a new git tag when a PR is merged, or on manual dispatch |
| 2 | +# |
| 3 | +# PR trigger flow: |
| 4 | +# - Triggered whenever a PR is merged, if that PR made code changes |
| 5 | +# - If version wasn't bumped in PR, increment patch version and update package.json |
| 6 | +# - Otherwise (if the PR did bump version) we use the new version from package.json |
| 7 | +# - Creates and pushes a git tag for the new version |
| 8 | +# - That git tag then triggers Docker publishing and AWS deployment in other CI |
| 9 | +# - Add tags to issues from newly released features/fixes (if applicable) |
| 10 | +# - Finally, shows summary of actions taken and new tag published |
| 11 | +# |
| 12 | +# Manual dispatch flow: |
| 13 | +# - If a version is provided, sets package.json to that version |
| 14 | +# - If no version is provided, increments patch version automatically |
| 15 | +# - Creates and pushes a git tag for the new version |
| 16 | +name: 🔖 Auto Version & Tag |
| 17 | + |
| 18 | +on: |
| 19 | + workflow_dispatch: |
| 20 | + inputs: |
| 21 | + version: |
| 22 | + description: 'Version to release (e.g. 2.1.0). Leave blank to auto-increment patch.' |
| 23 | + required: false |
| 24 | + type: string |
| 25 | + pull_request_target: |
| 26 | + types: [closed] |
| 27 | + branches: [master] |
| 28 | + |
| 29 | +concurrency: |
| 30 | + group: auto-version-and-tag |
| 31 | + cancel-in-progress: false |
| 32 | + |
| 33 | +permissions: |
| 34 | + contents: write |
| 35 | + pull-requests: read |
| 36 | + issues: write |
| 37 | + |
| 38 | +env: |
| 39 | + IS_MANUAL: ${{ github.event_name == 'workflow_dispatch' }} |
| 40 | + |
| 41 | +jobs: |
| 42 | + version-and-tag: |
| 43 | + if: >- |
| 44 | + github.event_name == 'workflow_dispatch' |
| 45 | + || github.event.pull_request.merged == true |
| 46 | + runs-on: ubuntu-latest |
| 47 | + steps: |
| 48 | + - name: Validate manual version input 🔢 |
| 49 | + if: env.IS_MANUAL == 'true' && inputs.version != '' |
| 50 | + env: |
| 51 | + INPUT_VERSION: ${{ inputs.version }} |
| 52 | + run: | |
| 53 | + if ! echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then |
| 54 | + echo "::error::Invalid version '${INPUT_VERSION}'. Must be semver (e.g. 2.1.0)." |
| 55 | + exit 1 |
| 56 | + fi |
| 57 | +
|
| 58 | + - name: Check PR for code changes and version bump 📂 |
| 59 | + id: check_pr |
| 60 | + if: env.IS_MANUAL != 'true' |
| 61 | + uses: actions/github-script@v9 |
| 62 | + with: |
| 63 | + script: | |
| 64 | + const { owner, repo } = context.repo; |
| 65 | + const pull_number = context.payload.pull_request.number; |
| 66 | +
|
| 67 | + const files = await github.paginate( |
| 68 | + github.rest.pulls.listFiles, { owner, repo, pull_number } |
| 69 | + ); |
| 70 | + const codePatterns = [ |
| 71 | + /^src\//, /^api\//, /^public\//, /^Dockerfile$/, /^[^/]+\.(js|mjs)$/, |
| 72 | + ]; |
| 73 | + const codeChanged = files.some(f => |
| 74 | + codePatterns.some(p => p.test(f.filename)) |
| 75 | + ); |
| 76 | + const pkgChanged = files.some(f => f.filename === 'package.json'); |
| 77 | +
|
| 78 | + if (!codeChanged && !pkgChanged) { |
| 79 | + core.info('No code or package.json changes, skipping'); |
| 80 | + core.setOutput('needs_bump', 'false'); |
| 81 | + core.setOutput('needs_tag', 'false'); |
| 82 | + return; |
| 83 | + } |
| 84 | +
|
| 85 | + let versionBumped = false; |
| 86 | + if (pkgChanged) { |
| 87 | + const mergeSha = context.payload.pull_request.merge_commit_sha; |
| 88 | + const { data: mergeCommit } = await github.rest.git.getCommit({ |
| 89 | + owner, repo, commit_sha: mergeSha, |
| 90 | + }); |
| 91 | + const parentSha = mergeCommit.parents[0].sha; |
| 92 | + const getVersion = async (ref) => { |
| 93 | + const { data } = await github.rest.repos.getContent({ |
| 94 | + owner, repo, path: 'package.json', ref, |
| 95 | + }); |
| 96 | + return JSON.parse(Buffer.from(data.content, 'base64').toString()).version; |
| 97 | + }; |
| 98 | + const [prevVersion, mergeVersion] = await Promise.all([ |
| 99 | + getVersion(parentSha), getVersion(mergeSha), |
| 100 | + ]); |
| 101 | + versionBumped = prevVersion !== mergeVersion; |
| 102 | + core.info(`Version: ${prevVersion} → ${mergeVersion}`); |
| 103 | + } |
| 104 | +
|
| 105 | + const needsBump = codeChanged && !versionBumped; |
| 106 | + const needsTag = codeChanged || versionBumped; |
| 107 | + core.info(`Needs bump: ${needsBump}, Needs tag: ${needsTag}`); |
| 108 | + core.setOutput('needs_bump', needsBump.toString()); |
| 109 | + core.setOutput('needs_tag', needsTag.toString()); |
| 110 | +
|
| 111 | + - name: Checkout repository 🛎️ |
| 112 | + if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' |
| 113 | + uses: actions/checkout@v6 |
| 114 | + with: |
| 115 | + token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} |
| 116 | + |
| 117 | + - name: Configure git identity 👤 |
| 118 | + if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' |
| 119 | + run: | |
| 120 | + git config user.name "Liss-Bot" |
| 121 | + git config user.email "liss-bot@d0h.co" |
| 122 | +
|
| 123 | + - name: Extract referenced issues 🔍 |
| 124 | + id: issues |
| 125 | + if: env.IS_MANUAL != 'true' && steps.check_pr.outputs.needs_tag == 'true' |
| 126 | + uses: actions/github-script@v9 |
| 127 | + with: |
| 128 | + script: | |
| 129 | + const body = context.payload.pull_request.body || ''; |
| 130 | + const prNumber = String(context.payload.pull_request.number); |
| 131 | + const matches = body.match(/#(\d+)(?![a-fA-F0-9])/g); |
| 132 | + if (!matches) { |
| 133 | + core.info('No issue references found in PR body'); |
| 134 | + core.setOutput('numbers', ''); |
| 135 | + return; |
| 136 | + } |
| 137 | + const unique = [...new Set(matches.map(m => m.replace('#', '')))] |
| 138 | + .filter(n => n !== prNumber && parseInt(n, 10) > 0); |
| 139 | + if (unique.length === 0) { |
| 140 | + core.info('No issue references after filtering'); |
| 141 | + core.setOutput('numbers', ''); |
| 142 | + return; |
| 143 | + } |
| 144 | + core.info(`Found issue references: ${unique.join(', ')}`); |
| 145 | + core.setOutput('numbers', unique.join(',')); |
| 146 | +
|
| 147 | + - name: Bump version ⬆️ |
| 148 | + if: >- |
| 149 | + env.IS_MANUAL == 'true' |
| 150 | + || steps.check_pr.outputs.needs_bump == 'true' |
| 151 | + env: |
| 152 | + INPUT_VERSION: ${{ inputs.version }} |
| 153 | + run: | |
| 154 | + if [ "$IS_MANUAL" = "true" ] && [ -n "$INPUT_VERSION" ]; then |
| 155 | + npm version "$INPUT_VERSION" --no-git-tag-version --allow-same-version |
| 156 | + else |
| 157 | + npm version patch --no-git-tag-version |
| 158 | + fi |
| 159 | + git add package.json |
| 160 | + git commit -m "🔖 Bump version to $(node -p "require('./package.json').version")" |
| 161 | + git push |
| 162 | +
|
| 163 | + - name: Create and push tag 🏷️ |
| 164 | + id: tag |
| 165 | + if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' |
| 166 | + env: |
| 167 | + PR_NUMBER: ${{ github.event.pull_request.number || '' }} |
| 168 | + PR_TITLE: ${{ github.event.pull_request.title || '' }} |
| 169 | + PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }} |
| 170 | + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha || github.sha }} |
| 171 | + ISSUES: ${{ steps.issues.outputs.numbers }} |
| 172 | + run: | |
| 173 | + VERSION=$(node -p "require('./package.json').version") |
| 174 | + git fetch --tags --force |
| 175 | + if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then |
| 176 | + echo "Tag $VERSION already exists, skipping" |
| 177 | + exit 0 |
| 178 | + fi |
| 179 | +
|
| 180 | + { |
| 181 | + printf 'Web-Check v%s 🚀\n\n' "$VERSION" |
| 182 | + if [ -n "$PR_NUMBER" ]; then |
| 183 | + printf 'PR: #%s - %s\n' "$PR_NUMBER" "$PR_TITLE" |
| 184 | + else |
| 185 | + printf 'Manual release by @%s\n' "$PR_AUTHOR" |
| 186 | + fi |
| 187 | + if [ -n "$ISSUES" ]; then |
| 188 | + printf 'Resolves: %s\n' "$(echo "$ISSUES" | sed 's/,/, #/g; s/^/#/')" |
| 189 | + fi |
| 190 | + printf 'Author: @%s\n' "$PR_AUTHOR" |
| 191 | + printf 'Commit: %s\n' "$MERGE_SHA" |
| 192 | + } > tag-message.txt |
| 193 | +
|
| 194 | + git tag -a "$VERSION" -F tag-message.txt |
| 195 | + git push origin "$VERSION" |
| 196 | +
|
| 197 | + - name: Label referenced issues 🛩️ |
| 198 | + id: label |
| 199 | + if: >- |
| 200 | + env.IS_MANUAL != 'true' |
| 201 | + && steps.check_pr.outputs.needs_tag == 'true' |
| 202 | + && steps.issues.outputs.numbers != '' |
| 203 | + continue-on-error: true |
| 204 | + uses: actions/github-script@v9 |
| 205 | + env: |
| 206 | + ISSUES: ${{ steps.issues.outputs.numbers }} |
| 207 | + with: |
| 208 | + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} |
| 209 | + script: | |
| 210 | + const { owner, repo } = context.repo; |
| 211 | + const { version } = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); |
| 212 | + const labelName = `🛩️ Released ${version}`; |
| 213 | + const issues = process.env.ISSUES.split(',').filter(Boolean); |
| 214 | +
|
| 215 | + try { |
| 216 | + await github.rest.issues.createLabel({ |
| 217 | + owner, repo, |
| 218 | + name: labelName, |
| 219 | + color: 'EDEDED', |
| 220 | + description: `Included in release v${version}`, |
| 221 | + }); |
| 222 | + core.info(`Created label: ${labelName}`); |
| 223 | + } catch (e) { |
| 224 | + if (e.status === 422) { |
| 225 | + core.info(`Label already exists: ${labelName}`); |
| 226 | + } else { |
| 227 | + core.warning(`Failed to create label: ${e.message}`); |
| 228 | + } |
| 229 | + } |
| 230 | +
|
| 231 | + const prNumber = context.payload.pull_request.number; |
| 232 | + const prAuthor = context.payload.pull_request.user?.login; |
| 233 | + const creditAuthor = prAuthor && prAuthor.toLowerCase() !== 'lissy93'; |
| 234 | + const marker = `released-${version}`; |
| 235 | + for (const num of issues) { |
| 236 | + const issue_number = parseInt(num, 10); |
| 237 | + try { |
| 238 | + const [{ data: issue }, comments] = await Promise.all([ |
| 239 | + github.rest.issues.get({ owner, repo, issue_number }), |
| 240 | + github.rest.issues.listComments({ |
| 241 | + owner, repo, issue_number, per_page: 100, |
| 242 | + }), |
| 243 | + ]); |
| 244 | + const alreadyCommented = comments.data.some( |
| 245 | + c => c.body?.includes(marker) |
| 246 | + ); |
| 247 | + if (!alreadyCommented) { |
| 248 | + const author = issue.user?.login; |
| 249 | + const greeting = author ? `Hey @${author},` : 'Hey,'; |
| 250 | + const sixMonthsAgo = new Date(); |
| 251 | + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); |
| 252 | + const isOld = new Date(issue.created_at) < sixMonthsAgo; |
| 253 | + const byLine = creditAuthor ? ` by @${prAuthor}` : ''; |
| 254 | + const parts = [ |
| 255 | + greeting, |
| 256 | + `this has now been implemented${byLine} in #${prNumber},`, |
| 257 | + `and will be released shortly in ${version} 😇`, |
| 258 | + ]; |
| 259 | + if (isOld) parts.push(`\n\nWe're sorry this one took so long 😔`); |
| 260 | + parts.push(`<!-- ${marker} -->`); |
| 261 | + const body = parts.join(' '); |
| 262 | + await github.rest.issues.createComment({ |
| 263 | + owner, repo, issue_number, body, |
| 264 | + }); |
| 265 | + } |
| 266 | + await github.rest.issues.addLabels({ |
| 267 | + owner, repo, issue_number, labels: [labelName, '✅ Fixed'], |
| 268 | + }); |
| 269 | + core.info(alreadyCommented |
| 270 | + ? `Already commented on #${num}, labels applied` |
| 271 | + : `Commented and labeled #${num}`); |
| 272 | + } catch (e) { |
| 273 | + core.warning(`Failed to process #${num}: ${e.message}`); |
| 274 | + } |
| 275 | + } |
| 276 | +
|
| 277 | + - name: Job summary 📋 |
| 278 | + if: always() |
| 279 | + env: |
| 280 | + PR_NUMBER: ${{ github.event.pull_request.number || '' }} |
| 281 | + PR_TITLE: ${{ github.event.pull_request.title || '' }} |
| 282 | + REPO_URL: ${{ github.server_url }}/${{ github.repository }} |
| 283 | + NEEDS_BUMP: ${{ steps.check_pr.outputs.needs_bump }} |
| 284 | + NEEDS_TAG: ${{ steps.check_pr.outputs.needs_tag }} |
| 285 | + ISSUES: ${{ steps.issues.outputs.numbers }} |
| 286 | + TAG_OUTCOME: ${{ steps.tag.outcome }} |
| 287 | + LABEL_OUTCOME: ${{ steps.label.outcome }} |
| 288 | + run: | |
| 289 | + VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") |
| 290 | +
|
| 291 | + { |
| 292 | + echo "## 🔖 Auto Version & Tag" |
| 293 | + echo "" |
| 294 | + echo "| Step | Result |" |
| 295 | + echo "|------|--------|" |
| 296 | +
|
| 297 | + if [ "$IS_MANUAL" = "true" ]; then |
| 298 | + echo "| Trigger | Manual dispatch |" |
| 299 | + elif [ -n "$PR_NUMBER" ]; then |
| 300 | + echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}) — ${PR_TITLE} |" |
| 301 | + fi |
| 302 | +
|
| 303 | + if [ "$IS_MANUAL" = "true" ] || [ "$NEEDS_BUMP" = "true" ]; then |
| 304 | + echo "| Version bump | ✅ \`${VERSION}\` |" |
| 305 | + else |
| 306 | + echo "| Version bump | ⏭️ Skipped |" |
| 307 | + fi |
| 308 | +
|
| 309 | + if [ "$TAG_OUTCOME" = "success" ]; then |
| 310 | + echo "| Tag | ✅ [\`${VERSION}\`](${REPO_URL}/releases/tag/${VERSION}) |" |
| 311 | + elif [ "$IS_MANUAL" = "true" ] || [ "$NEEDS_TAG" = "true" ]; then |
| 312 | + echo "| Tag | ❌ Failed |" |
| 313 | + else |
| 314 | + echo "| Tag | ⏭️ Skipped |" |
| 315 | + fi |
| 316 | +
|
| 317 | + if [ -n "$ISSUES" ]; then |
| 318 | + ISSUE_LINKS=$(echo "$ISSUES" | tr ',' '\n' | sed "s|.*|[#&](${REPO_URL}/issues/&)|" | paste -sd ' ' -) |
| 319 | + if [ "$LABEL_OUTCOME" = "success" ]; then |
| 320 | + echo "| Issues labeled | ✅ ${ISSUE_LINKS} |" |
| 321 | + else |
| 322 | + echo "| Issues labeled | ⚠️ ${ISSUE_LINKS} |" |
| 323 | + fi |
| 324 | + fi |
| 325 | + } >> "$GITHUB_STEP_SUMMARY" |
0 commit comments