Adds CI/CD workflows #1
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 | |
| # - Dispatches the Docker workflow (and Release, for X.Y.0 tags) for the new tag, | |
| # since tags pushed with GITHUB_TOKEN don't fire `on: push` workflows themselves | |
| # - 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 dispatches the downstream workflows | |
| name: 🔖 Tag | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version to tag (e.g. 1.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: [main] | |
| concurrency: | |
| group: auto-version-and-tag | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| pull-requests: read | |
| actions: 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/main" ]; then | |
| echo "::error::Manual dispatch only allowed from main (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. 1.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\//, /^public\//, /^papervault-(cli|core|init|mcp)\//, | |
| /^Dockerfile$/, /^nginx\.conf$/, /^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.GITHUB_TOKEN }} | |
| - name: 👤 Configure git identity | |
| if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - 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 package-lock.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 }} | |
| 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 'PaperVault 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 | |
| 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" | |
| # Tags pushed with GITHUB_TOKEN don't trigger `on: push` workflows | |
| # (GitHub's recursion guard), but workflow_dispatch via the API does. | |
| - name: 🚀 Dispatch Docker build & release draft | |
| id: dispatch | |
| if: steps.tag.outputs.result == 'created' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VERSION: ${{ steps.tag.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" \ | |
| --ref "$VERSION" -f tag="$VERSION" -f latest=true | |
| echo "Dispatched Docker build for $VERSION" | |
| if printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.0$'; then | |
| gh workflow run release.yml --repo "$GITHUB_REPOSITORY" \ | |
| --ref "$VERSION" -f tag="$VERSION" | |
| echo "Dispatched release draft for $VERSION" | |
| fi | |
| - 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 }} | |
| 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 }} | |
| DISPATCH_OUTCOME: ${{ steps.dispatch.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 [ "$DISPATCH_OUTCOME" = "success" ]; then | |
| echo "| Downstream builds | ✅ Dispatched |" | |
| elif [ "$DISPATCH_OUTCOME" = "failure" ]; then | |
| echo "| Downstream builds | ⚠️ Dispatch failed |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" |