diff --git a/.github/config/labels.json b/.github/config/labels.json new file mode 100644 index 000000000..d935edb97 --- /dev/null +++ b/.github/config/labels.json @@ -0,0 +1,12 @@ +[ + { + "name": "skip-changelog", + "color": "ededed", + "description": "Exclude this PR from the automated changelog update." + }, + { + "name": "auto version bump", + "color": "bfd4f2", + "description": "Automated version bump PRs created by the release workflow." + } +] diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs new file mode 100644 index 000000000..a4f55a2ca --- /dev/null +++ b/.github/scripts/check-changelog-exclusions.mjs @@ -0,0 +1,135 @@ +/** + * Checks if a PR should be excluded from the changelog + * Used by the changelog-ci workflow to determine early if processing should continue + */ + +import { INCLUDED_TYPES } from "./update-changelog.mjs"; + +/** + * Labels that should exclude PRs from the changelog + */ +const EXCLUDED_LABELS = [ + "skip-changelog", + "no-changelog", + "dependencies", + "dependabot", + "auto version bump", + "release", +]; + +/** + * Commit types that should exclude PRs from the changelog + */ +const EXCLUDED_TYPES = [ + "build", + "chore", + "ci", + "docs", + "image", + "style", + "test", +]; + +/** + * All valid commit types (included + excluded) + * Included types are derived from TYPE_TO_SECTION + */ +const ALL_COMMIT_TYPES = [...INCLUDED_TYPES, ...EXCLUDED_TYPES]; + +/** + * Regex to match conventional commit type prefix in PR titles, + * including both included and excluded types. + */ +const typeRegex = new RegExp( + `^(${ALL_COMMIT_TYPES.join("|")})(\\(.+?\\))?!?:`, + "i", +); + +/** + * Checks if the PR has any labels that are in the EXCLUDED_LABELS list. + * The PR should be excluded from the changelog update process if any excluded label is found. + * + * @param {string[]} labels - Array of PR labels + * @returns {boolean} - True if any label is excluded + */ +function hasExcludedLabel(labels) { + return labels.some((label) => + EXCLUDED_LABELS.includes(label.name.toLowerCase()), + ); +} + +/** + * Gets the name of the excluded label, if any. + * @param {string[]} labels - Array of PR labels + * @returns {string|undefined} - The name of the excluded label, if any + */ +function getExcludedLabel(labels) { + return labels.find((label) => + EXCLUDED_LABELS.includes(label.name.toLowerCase()), + )?.name; +} + +/** + * Checks if a PR should be excluded from the changelog + */ +export default async function checkExclusions({ pr, core }) { + try { + const prTitle = pr.title; + + console.log(`🔍 Checking exclusions for PR #${pr.number}: ${prTitle}`); + + let shouldSkip = false; + let skipReason = ""; + + // Check for excluded labels + if (hasExcludedLabel(pr.labels)) { + const excludedLabel = getExcludedLabel(pr.labels); + console.log(`â­ī¸ PR has excluded label "${excludedLabel}". Should skip.`); + shouldSkip = true; + skipReason = `excluded label: ${excludedLabel}`; + } + // Check for conventional commit type + else { + // Match the PR title against the regex to extract the commit type. + const match = prTitle.match(typeRegex); + + // If no conventional commit type is found, skip the PR. + if (!match) { + console.log( + "âš ī¸ No conventional commit type found in PR title. Should skip.", + ); + shouldSkip = true; + skipReason = "no conventional commit type"; + } + // If a commit type is found, check if it's in the excluded types list. + else { + // Extract the commit type from the matched regex and + // convert it to lowercase for comparison. + const type = match[1].toLowerCase(); + + // If the commit type is in the EXCLUDED_TYPES list, skip the PR. + if (EXCLUDED_TYPES.includes(type)) { + console.log( + `âš ī¸ Conventional commit type "${type}" is excluded. Should skip.`, + ); + shouldSkip = true; + skipReason = `excluded type: ${type}`; + } + // If the commit type is NOT in the EXCLUDED_TYPES list, include the PR. + else { + console.log(`✅ PR will be included in changelog (type: ${type})`); + } + } + } + + // Set outputs + core.setOutput("should-skip", shouldSkip.toString()); + // If skipping, provide the reason. + if (shouldSkip) { + core.setOutput("skip-reason", skipReason); + } + } catch (error) { + console.error("❌ Error checking exclusions:", error); + core.setFailed(`Failed to check exclusions: ${error.message}`); + } +} diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs new file mode 100644 index 000000000..bd3cc2ac5 --- /dev/null +++ b/.github/scripts/update-changelog.mjs @@ -0,0 +1,382 @@ +/** + * Updates CHANGELOG.md with merged PR information + * Categorises PRs based on conventional commit types + * Used by the changelog-ci workflow + * + * Note: Exclusion checks (labels, commit types) are handled by check-changelog-exclusions.mjs + * before this script is called, so we can assume the PR should be included. + */ + +import { readFileSync, writeFileSync } from "fs"; + +/** + * Maps conventional commit types to changelog sections + */ +const TYPE_TO_SECTION = { + feat: "Added", + fix: "Fixed", + refactor: "Changed", + perf: "Changed", + revert: "Changed", + remove: "Removed", + security: "Security", + change: "Changed", + deprecate: "Deprecated", +}; + +/** + * Maps commit types to custom display prefixes in changelog entries. + * When a type is listed here, its capitalised name is used as the prefix + * instead of the section name. Add new types here to override the default. + */ +const TYPE_TO_PREFIX = { + revert: "Reverted", + refactor: "Refactored", +}; + +/** + * Indentation used for PR description lines nested under a changelog list item — 2 spaces. + */ +const DESCRIPTION_INDENT = " "; + +/** + * Array of included commit types derived from the keys of TYPE_TO_SECTION object + */ +export const INCLUDED_TYPES = Object.keys(TYPE_TO_SECTION); + +/** + * Build regex pattern to match conventional commit type prefix + * Matches: type(scope)?: or type!: with optional whitespace after colon + */ +const COMMIT_TYPE_REGEX = new RegExp( + `^(${INCLUDED_TYPES.join("|")})(\\(.+?\\))?!?:\\s*`, + "i", +); + +/** + * Extracts the conventional commit type from a PR title + * @param {string} title - PR title + * @returns {string|null} - The type or null if not found + */ +function extractType(title) { + const match = title.match(COMMIT_TYPE_REGEX); + return match ? match[1].toLowerCase() : null; +} + +/** + * Strips the conventional commit type prefix from a PR title + * @param {string} title - PR title + * @returns {string} - Cleaned title + */ +function cleanTitle(title) { + // Remove the type prefix (e.g., "feat: ", "fix(scope): ") + const cleaned = title.replace(COMMIT_TYPE_REGEX, ""); + + if (cleaned.length === 0) return title; // Fallback to original if something went wrong + return cleaned; +} + +/** + * Gets or creates the Unreleased section in the changelog + * @param {string} changelog - Current changelog content + * @returns {object} - { hasUnreleased, lines, unreleasedIndex } + */ +function findOrCreateUnreleased(changelog) { + const lines = changelog.split("\n"); + + // Find if Unreleased section exists — handles both plain "## [Unreleased]" + // and the URL-bearing variant "## [Unreleased](url)" + const unreleasedIndex = lines.findIndex((line) => + line.match(/^## \[?Unreleased\]?/i), + ); + + if (unreleasedIndex !== -1) { + return { hasUnreleased: true, lines, unreleasedIndex }; + } + + // Create Unreleased section — find first release section to insert before it + let insertIndex = -1; + + // Look for the first release section (## [version]) + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^## \[.+\]/)) { + insertIndex = i; + break; + } + } + + // If no release found, append to the end of the document (fallback behaviour) + if (insertIndex === -1) { + insertIndex = lines.length; + } + + // Insert Unreleased section + const unreleasedSection = ["", "## [Unreleased]", ""]; + + lines.splice(insertIndex, 0, ...unreleasedSection); + + return { hasUnreleased: false, lines, unreleasedIndex: insertIndex + 1 }; +} + +/** + * Checks if a PR entry already exists in the Unreleased section + * @param {array} lines - Changelog lines + * @param {number} unreleasedIndex - Index of Unreleased header + * @param {number} prNumber - PR number to check + * @returns {boolean} - True if PR already exists + */ +function isDuplicateEntry(lines, unreleasedIndex, prNumber) { + // Find the next version header (##) or end of file + let nextSectionIndex = lines.length; + for (let i = unreleasedIndex + 1; i < lines.length; i++) { + if (lines[i].startsWith("## ")) { + nextSectionIndex = i; + break; + } + } + + // Check all lines in the Unreleased section for this PR number + const prPattern = new RegExp(`\\[#${prNumber}\\]\\(`); + for (let i = unreleasedIndex + 1; i < nextSectionIndex; i++) { + if (prPattern.test(lines[i])) { + return true; + } + } + + return false; +} + +/** + * Adds a PR entry to the appropriate section within Unreleased + * @param {array} lines - Changelog lines + * @param {number} unreleasedIndex - Index of Unreleased header + * @param {string} section - Section name (Added, Fixed, etc.) + * @param {string} entry - PR entry to add + * @returns {array} - Updated lines + */ +function addEntryToSection(lines, unreleasedIndex, section, entry) { + // Find the next version header (##) or end of file + let nextSectionIndex = lines.length; + for (let i = unreleasedIndex + 1; i < lines.length; i++) { + if (lines[i].startsWith("## ")) { + nextSectionIndex = i; + break; + } + } + + // Look for the section header within Unreleased + let sectionIndex = -1; + for (let i = unreleasedIndex + 1; i < nextSectionIndex; i++) { + if (lines[i].startsWith(`### ${section}`)) { + sectionIndex = i; + break; + } + } + + if (sectionIndex === -1) { + // Section doesn't exist, create it + let insertIndex = unreleasedIndex + 1; + + // Skip blank lines + while (insertIndex < nextSectionIndex && lines[insertIndex].trim() === "") { + insertIndex++; + } + + // Skip all existing sections to add new section at the end + while ( + insertIndex < nextSectionIndex && + lines[insertIndex].startsWith("### ") + ) { + // Skip section header + insertIndex++; + + // Skip all content until the next section header or end of Unreleased + while ( + insertIndex < nextSectionIndex && + !lines[insertIndex].startsWith("### ") + ) { + insertIndex++; + } + } + + // Insert new section + lines.splice(insertIndex, 0, `### ${section}`, "", entry, ""); + } else { + // Section exists, add entry to it + let insertIndex = sectionIndex + 1; + + // Skip blank lines after section header + while (insertIndex < nextSectionIndex && lines[insertIndex].trim() === "") { + insertIndex++; + } + + // Skip existing entries using markers as definitive boundaries. + // For entries without a marker (backward compatibility), stop at the next + // entry title ("- ") or section header ("### "). + while ( + insertIndex < nextSectionIndex && + lines[insertIndex].startsWith("- ") + ) { + insertIndex++; // skip the entry title line + // Advance past description lines/blank lines up to the marker + while ( + insertIndex < nextSectionIndex && + lines[insertIndex] !== "" && + !lines[insertIndex].startsWith("- ") && + !lines[insertIndex].startsWith("### ") + ) { + insertIndex++; + } + // Skip the marker if present + if ( + insertIndex < nextSectionIndex && + lines[insertIndex] === "" + ) { + insertIndex++; + } + // Skip any blank lines between entries + while (insertIndex < nextSectionIndex && lines[insertIndex] === "") { + insertIndex++; + } + } + + // Insert entry + lines.splice(insertIndex, 0, entry); + } + + return lines; +} + +/** + * Formats the PR description with indentation for nesting under a list item + * @param {string|null} prBody - PR description/body text + * @returns {string} - Formatted description string (empty if no body) + */ +function formatPRDescription(prBody) { + if (!prBody || prBody.trim() === "") { + return ""; + } + + // Convert markdown headings to bold text + const withoutHeadings = prBody.replace(/^#{1,6}\s+(.+)$/gm, "**$1**"); + + // Indent each line with 2 spaces to nest under the list item. + // Skip indentation on empty lines to avoid trailing whitespace. + const indented = withoutHeadings + .split("\n") + .map((line) => (line ? `${DESCRIPTION_INDENT}${line}` : "")) + .join("\n"); + + // Always separate the description from the entry title with a blank line so + // that markdown renders the description on its own line. Strip any leading + // newlines from `indented` first to avoid double blank lines when prBody + // itself starts with a blank line. + return `\n\n${indented.replace(/^\n+/, "")}`; +} + +/** + * Builds the full changelog entry line for a PR + * @param {string} type - Conventional commit type (e.g., "feat", "fix", "revert") + * @param {string} section - Section name resolved from TYPE_TO_SECTION + * @param {string} cleanedTitle - PR title with the type prefix stripped + * @param {number} prNumber - PR number + * @param {string} prUrl - PR HTML URL + * @param {string} prAuthor - PR author login + * @param {string|null} prBody - PR body/description + * @returns {string} - Formatted entry line + */ +function buildEntry( + type, + section, + cleanedTitle, + prNumber, + prUrl, + prAuthor, + prBody, +) { + const prefix = TYPE_TO_PREFIX[type] ?? section; + return `- ${prefix} ${cleanedTitle} ([#${prNumber}](${prUrl})) by @${prAuthor}${formatPRDescription(prBody)}\n`; +} + +/** + * Main function to update the changelog + */ +export default async function updateChangelog({ pr, core }) { + try { + const prNumber = pr.number; + const prTitle = pr.title; + const prUrl = pr.html_url; + const prAuthor = pr.user.login; + const prBody = pr.body; + + console.log(`📝 Processing PR #${prNumber}: ${prTitle}`); + + // Extract type from PR title + const type = extractType(prTitle); + if (!type) { + console.log( + `âš ī¸ No valid conventional commit type found in PR title. Skipping changelog update.`, + ); + return; + } + + const section = TYPE_TO_SECTION[type]; + console.log(`📂 Type: ${type} → Section: ${section}`); + + // Read current changelog + const changelogPath = "CHANGELOG.md"; + let changelog = ""; + try { + changelog = readFileSync(changelogPath, "utf8"); + } catch (error) { + console.log("CHANGELOG.md not found, creating new one"); + changelog = "# Changelog\n\n"; + } + + // Get or create Unreleased section + const { lines, unreleasedIndex } = findOrCreateUnreleased(changelog); + + // Check if this PR is already in the changelog + if (isDuplicateEntry(lines, unreleasedIndex, prNumber)) { + console.log( + `â„šī¸ PR #${prNumber} already exists in the changelog. Skipping.`, + ); + return; + } + + // Format PR entry with cleaned title + const cleanedTitle = cleanTitle(prTitle); + const entry = buildEntry( + type, + section, + cleanedTitle, + prNumber, + prUrl, + prAuthor, + prBody, + ); + + // Add entry to the appropriate section + const updatedLines = addEntryToSection( + lines, + unreleasedIndex, + section, + entry, + ); + + // Write updated changelog + const updatedChangelog = updatedLines.join("\n"); + writeFileSync(changelogPath, updatedChangelog); + + console.log(`✅ Updated CHANGELOG.md with PR #${prNumber}`); + + // Set outputs for the workflow to use + core.setOutput("changelog-updated", "true"); + core.setOutput("pr-number", prNumber); + core.setOutput("pr-title", cleanedTitle); + core.setOutput("pr-author", prAuthor); + } catch (error) { + console.error("❌ Error updating changelog:", error); + core.setFailed(`Failed to update changelog: ${error.message}`); + } +} diff --git a/.github/workflows/changelog-ci.yml b/.github/workflows/changelog-ci.yml new file mode 100644 index 000000000..16d4a7386 --- /dev/null +++ b/.github/workflows/changelog-ci.yml @@ -0,0 +1,183 @@ +name: Changelog CI + +on: + pull_request: + types: + - closed + +permissions: + contents: write + pull-requests: write + actions: read + +jobs: + update_changelog: + name: Update Changelog + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Sparse checkout exclusion script + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/scripts/check-changelog-exclusions.mjs + .github/scripts/update-changelog.mjs + sparse-checkout-cone-mode: false + + - name: Check if PR should be excluded + id: check-exclusions + uses: actions/github-script@v7 + with: + script: | + const { default: checkExclusions } = await import('${{ github.workspace }}/.github/scripts/check-changelog-exclusions.mjs'); + return await checkExclusions({ + pr: context.payload.pull_request, + core + }); + + - name: Changelog update skipped + if: steps.check-exclusions.outputs.should-skip == 'true' + run: | + echo "â„šī¸ Changelog update skipped: ${{ steps.check-exclusions.outputs.skip-reason }}" + + - name: Checkout repository + if: steps.check-exclusions.outputs.should-skip == 'false' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Disable sparse checkout + if: steps.check-exclusions.outputs.should-skip == 'false' + run: | + git sparse-checkout disable + + - name: Check for existing changelog PR + if: steps.check-exclusions.outputs.should-skip == 'false' + id: check-existing-changelog-pr + run: | + BASE_BRANCH="${{ github.event.pull_request.base.ref }}" + + # Find existing changelog PR + CHANGELOG_PR=$(gh pr list --base "$BASE_BRANCH" --state open --json number,title,headRefName,body --jq '.[] | select(.title | test("^docs: Update changelog"; "i"))') + + if [ -n "$CHANGELOG_PR" ]; then + PR_NUMBER=$(echo "$CHANGELOG_PR" | jq -r '.number') + PR_BRANCH_NAME=$(echo "$CHANGELOG_PR" | jq -r '.headRefName') + PR_BODY=$(echo "$CHANGELOG_PR" | jq -r '.body') + + echo "📝 Found existing changelog PR #$PR_NUMBER" + echo "changelog-pr-exists=true" >> $GITHUB_OUTPUT + echo "changelog-pr-branch-name=$PR_BRANCH_NAME" >> $GITHUB_OUTPUT + echo "changelog-pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT + + echo "changelog-pr-body<> "$GITHUB_OUTPUT" + echo "$PR_BODY" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "✨ No existing changelog PR found" + echo "changelog-pr-exists=false" >> $GITHUB_OUTPUT + echo "changelog-pr-branch-name=docs/update-changelog" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Configure Git + if: steps.check-exclusions.outputs.should-skip == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Copy changelog script + if: steps.check-exclusions.outputs.should-skip == 'false' + run: cp "${{ github.workspace }}/.github/scripts/update-changelog.mjs" /tmp/update-changelog.mjs + + - name: Checkout changelog branch + if: steps.check-exclusions.outputs.should-skip == 'false' + run: | + BRANCH_NAME="${{ steps.check-existing-changelog-pr.outputs.changelog-pr-branch-name }}" + + if [ "${{ steps.check-existing-changelog-pr.outputs.changelog-pr-exists }}" = "true" ]; then + echo "🔄 Checking out existing branch: $BRANCH_NAME" + git fetch origin $BRANCH_NAME + git checkout $BRANCH_NAME + git pull origin $BRANCH_NAME + else + echo "✨ Creating new branch: $BRANCH_NAME" + git checkout -b $BRANCH_NAME + fi + + - name: Update Changelog + if: steps.check-exclusions.outputs.should-skip == 'false' + id: update-changelog + uses: actions/github-script@v7 + with: + script: | + const { default: updateChangelog } = await import('/tmp/update-changelog.mjs'); + return await updateChangelog({ + pr: context.payload.pull_request, + core + }); + + - name: Prettify Changelog + id: prettify-changelog + if: steps.check-exclusions.outputs.should-skip == 'false' && steps.update-changelog.outputs.changelog-updated == 'true' + run: | + npx prettier --write CHANGELOG.md + echo "✅ Formatted CHANGELOG.md with Prettier" + + - name: Git Diff + id: git-diff + if: steps.check-exclusions.outputs.should-skip == 'false' && steps.update-changelog.outputs.changelog-updated == 'true' + run: | + git diff CHANGELOG.md + + - name: Commit and push changes + if: steps.check-exclusions.outputs.should-skip == 'false' && steps.update-changelog.outputs.changelog-updated == 'true' + run: | + BRANCH_NAME="${{ steps.check-existing-changelog-pr.outputs.changelog-pr-branch-name }}" + + git add CHANGELOG.md + git commit -m "docs: update changelog for PR #${{ steps.update-changelog.outputs.pr-number }}" + + if [ "${{ steps.check-existing-changelog-pr.outputs.changelog-pr-exists }}" = "true" ]; then + git push origin $BRANCH_NAME + else + git push -u origin $BRANCH_NAME + fi + + - name: Create or update changelog PR + if: steps.check-exclusions.outputs.should-skip == 'false' && steps.update-changelog.outputs.changelog-updated == 'true' + run: | + BASE_BRANCH="${{ github.event.pull_request.base.ref }}" + BRANCH_NAME="${{ steps.check-existing-changelog-pr.outputs.changelog-pr-branch-name }}" + PR_NUMBER="${{ steps.update-changelog.outputs.pr-number }}" + CHANGELOG_PR_EXISTS="${{ steps.check-existing-changelog-pr.outputs.changelog-pr-exists }}" + + if [ "$CHANGELOG_PR_EXISTS" = "true" ]; then + # Update existing PR + EXISTING_PR_NUMBER="${{ steps.check-existing-changelog-pr.outputs.changelog-pr-number }}" + EXISTING_BODY="${{ steps.check-existing-changelog-pr.outputs.changelog-pr-body }}" + UPDATED_BODY="$EXISTING_BODY + - #$PR_NUMBER" + + gh pr edit "$EXISTING_PR_NUMBER" --body "$UPDATED_BODY" + echo "✅ Updated changelog PR #$EXISTING_PR_NUMBER" + else + # Create new PR + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$BRANCH_NAME" \ + --title "docs: Update changelog" \ + --body "This PR updates the changelog with merged PRs. + + This is an automated PR created by the changelog workflow. + + ### Included PRs: + - #$PR_NUMBER" + + NEW_PR_NUMBER=$(gh pr view "$BRANCH_NAME" --json number --jq '.number') + echo "✅ Created changelog PR #$NEW_PR_NUMBER" + fi + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..ffaa92b7f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,283 @@ +on: + release: + types: [prereleased, released] + +env: + TAG_NAME: ${{ github.ref_name }} + TARGET_BRANCH: ${{ github.event.release.target_commitish }} + +permissions: + contents: write + pull-requests: write + actions: read + +name: Release Version Update + +jobs: + validate-release-version: + name: Validate Release Version + runs-on: ubuntu-latest + if: github.event_name == 'release' + outputs: + version: ${{ steps.parse-version.outputs.version }} + is-valid: ${{ steps.parse-version.outputs.is-valid }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Parse and Validate Version + id: parse-version + run: | + TAG_NAME="${{ env.TAG_NAME }}" + + # Remove 'v' prefix if present and validate semver format + if [[ $TAG_NAME =~ ^v?([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.-]+)?(\+[a-zA-Z0-9\.-]+)?)$ ]]; then + VERSION=${BASH_REMATCH[1]} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "is-valid=true" >> $GITHUB_OUTPUT + echo "✅ Valid version format: $VERSION" + else + echo "is-valid=false" >> $GITHUB_OUTPUT + echo "❌ Invalid version format: $TAG_NAME" + echo "Version must follow semver format (e.g., v1.0.0, 1.0.0, v1.0.0-beta.1)" + exit 1 + fi + + update-version: + name: Update Version + needs: validate-release-version + runs-on: ubuntu-latest + if: needs.validate-release-version.outputs.is-valid == 'true' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Update Version in cli/version.php + run: | + VERSION="${{ needs.validate-release-version.outputs.version }}" + + # If this is a prerelease, remove any prerelease tags to get base version + if [ "${{ github.event.action }}" = "prereleased" ]; then + BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') + echo "🔄 Prerelease detected: using base version $BASE_VERSION instead of $VERSION" + VERSION="$BASE_VERSION" + fi + + CURRENT_VERSION=$(grep -oP "(?<=\\\$version = ')[^']+" cli/version.php) + + if [ "$VERSION" = "$CURRENT_VERSION" ]; then + echo "â„šī¸ cli/version.php already has version $VERSION, skipping update" + else + echo "đŸ“Ļ Updating cli/version.php from $CURRENT_VERSION to $VERSION" + sed -i "s/\$version = '[^']*';/\$version = '$VERSION';/" cli/version.php + echo "✅ Successfully updated version to $VERSION" + fi + + - name: Update Changelog Release Heading + run: | + VERSION="${{ needs.validate-release-version.outputs.version }}" + TAG_NAME="${{ env.TAG_NAME }}" + REPO="${{ github.repository }}" + CURRENT_DATE=$(date +%Y-%m-%d) + + # Check if CHANGELOG.md exists + if [ ! -f "CHANGELOG.md" ]; then + echo "âš ī¸ CHANGELOG.md not found, skipping changelog update" + exit 0 + fi + + # Check if Unreleased section exists (with or without a trailing URL) + if ! grep -qP "^## \[Unreleased\]" CHANGELOG.md; then + echo "â„šī¸ No [Unreleased] section found in CHANGELOG.md, skipping update" + exit 0 + fi + + # Replace the entire [Unreleased] heading line (including any trailing URL) with + # a versioned release heading that links to the tree at the tag. + RELEASE_URL="${{ github.server_url }}/${REPO}/tree/${TAG_NAME}" + NEW_HEADING="## [${VERSION}](${RELEASE_URL}) - ${CURRENT_DATE}" + + sed -i "s|^## \[Unreleased\].*|${NEW_HEADING}|" CHANGELOG.md + + echo "✅ Updated CHANGELOG.md:" + echo " [Unreleased] → [${VERSION}]" + echo " Date: ${CURRENT_DATE}" + echo " URL: ${RELEASE_URL}" + + - name: Check for Changes + id: git-check + run: | + # Check for any modified or untracked files + if [ -n "$(git status --porcelain)" ]; then + echo "changes=true" >> $GITHUB_OUTPUT + echo "📝 Changes detected:" + git status --porcelain + else + echo "changes=false" >> $GITHUB_OUTPUT + echo "â„šī¸ No changes to commit" + fi + + - name: Create New Branch + if: steps.git-check.outputs.changes == 'true' + id: create-branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e # Exit on any error + BRANCH_NAME="release/${{ env.TAG_NAME }}" + echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + # Check if branch already exists remotely + if git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1; then + echo "❌ Cannot create $BRANCH_NAME branch as it already exists. This may indicate:" + echo " - A previous workflow run failed partway through" + echo " - The branch was manually created" + echo "Please manually delete the branch or check for an existing PR and resolve manually." + exit 1 + fi + + # Create and checkout new branch + git checkout -b "$BRANCH_NAME" || { + echo "❌ Failed to create branch $BRANCH_NAME" + exit 1 + } + echo "✅ Created $BRANCH_NAME branch" + + - name: Commit Changes + if: steps.git-check.outputs.changes == 'true' + id: commit-changes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e # Exit on any error + + # Stage and commit changes + git add cli/version.php CHANGELOG.md || { + echo "❌ Failed to stage files" + exit 1 + } + + git commit -m "chore: version bump and update changelog" || { + echo "❌ Failed to commit changes" + exit 1 + } + echo "✅ Committed changes" + + # Push to remote + BRANCH_NAME="${{ steps.create-branch.outputs.branch-name }}" + git push origin "$BRANCH_NAME" || { + echo "❌ Failed to push changes to remote" + exit 1 + } + echo "✅ Pushed changes to remote on $BRANCH_NAME branch" + + - name: Create Pull Request + if: steps.git-check.outputs.changes == 'true' + id: create-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e # Exit on any error + BRANCH_NAME="${{ steps.create-branch.outputs.branch-name }}" + + echo "Creating pull request on $BRANCH_NAME branch into ${{ env.TARGET_BRANCH }}" + + # Set PR title and body based on release type + RELEASE_TYPE="release" + if [ "${{ github.event.action }}" = "prereleased" ]; then + RELEASE_TYPE="pre-release" + fi + + # Define PR details + # ^ capitalises the first letter + PR_TITLE="${RELEASE_TYPE^} ${{ env.TAG_NAME }}" + PR_BODY="Automated version bump for $RELEASE_TYPE ${{ env.TAG_NAME }}." + PR_LABELS="auto version bump,release" + + # Create pull request + PR_URL=$(gh pr create \ + --base "${{ env.TARGET_BRANCH }}" \ + --head "$BRANCH_NAME" \ + --title "$PR_TITLE" \ + --body "$PR_BODY" \ + --label "$PR_LABELS") || { + echo "❌ Failed to create pull request" + exit 1 + } + echo "✅ Pull request created successfully: $PR_URL" + + # Extract PR number from URL and store as output + PR_NUMBER=$(echo "$PR_URL" | grep -oP '\d+$') + echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "pr-url=$PR_URL" >> $GITHUB_OUTPUT + + # Enable GitHub PR auto-merge + gh pr merge "$PR_URL" --auto --squash || { + echo "❌ Failed to enable auto-merge" + echo "This may be due to:" + echo " - Auto-merge not being enabled in repository settings" + echo " - Required status checks not configured" + echo " - Insufficient permissions" + exit 1 + } + echo "✅ Enabled auto-merge for pull request" + + - name: Wait for PR Merge + if: steps.git-check.outputs.changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ steps.create-pr.outputs.pr-number }}" + + echo "âŗ Waiting for pull request #$PR_NUMBER to be merged..." + + # Wait for PR to be merged (timeout after 5 minutes) + TIMEOUT=300 + ELAPSED=0 + INTERVAL=5 + + while [ $ELAPSED -lt $TIMEOUT ]; do + # Get PR state and merge status in one call + PR_DATA=$(gh pr view "$PR_NUMBER" --json state,mergedAt 2>/dev/null || echo '{"state":"UNKNOWN","mergedAt":null}') + PR_STATE=$(echo "$PR_DATA" | jq -r '.state') + PR_MERGED_AT=$(echo "$PR_DATA" | jq -r '.mergedAt') + + if [ "$PR_STATE" = "MERGED" ] || [ "$PR_MERGED_AT" != "null" ]; then + echo "✅ Pull request successfully merged" + break + fi + + sleep $INTERVAL + ELAPSED=$((ELAPSED + INTERVAL)) + echo "âŗ Still waiting... (${ELAPSED}s elapsed)" + done + + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "❌ Timeout waiting for pull request to merge" + exit 1 + fi + + - name: Update Tag Reference + if: steps.git-check.outputs.changes == 'true' + run: | + # Fetch the latest changes from target branch + git fetch origin ${{ env.TARGET_BRANCH }} + git checkout ${{ env.TARGET_BRANCH }} + git pull origin ${{ env.TARGET_BRANCH }} + + # Move the existing tag to point to the new commit (preserves GitHub release relationship) + git tag ${{ env.TAG_NAME }} -f + git push origin ${{ env.TAG_NAME }} -f + echo "✅ Updated tag ${{ env.TAG_NAME }} to point to release commit" diff --git a/.github/workflows/setup-labels.yml b/.github/workflows/setup-labels.yml new file mode 100644 index 000000000..e97ed7e8a --- /dev/null +++ b/.github/workflows/setup-labels.yml @@ -0,0 +1,33 @@ +name: Setup Labels +on: + workflow_dispatch: + push: + paths: + - ".github/workflows/setup-labels.yml" + - ".github/config/labels.json" +permissions: + pull-requests: write +jobs: + sync-labels: + runs-on: ubuntu-latest + steps: + # Setup (and sync) global labels from the shared profile config + - name: Setup global labels + uses: julbme/gh-action-manage-label@v1 + with: + from: https://raw.githubusercontent.com/yCodeTech/yCodeTech/refs/heads/master/.github/config/labels.json + skip_delete: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Setup (and sync) repository-specific labels from the local config + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup local labels + uses: julbme/gh-action-manage-label@v1 + with: + from: .github/config/labels.json + skip_delete: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}