Skip to content

Adds CI/CD workflows #1

Adds CI/CD workflows

Adds CI/CD workflows #1

Workflow file for this run

# 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"