⬆️ (deps): Bump the github-actions group with 4 updates #144
Workflow file for this run
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
| # Creates a new git tag when a PR is merged, or on manual dispatch | |
| # | |
| # PR trigger flow: | |
| # - Triggered whenever a PR is merged, if that PR made code changes | |
| # - If version wasn't bumped in PR, increment patch version and update package.json | |
| # - Otherwise (if the PR did bump version) we use the new version from package.json | |
| # - Creates and pushes a git tag for the new version | |
| # - That git tag then triggers Docker publishing and release drafting in other CI | |
| # - Add labels and release comments to referenced issues (if applicable) | |
| # - Trigger fresh deploy of docs site, so changelog remains up-to-date | |
| # - Finally, shows summary of actions taken and new tag published | |
| # | |
| # Manual dispatch flow: | |
| # - If a version is provided, sets package.json to that version | |
| # - If no version is provided, increments patch version automatically | |
| # - Creates and pushes a git tag, then triggers docs site rebuild | |
| name: 🔖 Tag | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version to tag (e.g. 4.1.0). Leave blank to auto-increment patch.' | |
| required: false | |
| type: string | |
| # Btw, this is a safe and intentional trigger | |
| # It only runs once reviewed PR merged, and secrets are scoped | |
| pull_request_target: # zizmor: ignore[dangerous-triggers] | |
| types: [closed] | |
| branches: [master] | |
| concurrency: | |
| group: auto-version-and-tag | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| pull-requests: read | |
| issues: write | |
| env: | |
| IS_MANUAL: ${{ github.event_name == 'workflow_dispatch' }} | |
| jobs: | |
| version-and-tag: | |
| if: >- | |
| github.event_name == 'workflow_dispatch' | |
| || github.event.pull_request.merged == true | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: 🔢 Validate manual dispatch | |
| if: env.IS_MANUAL == 'true' | |
| env: | |
| INPUT_VERSION: ${{ inputs.version }} | |
| DISPATCH_REF: ${{ github.ref }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$DISPATCH_REF" != "refs/heads/master" ]; then | |
| echo "::error::Manual dispatch only allowed from master (got: $DISPATCH_REF)" | |
| exit 1 | |
| fi | |
| if [ -n "$INPUT_VERSION" ] && ! printf '%s' "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| echo "::error::Invalid version '${INPUT_VERSION}'. Must be semver (e.g. 4.1.0)." | |
| exit 1 | |
| fi | |
| - name: 📂 Check PR for code changes and version bump | |
| id: check_pr | |
| if: env.IS_MANUAL != 'true' | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const pull_number = context.payload.pull_request.number; | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, { owner, repo, pull_number } | |
| ); | |
| const codePatterns = [ | |
| /^src\//, /^services\//, /^public\//, /^Dockerfile$/, /^yarn.lock$/, /^[^/]+\.js$/, | |
| ]; | |
| const codeChanged = files.some(f => | |
| codePatterns.some(p => p.test(f.filename)) | |
| ); | |
| const pkgChanged = files.some(f => f.filename === 'package.json'); | |
| if (!codeChanged && !pkgChanged) { | |
| core.info('No code or package.json changes, skipping'); | |
| core.setOutput('needs_bump', 'false'); | |
| core.setOutput('needs_tag', 'false'); | |
| return; | |
| } | |
| let versionBumped = false; | |
| if (pkgChanged) { | |
| const mergeSha = context.payload.pull_request.merge_commit_sha; | |
| const { data: mergeCommit } = await github.rest.git.getCommit({ | |
| owner, repo, commit_sha: mergeSha, | |
| }); | |
| const parentSha = mergeCommit.parents[0].sha; | |
| const getVersion = async (ref) => { | |
| const { data } = await github.rest.repos.getContent({ | |
| owner, repo, path: 'package.json', ref, | |
| }); | |
| return JSON.parse(Buffer.from(data.content, 'base64').toString()).version; | |
| }; | |
| const [prevVersion, mergeVersion] = await Promise.all([ | |
| getVersion(parentSha), getVersion(mergeSha), | |
| ]); | |
| versionBumped = prevVersion !== mergeVersion; | |
| core.info(`Version: ${prevVersion} → ${mergeVersion}`); | |
| } | |
| const needsBump = codeChanged && !versionBumped; | |
| const needsTag = codeChanged || versionBumped; | |
| core.info(`Needs bump: ${needsBump}, Needs tag: ${needsTag}`); | |
| core.setOutput('needs_bump', needsBump.toString()); | |
| core.setOutput('needs_tag', needsTag.toString()); | |
| - name: 🛎️ Checkout repository | |
| if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' | |
| uses: actions/checkout@v7 | |
| with: | |
| token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} | |
| - name: 👤 Configure git identity | |
| if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' | |
| run: | | |
| git config user.name "Liss-Bot" | |
| git config user.email "liss-bot@d0h.co" | |
| - name: 🔍 Extract referenced issues | |
| id: issues | |
| if: env.IS_MANUAL != 'true' && steps.check_pr.outputs.needs_tag == 'true' | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const author = context.payload.pull_request.user?.login || ''; | |
| if (author.startsWith('dependabot')) { | |
| core.info('Dependabot PR, skipping issue references'); | |
| core.setOutput('numbers', ''); | |
| return; | |
| } | |
| const body = context.payload.pull_request.body || ''; | |
| const prNumber = String(context.payload.pull_request.number); | |
| const matches = body.match(/#(\d+)(?![a-fA-F0-9])/g); | |
| if (!matches) { | |
| core.info('No issue references found in PR body'); | |
| core.setOutput('numbers', ''); | |
| return; | |
| } | |
| const unique = [...new Set(matches.map(m => m.replace('#', '')))] | |
| .filter(n => n !== prNumber && parseInt(n, 10) > 0); | |
| if (unique.length === 0) { | |
| core.info('No issue references after filtering'); | |
| core.setOutput('numbers', ''); | |
| return; | |
| } | |
| core.info(`Found issue references: ${unique.join(', ')}`); | |
| core.setOutput('numbers', unique.join(',')); | |
| - name: ⬆️ Bump version | |
| id: bump | |
| if: >- | |
| env.IS_MANUAL == 'true' | |
| || steps.check_pr.outputs.needs_bump == 'true' | |
| env: | |
| INPUT_VERSION: ${{ inputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$IS_MANUAL" = "true" ] && [ -n "$INPUT_VERSION" ]; then | |
| npm version "$INPUT_VERSION" --no-git-tag-version --allow-same-version | |
| else | |
| npm version patch --no-git-tag-version | |
| fi | |
| NEW_VERSION=$(node -p "require('./package.json').version") | |
| git add package.json | |
| if git diff --cached --quiet; then | |
| echo "package.json already at $NEW_VERSION, nothing to commit" | |
| echo "bumped=false" >> "$GITHUB_OUTPUT" | |
| else | |
| git commit -m "🔖 Bump version to $NEW_VERSION" | |
| git push | |
| echo "bumped=true" >> "$GITHUB_OUTPUT" | |
| echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: 🏷️ Create and push tag | |
| id: tag | |
| if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number || '' }} | |
| PR_TITLE: ${{ github.event.pull_request.title || '' }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }} | |
| MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha || github.sha }} | |
| ISSUES: ${{ steps.issues.outputs.numbers }} | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(node -p "require('./package.json').version") | |
| git fetch --tags --force | |
| if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then | |
| echo "Tag $VERSION already exists, skipping" | |
| echo "result=existed" >> "$GITHUB_OUTPUT" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| { | |
| printf 'Dashy v%s 🚀\n\n' "$VERSION" | |
| if [ -n "$PR_NUMBER" ]; then | |
| printf 'PR: #%s - %s\n' "$PR_NUMBER" "$PR_TITLE" | |
| else | |
| printf 'Manual release by @%s\n' "$PR_AUTHOR" | |
| fi | |
| if [ -n "$ISSUES" ]; then | |
| printf 'Resolves: %s\n' "$(echo "$ISSUES" | sed 's/,/, #/g; s/^/#/')" | |
| fi | |
| printf 'Author: @%s\n' "$PR_AUTHOR" | |
| printf 'Commit: %s\n' "$MERGE_SHA" | |
| } > tag-message.txt | |
| git tag -a "$VERSION" -F tag-message.txt | |
| git push origin "$VERSION" | |
| echo "result=created" >> "$GITHUB_OUTPUT" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| - name: 🛩️ Label referenced issues | |
| id: label | |
| if: >- | |
| env.IS_MANUAL != 'true' | |
| && steps.check_pr.outputs.needs_tag == 'true' | |
| && steps.issues.outputs.numbers != '' | |
| continue-on-error: true | |
| uses: actions/github-script@v9 | |
| env: | |
| ISSUES: ${{ steps.issues.outputs.numbers }} | |
| with: | |
| github-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const { version } = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); | |
| const labelName = `🛩️ Released ${version}`; | |
| const issues = process.env.ISSUES.split(',').filter(Boolean); | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, | |
| name: labelName, | |
| color: 'EDEDED', | |
| description: `Included in release v${version}`, | |
| }); | |
| core.info(`Created label: ${labelName}`); | |
| } catch (e) { | |
| if (e.status === 422) { | |
| core.info(`Label already exists: ${labelName}`); | |
| } else { | |
| core.warning(`Failed to create label: ${e.message}`); | |
| } | |
| } | |
| const prNumber = context.payload.pull_request.number; | |
| const prAuthor = context.payload.pull_request.user?.login; | |
| const creditAuthor = prAuthor && prAuthor.toLowerCase() !== 'lissy93'; | |
| const marker = `released-${version}`; | |
| // Public sponsors, so we don't ask them to sponsor us again | |
| const sponsors = new Set(); | |
| try { | |
| const query = `query($login: String!, $cursor: String) { | |
| user(login: $login) { sponsors(first: 100, after: $cursor) { | |
| nodes { ... on User { login } ... on Organization { login } } | |
| pageInfo { hasNextPage endCursor } | |
| } } | |
| }`; | |
| let cursor = null; | |
| do { | |
| const { user } = await github.graphql(query, { login: owner, cursor }); | |
| user.sponsors.nodes.forEach(n => n?.login && sponsors.add(n.login.toLowerCase())); | |
| cursor = user.sponsors.pageInfo.hasNextPage ? user.sponsors.pageInfo.endCursor : null; | |
| } while (cursor); | |
| } catch (e) { | |
| core.warning(`Failed to fetch sponsors: ${e.message}`); | |
| } | |
| for (const num of issues) { | |
| const issue_number = parseInt(num, 10); | |
| try { | |
| const [{ data: issue }, comments] = await Promise.all([ | |
| github.rest.issues.get({ owner, repo, issue_number }), | |
| github.rest.issues.listComments({ | |
| owner, repo, issue_number, per_page: 100, | |
| }), | |
| ]); | |
| const alreadyCommented = comments.data.some( | |
| c => c.body?.includes(marker) | |
| ); | |
| if (!alreadyCommented) { | |
| const author = issue.user?.login; | |
| const greeting = author ? `Hey @${author},` : 'Hey,'; | |
| const sixMonthsAgo = new Date(); | |
| sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); | |
| const isOld = new Date(issue.created_at) < sixMonthsAgo; | |
| const sevenDaysAgo = new Date(); | |
| sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); | |
| const isNew = new Date(issue.created_at) > sevenDaysAgo; | |
| const byLine = creditAuthor ? ` by @${prAuthor}` : ''; | |
| const parts = [ | |
| greeting, | |
| `this has now been implemented${byLine} in #${prNumber},`, | |
| `and will be released shortly in ${version} 😇`, | |
| ]; | |
| if (isOld) parts.push(`\nWe're sorry this one took so long 😔`); | |
| if (isNew) { | |
| parts.push(sponsors.has(author?.toLowerCase()) | |
| ? `\nThank you for supporting Dashy's development with your sponsorship 💖` | |
| : `\nIf you're enjoying Dashy, consider ` + | |
| `[sponsoring us](https://github.com/sponsors/lissy93) on GitHub to help with development 💖`); | |
| } | |
| parts.push(`<!-- ${marker} -->`); | |
| const body = parts.join(' '); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number, body, | |
| }); | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number, labels: [labelName, '✅ Fixed'], | |
| }); | |
| core.info(alreadyCommented | |
| ? `Already commented on #${num}, labels applied` | |
| : `Commented and labeled #${num}`); | |
| } catch (e) { | |
| core.warning(`Failed to process #${num}: ${e.message}`); | |
| } | |
| } | |
| - name: 📝 Trigger docs site rebuild | |
| id: docs | |
| if: steps.tag.outputs.result == 'created' | |
| continue-on-error: true | |
| env: | |
| HOOK_URL: ${{ secrets.DOCS_SITE_REBUILD_HOOK }} | |
| VERSION: ${{ steps.tag.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$HOOK_URL" ]; then | |
| echo "::warning::DOCS_SITE_REBUILD_HOOK secret is not set, skipping" | |
| exit 1 | |
| fi | |
| curl -sf -X POST -d '{}' "${HOOK_URL}?trigger_title=v${VERSION}+released" \ | |
| --max-time 15 --retry 2 --retry-max-time 30 | |
| echo "Triggered docs rebuild for v${VERSION}" | |
| - name: 📋 Job summary | |
| if: always() | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number || '' }} | |
| PR_TITLE: ${{ github.event.pull_request.title || '' }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }} | |
| REPO_URL: ${{ github.server_url }}/${{ github.repository }} | |
| NEEDS_BUMP: ${{ steps.check_pr.outputs.needs_bump }} | |
| NEEDS_TAG: ${{ steps.check_pr.outputs.needs_tag }} | |
| ISSUES: ${{ steps.issues.outputs.numbers }} | |
| BUMPED: ${{ steps.bump.outputs.bumped }} | |
| BUMP_SHA: ${{ steps.bump.outputs.sha }} | |
| TAG_OUTCOME: ${{ steps.tag.outcome }} | |
| TAG_RESULT: ${{ steps.tag.outputs.result }} | |
| TAG_VERSION: ${{ steps.tag.outputs.version }} | |
| LABEL_OUTCOME: ${{ steps.label.outcome }} | |
| DOCS_OUTCOME: ${{ steps.docs.outcome }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="${TAG_VERSION:-$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")}" | |
| { | |
| echo "## Auto Version & Tag" | |
| echo "" | |
| echo "| Step | Result |" | |
| echo "|------|--------|" | |
| if [ "$IS_MANUAL" = "true" ]; then | |
| echo "| Trigger | Manual dispatch |" | |
| elif [ -n "$PR_NUMBER" ]; then | |
| echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}): ${PR_TITLE} |" | |
| fi | |
| if [ -n "$PR_AUTHOR" ]; then | |
| echo "| Author | [@${PR_AUTHOR}](${REPO_URL%/*/*}/${PR_AUTHOR}) |" | |
| fi | |
| if [ "$NEEDS_TAG" = "false" ]; then | |
| echo "| Result | ⏭️ No code changes, nothing to do |" | |
| fi | |
| if [ "$BUMPED" = "true" ]; then | |
| echo "| Version bump | ✅ \`${VERSION}\` ([\`${BUMP_SHA:0:7}\`](${REPO_URL}/commit/${BUMP_SHA})) |" | |
| else | |
| echo "| Version bump | ⏭️ Skipped |" | |
| fi | |
| if [ "$TAG_RESULT" = "created" ]; then | |
| echo "| Tag | ✅ [\`${VERSION}\`](${REPO_URL}/releases/tag/${VERSION}) |" | |
| elif [ "$TAG_RESULT" = "existed" ]; then | |
| echo "| Tag | ⏭️ Already exists: \`${VERSION}\` |" | |
| elif [ "$TAG_OUTCOME" = "failure" ]; then | |
| echo "| Tag | ❌ Failed |" | |
| else | |
| echo "| Tag | ⏭️ Skipped |" | |
| fi | |
| if [ "$DOCS_OUTCOME" = "success" ]; then | |
| echo "| Docs rebuild | ✅ Triggered |" | |
| elif [ "$DOCS_OUTCOME" = "failure" ]; then | |
| echo "| Docs rebuild | ⚠️ Failed |" | |
| fi | |
| if [ -n "$ISSUES" ]; then | |
| ISSUE_LINKS=$(echo "$ISSUES" | tr ',' '\n' | sed "s|.*|[#&](${REPO_URL}/issues/&)|" | paste -sd ' ' -) | |
| if [ "$LABEL_OUTCOME" = "success" ]; then | |
| echo "| Issues labeled | ✅ ${ISSUE_LINKS} |" | |
| else | |
| echo "| Issues labeled | ⚠️ ${ISSUE_LINKS} |" | |
| fi | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" |