OIDC server-side token refresh #18
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@v8 | |
| 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@v6 | |
| 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@v8 | |
| with: | |
| script: | | |
| 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" | |
| 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@v8 | |
| 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}`; | |
| 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 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(`\n\nWe're sorry this one took so long 😔`); | |
| 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 || '' }} | |
| 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 }} | |
| 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 [ "$BUMPED" = "true" ]; then | |
| echo "| Version bump | ✅ \`${VERSION}\` |" | |
| 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" |