diff --git a/.github/workflows/_test-gh.yaml b/.github/workflows/_test-gh.yaml index ea84089..72801c4 100644 --- a/.github/workflows/_test-gh.yaml +++ b/.github/workflows/_test-gh.yaml @@ -1,4 +1,4 @@ -name: Test GitHub Release Workflow +name: Test GitHub Release Workflow (git-cliff) on: pull_request: @@ -9,82 +9,79 @@ on: - ".github/workflows/gh-release.yaml" jobs: - test_semantic_release_dry_run: + test_dry_run: + name: Test dry-run (whole repo) uses: ./.github/workflows/gh-release.yaml with: - use_semantic_release: true dry_run: true - test_outputs: - needs: test_semantic_release_dry_run + test_dry_run_outputs: + name: Validate dry-run outputs + needs: test_dry_run runs-on: ubuntu-latest steps: - - name: Display all outputs + - name: Display outputs run: | - echo "new_release_published: ${{ needs.test_semantic_release_dry_run.outputs.new_release_published }}" - echo "new_release_version: ${{ needs.test_semantic_release_dry_run.outputs.new_release_version }}" - echo "new_release_major_version: ${{ needs.test_semantic_release_dry_run.outputs.new_release_major_version }}" - echo "new_release_minor_version: ${{ needs.test_semantic_release_dry_run.outputs.new_release_minor_version }}" - echo "new_release_patch_version: ${{ needs.test_semantic_release_dry_run.outputs.new_release_patch_version }}" - echo "new_release_git_tag: ${{ needs.test_semantic_release_dry_run.outputs.new_release_git_tag }}" - echo "new_release_git_head: ${{ needs.test_semantic_release_dry_run.outputs.new_release_git_head }}" + echo "new_release_published: ${{ needs.test_dry_run.outputs.new_release_published }}" + echo "new_release_version: ${{ needs.test_dry_run.outputs.new_release_version }}" + echo "new_release_major_version: ${{ needs.test_dry_run.outputs.new_release_major_version }}" + echo "new_release_minor_version: ${{ needs.test_dry_run.outputs.new_release_minor_version }}" + echo "new_release_patch_version: ${{ needs.test_dry_run.outputs.new_release_patch_version }}" + echo "new_release_git_tag: ${{ needs.test_dry_run.outputs.new_release_git_tag }}" + echo "dry_run: ${{ needs.test_dry_run.outputs.dry_run }}" - - name: Validate outputs are set + - name: Validate dry_run flag run: | - if [ "${{ needs.test_semantic_release_dry_run.outputs.new_release_published }}" == "true" ]; then - echo "✅ New release will be published" - echo " Version: ${{ needs.test_semantic_release_dry_run.outputs.new_release_version }}" - echo " Git tag: ${{ needs.test_semantic_release_dry_run.outputs.new_release_git_tag }}" - else - echo "ℹ️ No new release needed" + if [ "${{ needs.test_dry_run.outputs.dry_run }}" != "true" ]; then + echo "::error::Expected dry_run to be true" + exit 1 fi + echo "Dry run flag correctly set" - test_custom_tag_format: + test_scoped_dry_run: + name: Test dry-run (scoped with service_name) uses: ./.github/workflows/gh-release.yaml with: - use_semantic_release: true + service_name: test-service + service_path: tests/lambda/python dry_run: true - tag_format: "foo-v${version}" - test_working_directory: - uses: ./.github/workflows/gh-release.yaml - with: - working_directory: tests/lambda/python - dry_run: true - - test_working_directory_outputs: - needs: test_working_directory + test_scoped_outputs: + name: Validate scoped outputs + needs: test_scoped_dry_run runs-on: ubuntu-latest steps: - - name: Display all outputs + - name: Display outputs run: | - echo "=== Working Directory Test Outputs ===" - echo "new_release_published: ${{ needs.test_working_directory.outputs.new_release_published }}" - echo "new_release_version: ${{ needs.test_working_directory.outputs.new_release_version }}" - echo "new_release_major_version: ${{ needs.test_working_directory.outputs.new_release_major_version }}" - echo "new_release_minor_version: ${{ needs.test_working_directory.outputs.new_release_minor_version }}" - echo "new_release_patch_version: ${{ needs.test_working_directory.outputs.new_release_patch_version }}" - echo "new_release_git_tag: ${{ needs.test_working_directory.outputs.new_release_git_tag }}" - echo "new_release_git_head: ${{ needs.test_working_directory.outputs.new_release_git_head }}" + echo "new_release_published: ${{ needs.test_scoped_dry_run.outputs.new_release_published }}" + echo "new_release_version: ${{ needs.test_scoped_dry_run.outputs.new_release_version }}" + echo "new_release_git_tag: ${{ needs.test_scoped_dry_run.outputs.new_release_git_tag }}" - - name: Validate first release + - name: Validate scoped tag format + if: needs.test_scoped_dry_run.outputs.new_release_published == 'true' run: | - if [ "${{ needs.test_working_directory.outputs.new_release_published }}" != "true" ]; then - echo "❌ Expected new_release_published to be true" + TAG="${{ needs.test_scoped_dry_run.outputs.new_release_git_tag }}" + if [[ "$TAG" != test-service-v* ]]; then + echo "::error::Expected tag to start with 'test-service-v', got: $TAG" exit 1 fi + echo "Scoped tag format correct: $TAG" - if [ "${{ needs.test_working_directory.outputs.new_release_version }}" != "1.0.0" ]; then - echo "❌ Expected version to be 1.0.0 (first release), got: ${{ needs.test_working_directory.outputs.new_release_version }}" - exit 1 - fi + test_pr_dry_run: + name: Test PR context (auto dry-run) + uses: ./.github/workflows/gh-release.yaml + with: + dry_run: false - if [ "${{ needs.test_working_directory.outputs.new_release_git_tag }}" != "python-lambda-v1.0.0" ]; then - echo "❌ Expected tag to be python-lambda-v1.0.0, got: ${{ needs.test_working_directory.outputs.new_release_git_tag }}" + test_pr_dry_run_outputs: + name: Validate PR forces dry-run + needs: test_pr_dry_run + runs-on: ubuntu-latest + steps: + - name: Validate PR context forces dry_run + run: | + if [ "${{ needs.test_pr_dry_run.outputs.dry_run }}" != "true" ]; then + echo "::error::Expected dry_run to be true in PR context" exit 1 fi - - echo "✅ Working directory test passed!" - echo " Version: ${{ needs.test_working_directory.outputs.new_release_version }}" - echo " Git tag: ${{ needs.test_working_directory.outputs.new_release_git_tag }}" - echo " Tag format from .releaserc.json was correctly applied" + echo "PR context correctly forces dry-run" diff --git a/.github/workflows/_test-gitops-image-tag.yaml b/.github/workflows/_test-gitops-image-tag.yaml new file mode 100644 index 0000000..985de41 --- /dev/null +++ b/.github/workflows/_test-gitops-image-tag.yaml @@ -0,0 +1,56 @@ +name: Test GitOps Image Tag Workflow + +on: + pull_request: + branches: + - main + paths: + - ".github/workflows/_test-gitops-image-tag.yaml" + - ".github/workflows/gitops-image-tag.yaml" + +jobs: + test_image_mode_dry_run: + name: Test image mode (dry-run) + uses: ./.github/workflows/gitops-image-tag.yaml + with: + image_tag: "abc1234" + image_name: "my-app" + values_files: "tests/gitops-image-tag/values.yaml" + create_pr: false + dry_run: true + + test_key_mode_dry_run: + name: Test key mode (dry-run) + uses: ./.github/workflows/gitops-image-tag.yaml + with: + image_tag: "abc1234" + image_tag_keys: | + webapp.image.tag + sidecar.image.tag + values_files: "tests/gitops-image-tag/values.yaml" + create_pr: false + dry_run: true + + test_validation_fails_without_mode: + name: Test validation (no image_name or keys) + uses: ./.github/workflows/gitops-image-tag.yaml + with: + image_tag: "abc1234" + values_files: "tests/gitops-image-tag/values.yaml" + create_pr: false + dry_run: true + # This job is expected to fail — validation rejects missing image_name + image_tag_keys + continue-on-error: true + + validate_failure: + name: Validate input validation works + needs: test_validation_fails_without_mode + runs-on: ubuntu-latest + steps: + - name: Check that validation job failed + run: | + if [ "${{ needs.test_validation_fails_without_mode.result }}" != "failure" ]; then + echo "::error::Expected validation job to fail when neither image_name nor image_tag_keys is provided" + exit 1 + fi + echo "Input validation correctly rejected missing mode inputs" diff --git a/.github/workflows/gh-release-on-main.yaml b/.github/workflows/gh-release-on-main.yaml deleted file mode 100644 index 8123f49..0000000 --- a/.github/workflows/gh-release-on-main.yaml +++ /dev/null @@ -1,102 +0,0 @@ -name: Github Conventional Commit Release - -on: - workflow_call: - inputs: - metadata_file: - description: "File path containing the extra metadata to append to the release version, if not specified the standard semver is applied" - type: string - default: "" - update_version_aliases: - description: "Automatically update version alias tags (e.g., v1 and v1.2) to point to the latest release." - type: boolean - default: true - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Retrieve Merge Commit Message - id: merge_commit_message - run: | - COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1) - echo "COMMIT_MESSAGE=$COMMIT_MESSAGE" >> $GITHUB_ENV - - - name: Read metadata from file - id: metadata - run: | - if [ -n "${{ inputs.metadata_file }}" ] && [ -f "${{ inputs.metadata_file }}" ]; then - METADATA_CONTENT=$(cat ${{ inputs.metadata_file }}) - echo "METADATA_CONTENT=$METADATA_CONTENT" >> $GITHUB_ENV - else - echo "METADATA_CONTENT=" >> $GITHUB_ENV - fi - - name: Determine version increment - id: version_increment - run: | - METADATA_CONTENT=${{ env.METADATA_CONTENT }} - - git fetch --tags - CURRENT_TAG=$(git tag | sort -V | tail -1) - if [ "$CURRENT_TAG" = "" ]; then - CURRENT_TAG="v0.0.1" - fi - - CURRENT_TAG=$(echo "$CURRENT_TAG" | awk -F'+' '{print $1}') - MAJOR=$(echo $CURRENT_TAG | awk -F. '{print $1}' | sed 's/v//') - MINOR=$(echo $CURRENT_TAG | awk -F. '{print $2}') - PATCH=$(echo $CURRENT_TAG | awk -F. '{print $3}') - - COMMIT_MESSAGE="${{ env.COMMIT_MESSAGE }}" - - if [[ "$COMMIT_MESSAGE" =~ ^feat!: ]]; then - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - elif [[ "$COMMIT_MESSAGE" =~ ^feat: ]]; then - MINOR=$((MINOR + 1)) - PATCH=0 - elif [[ "$COMMIT_MESSAGE" =~ ^fix: ]]; then - PATCH=$((PATCH + 1)) - else - echo "Nothing to release" - echo "RELEASE=false" >> $GITHUB_OUTPUT - exit 0 - fi - - if [ -n "$METADATA_CONTENT" ]; then - NEW_TAG="v$MAJOR.$MINOR.$PATCH+$METADATA_CONTENT" - else - NEW_TAG="v$MAJOR.$MINOR.$PATCH" - fi - - MAJOR_VERSION="v$MAJOR" - MINOR_VERSION="v$MAJOR.$MINOR" - echo "TAG $NEW_TAG" - echo "NEW_TAG=$NEW_TAG" >> $GITHUB_OUTPUT - echo "MAJOR_VERSION=$MAJOR_VERSION" >> $GITHUB_ENV - echo "MINOR_VERSION=$MINOR_VERSION" >> $GITHUB_ENV - echo "RELEASE=true" >> $GITHUB_OUTPUT - - - name: Release - if: steps.version_increment.outputs.RELEASE == 'true' - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 - with: - tag_name: ${{ steps.version_increment.outputs.NEW_TAG }} - generate_release_notes: true - - - name: Update major and minor version tags - if: steps.version_increment.outputs.RELEASE == 'true' && inputs.update_version_aliases - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git tag -fa ${{ env.MAJOR_VERSION }} -m "Update major version tag" - git push origin ${{ env.MAJOR_VERSION }} --force - git tag -fa ${{ env.MINOR_VERSION }} -m "Update minor version tag" - git push origin ${{ env.MINOR_VERSION }} --force diff --git a/.github/workflows/gh-release.yaml b/.github/workflows/gh-release.yaml index 9da7d5f..9feaf6d 100644 --- a/.github/workflows/gh-release.yaml +++ b/.github/workflows/gh-release.yaml @@ -1,23 +1,23 @@ -name: Github Semantic Release [Alpha] +name: GitHub Release (git-cliff) on: workflow_call: inputs: - tag: - description: "The tag to release (for manual releases). If not provided, uses semantic-release for automatic versioning." + service_name: + description: "Name of the service (used for scoped tag prefix, e.g., 'titan-api'). Leave empty for whole-repo releases." type: string required: false - tag_format: - description: "The format of the tag to release. If not provided, uses the tagFormat from .releaserc.json or semantic-release default (v${version})." + default: '' + service_path: + description: "Path to the service directory for monorepo filtering (e.g., 'services/titan-api'). Only commits touching this path trigger releases." type: string required: false - use_semantic_release: - description: "Use semantic-release for automatic versioning and changelog generation" - type: boolean - default: true - working_directory: - description: "The working directory where the project is located (relative to the repository root)." + default: '' + cliff_config: + description: "Path to git-cliff configuration file. If not found, uses an embedded default." type: string + required: false + default: cliff.toml update_version_aliases: description: "Automatically update version alias tags (e.g., v1 and v1.2) to point to the latest release." type: boolean @@ -26,17 +26,22 @@ on: description: "Run in dry-run mode to preview the release without creating it." type: boolean default: false + metadata_file: + description: "File path containing extra metadata to append to the release version (e.g., build info). Empty to skip." + type: string + required: false + default: '' app_id: - description: "GitHub App ID (for generating a token if using GitHub App authentication)" + description: "GitHub App ID for authentication" type: string required: false secrets: app_private_key: - description: "GitHub App private key (for generating a token if using GitHub App authentication)" + description: "GitHub App private key for authentication" required: false outputs: new_release_published: - description: "Whether a new release was published (true or false)" + description: "Whether a new release was published ('true' or 'false')" value: ${{ jobs.release.outputs.new_release_published }} new_release_version: description: "Version of the new release (e.g. 1.3.0)" @@ -51,7 +56,7 @@ on: description: "Patch version of the new release (e.g. 0)" value: ${{ jobs.release.outputs.new_release_patch_version }} new_release_git_tag: - description: "The Git tag associated with the new release (e.g. v1.3.0)" + description: "The Git tag associated with the new release" value: ${{ jobs.release.outputs.new_release_git_tag }} new_release_git_head: description: "The sha of the last commit being part of the new release" @@ -70,20 +75,18 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - issues: write - pull-requests: write outputs: - new_release_published: ${{ steps.semantic.outputs.new_release_published }} - new_release_version: ${{ steps.semantic.outputs.new_release_version }} - new_release_major_version: ${{ steps.semantic.outputs.new_release_major_version }} - new_release_minor_version: ${{ steps.semantic.outputs.new_release_minor_version }} - new_release_patch_version: ${{ steps.semantic.outputs.new_release_patch_version }} - new_release_git_tag: ${{ steps.semantic.outputs.new_release_git_tag }} - new_release_git_head: ${{ steps.semantic.outputs.new_release_git_head }} + new_release_published: ${{ steps.release.outputs.new_release_published }} + new_release_version: ${{ steps.release.outputs.new_release_version }} + new_release_major_version: ${{ steps.release.outputs.new_release_major_version }} + new_release_minor_version: ${{ steps.release.outputs.new_release_minor_version }} + new_release_patch_version: ${{ steps.release.outputs.new_release_patch_version }} + new_release_git_tag: ${{ steps.release.outputs.new_release_git_tag }} + new_release_git_head: ${{ steps.release.outputs.new_release_git_head }} dry_run: ${{ inputs.dry_run == true || github.event_name == 'pull_request' }} steps: - name: Generate GitHub App Token - if: inputs.use_semantic_release == true && inputs.app_id != '' + if: inputs.app_id != '' id: generate-token uses: actions/create-github-app-token@v3 with: @@ -91,68 +94,240 @@ jobs: private-key: ${{ secrets.app_private_key }} - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@v6 with: fetch-depth: 0 - persist-credentials: true - token: ${{ (inputs.use_semantic_release == true && inputs.app_id != '') && steps.generate-token.outputs.token || github.token }} - - - name: Configure Git for semantic-release - if: inputs.use_semantic_release == true && inputs.app_id != '' - run: | - git config --global user.name "${{ env.GIT_USER_NAME }}" - git config --global user.email "${{ env.GIT_USER_EMAIL }}" + fetch-tags: true + token: ${{ inputs.app_id != '' && steps.generate-token.outputs.token || github.token }} - name: Temporarily merge PR branch - if: ${{ github.event_name == 'pull_request' }} + if: github.event_name == 'pull_request' run: | - git config --global user.name "${{ env.GIT_USER_NAME }}" - git config --global user.email "${{ env.GIT_USER_EMAIL }}" + git config user.name "${{ env.GIT_USER_NAME }}" + git config user.email "${{ env.GIT_USER_EMAIL }}" git checkout ${{ github.event.pull_request.base.ref }} git merge --squash origin/${{ github.event.pull_request.head.ref }} git commit -m "${{ github.event.pull_request.title }}" - - name: Semantic Release - if: inputs.use_semantic_release == true - id: semantic - uses: cycjimmy/semantic-release-action@v5 + - name: Prepare git-cliff config + id: config + run: | + CONFIG="${{ inputs.cliff_config }}" + + if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then + echo "config_path=$CONFIG" >> $GITHUB_OUTPUT + echo "Using cliff config: $CONFIG" + else + # Write embedded default config + cat > /tmp/cliff-default.toml << 'CLIFF_EOF' + [changelog] + body = """ + {% if version -%} + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} + {% else -%} + ## [unreleased] + {% endif -%} + + {% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}\ + {% endfor %} + {% endfor %} + """ + trim = true + + [git] + conventional_commits = true + filter_unconventional = true + commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^refactor", group = "Refactor" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\)", skip = true }, + { message = "^chore\\(deps\\)", skip = true }, + { message = "^chore|^ci", group = "Miscellaneous" }, + ] + filter_commits = false + sort_commits = "oldest" + CLIFF_EOF + echo "config_path=/tmp/cliff-default.toml" >> $GITHUB_OUTPUT + echo "Using embedded default cliff config" + fi + + - name: Check for existing tags + id: check-tags + run: | + SERVICE_NAME="${{ inputs.service_name }}" + + if [ -n "$SERVICE_NAME" ]; then + PATTERN="^${SERVICE_NAME}-v[0-9]" + MATCHING_TAGS=$(git tag --list | grep -E "$PATTERN" || true) + else + MATCHING_TAGS=$(git tag --list | grep -E "^v[0-9]" || true) + fi + + if [ -n "$MATCHING_TAGS" ]; then + echo "has_tags=true" >> $GITHUB_OUTPUT + echo "Found existing tags matching pattern" + else + echo "has_tags=false" >> $GITHUB_OUTPUT + echo "No existing tags found — first release" + fi + + - name: Generate changelog + id: cliff + if: steps.check-tags.outputs.has_tags == 'true' + uses: orhun/git-cliff-action@v4 with: - dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - unset_gha_env: ${{ github.event_name == 'pull_request' }} - ci: ${{ github.event_name == 'pull_request' && false || true }} - tag_format: ${{ inputs.tag_format }} - working_directory: ${{ inputs.working_directory }} - branches: | - [ - 'main', - 'master' - ] - semantic_version: 24.2.0 - extra_plugins: | - @semantic-release/changelog@6.0.3 - @semantic-release/git@10.0.1 - conventional-changelog-conventionalcommits@8.0.0 + config: ${{ steps.config.outputs.config_path }} + args: >- + --bump + --unreleased + --strip header + ${{ inputs.service_name != '' && format('--tag-pattern "^{0}-v[0-9]"', inputs.service_name) || '' }} + ${{ inputs.service_path != '' && format('--include-path "{0}/**"', inputs.service_path) || '' }} env: - GITHUB_TOKEN: ${{ (inputs.app_id != '') && steps.generate-token.outputs.token || github.token }} + OUTPUT: /tmp/RELEASE_NOTES.md - - name: Manual Release - if: inputs.use_semantic_release == false && inputs.tag != '' && inputs.dry_run == false - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + - name: Generate changelog (first release) + id: cliff-first + if: steps.check-tags.outputs.has_tags == 'false' + uses: orhun/git-cliff-action@v4 with: - tag_name: ${{ inputs.tag }} - generate_release_notes: true + config: ${{ steps.config.outputs.config_path }} + args: >- + --unreleased + --strip header + ${{ inputs.service_path != '' && format('--include-path "{0}/**"', inputs.service_path) || '' }} + env: + OUTPUT: /tmp/RELEASE_NOTES.md + + - name: Create release + id: release + env: + GH_TOKEN: ${{ inputs.app_id != '' && steps.generate-token.outputs.token || github.token }} + run: | + SERVICE_NAME="${{ inputs.service_name }}" + HAS_TAGS="${{ steps.check-tags.outputs.has_tags }}" + IS_DRY_RUN="${{ inputs.dry_run == true || github.event_name == 'pull_request' }}" + + if [ "$HAS_TAGS" = "true" ]; then + CLIFF_VERSION="${{ steps.cliff.outputs.version }}" + + # Strip service name prefix if present + if [ -n "$SERVICE_NAME" ]; then + VERSION="${CLIFF_VERSION#${SERVICE_NAME}-v}" + else + VERSION="${CLIFF_VERSION#v}" + fi + + # If version is empty or unchanged, nothing to release + if [ -z "$VERSION" ] || [ "$VERSION" = "$CLIFF_VERSION" ]; then + echo "No new version computed, nothing to release" + echo "new_release_published=false" >> $GITHUB_OUTPUT + exit 0 + fi + else + # First release — check for conventional commits + COMMIT_COUNT=$(git log --oneline --format="%s" | grep -cE "^(feat|fix|refactor|perf|docs?|test|chore|ci|style|build)" || true) + if [ "$COMMIT_COUNT" -gt 0 ]; then + VERSION="0.1.0" + echo "First release detected, defaulting to $VERSION" + else + echo "No conventional commits found, nothing to release" + echo "new_release_published=false" >> $GITHUB_OUTPUT + exit 0 + fi + fi + + # Build tag + if [ -n "$SERVICE_NAME" ]; then + TAG="${SERVICE_NAME}-v${VERSION}" + else + TAG="v${VERSION}" + fi + + # Append metadata if provided + if [ -n "${{ inputs.metadata_file }}" ] && [ -f "${{ inputs.metadata_file }}" ]; then + METADATA=$(cat "${{ inputs.metadata_file }}") + if [ -n "$METADATA" ]; then + TAG="${TAG}+${METADATA}" + VERSION="${VERSION}+${METADATA}" + fi + fi + + # Check if tag already exists + if git tag --list | grep -qx "$TAG"; then + echo "Tag $TAG already exists, nothing to release" + echo "new_release_published=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Parse version components + SEMVER="${VERSION%%+*}" + MAJOR=$(echo "$SEMVER" | cut -d. -f1) + MINOR=$(echo "$SEMVER" | cut -d. -f2) + PATCH=$(echo "$SEMVER" | cut -d. -f3) - - name: Update major and minor version tags - if: inputs.use_semantic_release == true && inputs.update_version_aliases == true && inputs.dry_run == false && github.event_name != 'pull_request' && steps.semantic.outputs.new_release_published == 'true' + echo "Version: $VERSION" + echo "Tag: $TAG" + echo "Major: $MAJOR, Minor: $MINOR, Patch: $PATCH" + + # Set outputs + echo "new_release_version=$VERSION" >> $GITHUB_OUTPUT + echo "new_release_major_version=$MAJOR" >> $GITHUB_OUTPUT + echo "new_release_minor_version=$MINOR" >> $GITHUB_OUTPUT + echo "new_release_patch_version=$PATCH" >> $GITHUB_OUTPUT + echo "new_release_git_tag=$TAG" >> $GITHUB_OUTPUT + echo "new_release_git_head=${{ github.sha }}" >> $GITHUB_OUTPUT + + if [ "$IS_DRY_RUN" = "true" ]; then + echo "Dry run: would create tag $TAG with version $VERSION" + echo "new_release_published=true" >> $GITHUB_OUTPUT + echo "--- Release Notes Preview ---" + cat /tmp/RELEASE_NOTES.md || echo "(no release notes generated)" + exit 0 + fi + + # Create tag and release + git config user.name "${{ env.GIT_USER_NAME }}" + git config user.email "${{ env.GIT_USER_EMAIL }}" + git tag -a "$TAG" -m "chore(release): $TAG" + git push origin "$TAG" + + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/RELEASE_NOTES.md \ + --target "${{ github.sha }}" + + echo "new_release_published=true" >> $GITHUB_OUTPUT + + - name: Update version alias tags + if: steps.release.outputs.new_release_published == 'true' && inputs.update_version_aliases == true && inputs.dry_run == false && github.event_name != 'pull_request' env: - GITHUB_TOKEN: ${{ (inputs.app_id != '') && steps.generate-token.outputs.token || github.token }} + GH_TOKEN: ${{ inputs.app_id != '' && steps.generate-token.outputs.token || github.token }} run: | - # Update major version tag (e.g., v3) - git tag -fa "v${{ steps.semantic.outputs.new_release_major_version }}" -m "Update major version tag to ${{ steps.semantic.outputs.new_release_version }}" - git push origin "v${{ steps.semantic.outputs.new_release_major_version }}" --force + SERVICE_NAME="${{ inputs.service_name }}" + MAJOR="${{ steps.release.outputs.new_release_major_version }}" + MINOR="${{ steps.release.outputs.new_release_minor_version }}" + VERSION="${{ steps.release.outputs.new_release_version }}" + + if [ -n "$SERVICE_NAME" ]; then + MAJOR_TAG="${SERVICE_NAME}-v${MAJOR}" + MINOR_TAG="${SERVICE_NAME}-v${MAJOR}.${MINOR}" + else + MAJOR_TAG="v${MAJOR}" + MINOR_TAG="v${MAJOR}.${MINOR}" + fi + + git tag -fa "$MAJOR_TAG" -m "Update major version alias to $VERSION" + git push origin "$MAJOR_TAG" --force - # Update minor version tag (e.g., v3.6) - git tag -fa "v${{ steps.semantic.outputs.new_release_major_version }}.${{ steps.semantic.outputs.new_release_minor_version }}" -m "Update minor version tag to ${{ steps.semantic.outputs.new_release_version }}" - git push origin "v${{ steps.semantic.outputs.new_release_major_version }}.${{ steps.semantic.outputs.new_release_minor_version }}" --force + git tag -fa "$MINOR_TAG" -m "Update minor version alias to $VERSION" + git push origin "$MINOR_TAG" --force - echo "✅ Updated version aliases: v${{ steps.semantic.outputs.new_release_major_version }}, v${{ steps.semantic.outputs.new_release_major_version }}.${{ steps.semantic.outputs.new_release_minor_version }}" + echo "Updated version aliases: $MAJOR_TAG, $MINOR_TAG" diff --git a/.github/workflows/gitops-image-tag.yaml b/.github/workflows/gitops-image-tag.yaml index fa4db66..5988fbc 100644 --- a/.github/workflows/gitops-image-tag.yaml +++ b/.github/workflows/gitops-image-tag.yaml @@ -1,235 +1,117 @@ -# Simple workflow to update the image tag in a Helm values file -# and create a pull request if needed. This should be migrated to an action -# in the future. -name: Update Helm Image Tag in Values.yaml +name: Update Helm Image Tag on: workflow_call: inputs: image_tag: - description: 'The tag of the Docker image' + description: 'The Docker image tag to set' type: string required: true - image_tag_keys: - description: 'Newline-separated list of keys to update (e.g., webapp.image.tag). Ignored when image_name is set.' + image_name: + description: 'Image name to search for (mode=image). Finds all image blocks where repository ends with this name and updates their tag.' type: string default: '' - image_name: - description: 'Image name to search for (e.g., social-media). Finds all image blocks where the repository ends with this name and updates their tag. Takes precedence over image_tag_keys.' + image_tag_keys: + description: 'Newline-separated dot-notation key paths to update (mode=key). Ignored when image_name is set.' type: string default: '' values_files: - description: 'Newline-separated list of file paths to update' + description: 'Newline-separated list of YAML file paths to update' type: string required: true create_pr: - description: | - Create a pull request. If false, the changes will be committed directly to the target branch. - Github Actions must have write permissions to the repository. If branch protection is enabled a Github App is required and is able to bypass branch protection rules. + description: 'Create a pull request. If false, commits directly to the target branch.' type: boolean default: true - pr_message: - description: 'Custom message for the pull request. Defaults to a standard message.' + pr_title: + description: 'Custom title for the pull request.' type: string - default: 'This PR updates the Helm values files to use the latest image tag.' + default: '' + pr_body: + description: 'Custom body for the pull request.' + type: string + default: '' auto_merge: - description: 'Enable auto-merge for the pull request. Only works if create_pr is true.' + description: 'Enable auto-merge for the pull request.' type: boolean default: false - branch_name_prefix: - description: 'Prefix for the branch name.' + pr_branch: + description: 'Branch name for the pull request. Auto-generated if empty.' type: string - default: 'helm-values' + default: '' target_branch: - description: 'The target branch for the pull request. Defaults the default branch of the repository.' + description: 'Target branch for the PR or direct commit. Defaults to the default branch.' type: string - default: ${{ github.event.repository.default_branch }} + default: '' + dry_run: + description: 'Preview changes without modifying anything.' + type: boolean + default: false app_id: - description: 'GitHub App ID for generating a token. Required if using GitHub App authentication.' + description: 'GitHub App ID for generating a token.' type: string required: false secrets: app_private_key: - description: 'Private key for the GitHub App. Required if using GitHub App authentication.' + description: 'Private key for the GitHub App.' required: false jobs: update-helm-values: runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v6.0.2 - - - name: Update Helm values files - run: | - IMAGE_TAG="${{ inputs.image_tag }}" - IMAGE_NAME="${{ inputs.image_name }}" - - # Validate inputs - if [ -z "$IMAGE_TAG" ]; then - echo "Error: image_tag input cannot be empty" - exit 1 - fi - if [ -z "${{ inputs.values_files }}" ]; then - echo "Error: values_files input cannot be empty" - exit 1 - fi - if [ -z "$IMAGE_NAME" ] && [ -z "${{ inputs.image_tag_keys }}" ]; then - echo "Error: either image_name or image_tag_keys must be provided" - exit 1 - fi - - echo "Files to update:" - echo "${{ inputs.values_files }}" - - if [ -n "$IMAGE_NAME" ]; then - echo "" - echo "Image name to search for: $IMAGE_NAME" - else - echo "" - echo "Keys to update:" - echo "${{ inputs.image_tag_keys }}" - fi - - # Process each file (newline-separated) - while IFS= read -r file; do - # Skip empty lines - [ -z "$file" ] && continue - - echo "" - echo "Updating $file with image tag: $IMAGE_TAG" - - if [ -n "$IMAGE_NAME" ]; then - # --- IMAGE NAME SEARCH MODE --- - # Find all dot-notation paths to tag fields where sibling repository ends with the image name - TAG_PATHS=$(yq ' - (.. | select(has("repository") and has("tag")) | select(.repository | test("'"${IMAGE_NAME}"'$"))).tag | path | "." + join(".") - ' "$file" 2>/dev/null) - - if [ -z "$TAG_PATHS" ]; then - echo " No image blocks matching '$IMAGE_NAME' found in $file, skipping" - continue - fi - - while IFS= read -r tag_path; do - [ -z "$tag_path" ] && continue - current_value=$(yq "$tag_path" "$file") - if [ "$current_value" = "$IMAGE_TAG" ]; then - echo " $tag_path already up to date" - continue - fi - LINE=$(yq "$tag_path | line" "$file") - sed -i "${LINE}s|tag: .*|tag: \"${IMAGE_TAG}\"|" "$file" - echo " $tag_path = $IMAGE_TAG (line $LINE)" - done <<< "$TAG_PATHS" - else - # --- EXPLICIT KEYS MODE --- - while IFS= read -r key; do - [ -z "$key" ] && continue - - # Check if key exists before updating - if yq e ".$key" "$file" >/dev/null 2>&1 && [ "$(yq e ".$key" "$file")" != "null" ]; then - echo " Setting $key = $IMAGE_TAG" - current_value=$(yq e ".$key" "$file") - if [ "$current_value" != "$IMAGE_TAG" ]; then - # Use yq to find the exact line, then sed to preserve formatting - LINE=$(yq ".$key | line" "$file") - sed -i "${LINE}s|${key##*.}: .*|${key##*.}: \"${IMAGE_TAG}\"|" "$file" - else - echo " Value already up to date" - fi - else - echo " Key $key not found in $file, skipping" - fi - done <<< "${{ inputs.image_tag_keys }}" - fi - done <<< "${{ inputs.values_files }}" - - - name: Generate PR details - id: pr_details - run: | - # Create a hash of the files being updated for consistent branch naming - FILES_HASH=$(echo "${{ inputs.values_files }}" | sha256sum | cut -c1-8) - echo "branch_name=${{ inputs.branch_name_prefix }}-$FILES_HASH" >> $GITHUB_OUTPUT - - - name: Create Pull Request - if: inputs.create_pr == true - id: create_pr - uses: peter-evans/create-pull-request@v8.1.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: 'chore: update image tag to ${{ inputs.image_tag }}' - title: 'Update image tag to ${{ inputs.image_tag }}' - body: | - ${{ inputs.pr_message }} - - **New tag**: - ``` - ${{ inputs.image_tag }} - ``` - - **Image**: `${{ inputs.image_name || 'N/A' }}` - - **Keys to update**: - ``` - ${{ inputs.image_tag_keys || 'auto-detected via image_name' }} - ``` - - **Files to update**: - ``` - ${{ inputs.values_files }} - ``` - - *This change was automatically generated by the ${{ github.workflow }} workflow.* - branch: ${{ steps.pr_details.outputs.branch_name }} - base: ${{ inputs.target_branch }} - delete-branch: true - - - name: Generate a token + - name: Generate GitHub App Token + if: inputs.app_id != '' id: generate-token - if: inputs.auto_merge == true uses: actions/create-github-app-token@v3 with: app-id: ${{ inputs.app_id }} private-key: ${{ secrets.app_private_key }} - - name: Enable auto-merge - if: inputs.create_pr == true && steps.create_pr.outputs.pull-request-number && inputs.auto_merge == true - env: - GH_TOKEN: ${{ steps.generate-token.outputs.token }} - run: | - echo "Enabling auto-merge for PR #${{ steps.create_pr.outputs.pull-request-number }}" - - # Add a comment explaining the auto-merge - gh pr comment "${{ steps.create_pr.outputs.pull-request-number }}" \ - --body "This PR will be automatically merged once all checks pass." - - # Enable auto-merge - gh pr merge "${{ steps.create_pr.outputs.pull-request-number }}" \ - --auto \ - --squash \ - --delete-branch + - name: Checkout repository + uses: actions/checkout@v6 - - name: Commit changes - if: inputs.create_pr == false && steps.generate-token.outputs.token - env: - GH_TOKEN: ${{ steps.generate-token.outputs.token }} + - name: Validate inputs run: | - git config --global user.name 'GitHub Actions' - git config --global user.email 'actions@github.com' - - TARGET_BRANCH="${{ inputs.target_branch }}" - if [ -z "$TARGET_BRANCH" ]; then - TARGET_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') + if [ -z "${{ inputs.image_tag }}" ]; then + echo "::error::image_tag input cannot be empty" + exit 1 + fi + if [ -z "${{ inputs.image_name }}" ] && [ -z "${{ inputs.image_tag_keys }}" ]; then + echo "::error::Either image_name or image_tag_keys must be provided" + exit 1 fi - git checkout "$TARGET_BRANCH" 2>/dev/null || git checkout -b "$TARGET_BRANCH" - git pull origin "$TARGET_BRANCH" 2>/dev/null || true - - while IFS= read -r file; do - [ -z "$file" ] && continue - git add "$file" - done <<< "${{ inputs.values_files }}" - - git commit -m "chore: update image tag to ${{ inputs.image_tag }}" - git push origin "$TARGET_BRANCH" + - name: Update YAML files (image mode) + if: inputs.image_name != '' + uses: dnd-it/action-yaml-update@v0 + with: + mode: image + files: ${{ inputs.values_files }} + image_name: ${{ inputs.image_name }} + image_tag: ${{ inputs.image_tag }} + create_pr: ${{ inputs.create_pr && 'true' || 'false' }} + pr_title: "${{ inputs.pr_title || format('chore: update {0} image tag to {1}', inputs.image_name, inputs.image_tag) }}" + pr_body: "${{ inputs.pr_body || format('Updates image `{0}` to tag `{1}`.', inputs.image_name, inputs.image_tag) }}" + pr_branch: ${{ inputs.pr_branch }} + target_branch: ${{ inputs.target_branch }} + auto_merge: ${{ inputs.auto_merge && 'true' || 'false' }} + dry_run: ${{ inputs.dry_run && 'true' || 'false' }} + token: ${{ inputs.app_id != '' && steps.generate-token.outputs.token || github.token }} + + - name: Update YAML files (key mode) + if: inputs.image_name == '' && inputs.image_tag_keys != '' + uses: dnd-it/action-yaml-update@v0 + with: + mode: key + files: ${{ inputs.values_files }} + keys: ${{ inputs.image_tag_keys }} + value: ${{ inputs.image_tag }} + create_pr: ${{ inputs.create_pr && 'true' || 'false' }} + pr_title: "${{ inputs.pr_title || format('chore: update image tag to {0}', inputs.image_tag) }}" + pr_body: "${{ inputs.pr_body || format('Updates image tag to `{0}`.', inputs.image_tag) }}" + pr_branch: ${{ inputs.pr_branch }} + target_branch: ${{ inputs.target_branch }} + auto_merge: ${{ inputs.auto_merge && 'true' || 'false' }} + dry_run: ${{ inputs.dry_run && 'true' || 'false' }} + token: ${{ inputs.app_id != '' && steps.generate-token.outputs.token || github.token }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 24de2eb..067cb8b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,11 +20,8 @@ jobs: release: permissions: contents: write - issues: write - pull-requests: write uses: ./.github/workflows/gh-release.yaml with: - use_semantic_release: true update_version_aliases: true app_id: ${{ vars.FISSION_GH_APP_ID }} secrets: @@ -39,7 +36,7 @@ jobs: slack_bot_token: ${{ secrets.SLACK_BOT_TOKEN }} with: channel: "C09LG2L8EQ5" - notification_title: "New ${{ github.event.repository.name }} release 🚀" + notification_title: "New ${{ github.event.repository.name }} release" notification_message: "Version v${{ needs.release.outputs.new_release_version }} has been released!" status: "success" additional_fields: | diff --git a/.github/workflows/service-pipeline.yaml b/.github/workflows/service-pipeline.yaml index cb42433..010c241 100644 --- a/.github/workflows/service-pipeline.yaml +++ b/.github/workflows/service-pipeline.yaml @@ -46,8 +46,13 @@ on: required: false type: string default: 'image.tag' + argocd_server: + description: "ArgoCD server URL. Falls back to vars.argocd_server if not provided." + required: false + type: string + default: '' app_id: - description: "GitHub App ID for semantic-release authentication" + description: "GitHub App ID for release authentication" required: true type: string outputs: @@ -177,12 +182,12 @@ jobs: contents: write issues: write pull-requests: write - uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v3 + uses: ./.github/workflows/gh-release.yaml with: - use_semantic_release: true + service_name: ${{ inputs.service_name }} + service_path: ${{ inputs.service_path }} dry_run: true app_id: ${{ inputs.app_id }} - working_directory: ${{ inputs.service_path }} secrets: app_private_key: ${{ secrets.app_private_key }} @@ -235,7 +240,7 @@ jobs: docker tag "${SERVICE}:${SHA}" "${ECR_REGISTRY}/${SERVICE}:latest" fi - # Tag version if semantic release found a new version + # Tag version if a new release was detected if [ "${{ needs.release-info.outputs.new_release_published }}" = "true" ]; then VERSION="${{ needs.release-info.outputs.new_release_version }}" docker tag "${SERVICE}:${SHA}" "${ECR_REGISTRY}/${SERVICE}:${VERSION}" @@ -251,11 +256,11 @@ jobs: contents: write issues: write pull-requests: write - uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v3 + uses: ./.github/workflows/gh-release.yaml with: - use_semantic_release: true + service_name: ${{ inputs.service_name }} + service_path: ${{ inputs.service_path }} app_id: ${{ inputs.app_id }} - working_directory: ${{ inputs.service_path }} secrets: app_private_key: ${{ secrets.app_private_key }} @@ -267,7 +272,7 @@ jobs: needs.release.result == 'success' && needs.release-info.outputs.new_release_published == 'true' && inputs.slack_channel != '' - uses: DND-IT/github-workflows/.github/workflows/notify-slack.yaml@v3 + uses: ./.github/workflows/notify-slack.yaml secrets: slack_bot_token: ${{ secrets.slack_bot_token }} with: @@ -315,8 +320,9 @@ jobs: name: Set Feature Branch Image Tag needs: [argocd-prepare] if: needs.argocd-prepare.result == 'success' - uses: DND-IT/github-workflows/.github/workflows/argocd-cli.yaml@v3 + uses: ./.github/workflows/argocd-cli.yaml secrets: argocd_auth_token: ${{ secrets.argocd_auth_token }} with: argocd_commands: ${{ needs.argocd-prepare.outputs.commands }} + argocd_server: ${{ inputs.argocd_server }} diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..1d20eaa --- /dev/null +++ b/TODOS.md @@ -0,0 +1,76 @@ +# TODOs + +## IN PROGRESS (git-ops-update branch) + +### Migrate gh-release.yaml from semantic-release to git-cliff + +- **Priority:** P1 +- **Effort:** M (human: ~4h / CC: ~30 min) +- **Context:** Replace `cycjimmy/semantic-release-action` with `orhun/git-cliff-action@v4`. git-cliff natively supports scoped tags via `--tag-pattern` and monorepo path filtering via `--include-path`. This eliminates the Node.js dependency and simplifies scoped versioning to inputs: `service_name`, `service_path`, `cliff_config`. +- **Consolidates:** `gh-release-on-main.yaml` (manual bash versioning) into the new workflow. Port `metadata_file` support. +- **Breaking change:** Ships as `v4`. Callers on `@v3` unaffected. New inputs replace `use_semantic_release`, `tag_format`. +- **First release handling:** Auto-creates `0.1.0` when no prior tags exist. + +### Rewrite gitops-image-tag.yaml to use action-yaml-update + +- **Priority:** P1 +- **Effort:** M (human: ~4h / CC: ~20 min) +- **Context:** The current `gitops-image-tag.yaml` manually implements YAML updating with yq/sed and PR creation with peter-evans/create-pull-request (~160 lines). The `dnd-it/action-yaml-update@v0` action already handles all of this natively with better format preservation and support for key, image, and marker modes. The rewrite simplifies the workflow to ~40 lines: app-token → checkout → action-yaml-update. +- **Interface change:** Simplified inputs to mirror action-yaml-update. Breaking change — ships under `v4`. + +### Add argocd_server input to service-pipeline.yaml + +- **Priority:** P1 +- **Effort:** S (human: ~30min / CC: ~5 min) +- **Context:** The `service-pipeline.yaml` has built-in ArgoCD support via `argocd_app_name` input, but it doesn't accept or forward `argocd_server` to `argocd-cli.yaml`. Repos that need to pass a custom ArgoCD server URL (e.g., `${{ vars.ARGOCD_URL }}`) are forced to call `argocd-cli.yaml` directly, duplicating the ArgoCD job in each workflow file. +- **Change:** Add optional `argocd_server` input, forward it to the internal `argocd-cli.yaml` call. Non-breaking additive change. + +### Document scoped tagging convention + migration guide + +- **Priority:** P1 +- **Effort:** S (human: ~1h / CC: ~10 min) +- **Context:** Teams using `github-workflows` need documentation on: (1) scoped tagging convention (`{service_name}-v{version}` via git-cliff), (2) migration guide from `@v3` semantic-release to `@v4` git-cliff with before/after workflow examples. +- **Tag format:** `{service_name}-v{version}` (single dash separator, e.g., `gitops-image-tag-v2.0.0`). No prefix when `service_name` is empty (whole-repo mode, e.g., `v4.0.0`). +- **Where:** `docs/VERSIONING.md` + migration section + +### Update release.yaml for this repo's own v4 release + +- **Priority:** P1 +- **Effort:** S (human: ~15min / CC: ~5 min) +- **Context:** This repo's `release.yaml` calls `gh-release.yaml` with `use_semantic_release: true`. After the git-cliff migration, update to pass the new inputs (`service_name`, `cliff_config`). The first release after merge should create `v4.0.0`. + +### Rewrite _test-gh.yaml for git-cliff + +- **Priority:** P1 +- **Effort:** S (human: ~1h / CC: ~10 min) +- **Context:** Existing tests validate semantic-release dry-run and custom tag format. Rewrite to test git-cliff: conventional commit bump, scoped tag creation, dry-run mode, first release (0.1.0 default). + +### Create _test-gitops-image-tag.yaml + +- **Priority:** P1 +- **Effort:** S (human: ~2h / CC: ~15 min) +- **Context:** No test file exists for `gitops-image-tag.yaml`. After rewrite to `action-yaml-update`, add 2-3 integration tests: image_name search mode, explicit keys mode, PR creation. Tests verify workflow wiring, not action internals. + +### Add cliff.toml for this repo's own releases + +- **Priority:** P1 +- **Effort:** S (human: ~30min / CC: ~5 min) +- **Context:** The git-cliff workflow embeds a default config for callers, but this repo should have its own `cliff.toml` to control changelog format (grouping by workflow category, conventional commit types). Used by `release.yaml`. + +--- + +## BACKLOG + +### Test coverage for scoped tag alias updates + +- **Priority:** P2 +- **Effort:** S (human: ~1h / CC: ~10 min) +- **Context:** The `update_version_aliases` step force-pushes alias tags (e.g., `service-name-v1`). Add test jobs to `_test-gh.yaml` that verify aliases are correctly created/updated for scoped releases. The alias logic is the most force-push-heavy part — a bug here could overwrite wrong tags. +- **Depends on:** git-cliff migration (above) + +### Migrate aws-rds-version-management.yaml and tf-cleanup.yaml from sed to dedicated actions + +- **Priority:** P3 +- **Effort:** M (human: ~4h / CC: ~20 min) +- **Context:** These workflows use manual `sed` for YAML/config editing. For consistency with the gitops-image-tag migration to `action-yaml-update`, consider migrating these too. sed-based editing is fragile and doesn't preserve YAML formatting. Note: different use cases (Terraform backend config vs Helm values) — assess each individually. +- **Depends on:** Nothing — can be done anytime diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..caea71f --- /dev/null +++ b/cliff.toml @@ -0,0 +1,49 @@ +# git-cliff configuration for github-workflows releases +# https://git-cliff.org/docs/configuration + +[changelog] +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} + +{% if version -%} + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} + ## [unreleased] +{% endif -%} + +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} \ + ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ + {% endfor %} +{% endfor %}\n +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^refactor", group = "Refactor" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\)", skip = true }, + { message = "^chore\\(deps\\)", skip = true }, + { message = "^chore|^ci", group = "Miscellaneous" }, +] +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "oldest" diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md new file mode 100644 index 0000000..d75f7d7 --- /dev/null +++ b/docs/VERSIONING.md @@ -0,0 +1,174 @@ +# Versioning + +## Overview + +This repository uses [git-cliff](https://git-cliff.org/) for automated versioning based on [conventional commits](https://www.conventionalcommits.org/). Releases are created automatically when changes are pushed to the `main` branch. + +## Tag Format + +### Global releases (this repo) + +Tags follow standard semver: `v{major}.{minor}.{patch}` (e.g., `v4.0.0`). + +Version aliases are automatically updated: +- `v{major}` (e.g., `v4`) — always points to the latest release in that major version +- `v{major}.{minor}` (e.g., `v4.0`) — always points to the latest patch in that minor version + +### Scoped releases (caller repos) + +For monorepos or repos with multiple services, tags include a service prefix: `{service_name}-v{major}.{minor}.{patch}` (e.g., `titan-api-v1.3.0`). + +Scoped version aliases: +- `{service_name}-v{major}` (e.g., `titan-api-v1`) +- `{service_name}-v{major}.{minor}` (e.g., `titan-api-v1.3`) + +git-cliff uses `--tag-pattern` and `--include-path` to only consider commits relevant to the specific service. + +## Conventional Commits + +Version bumps are determined by commit prefixes: + +| Prefix | Version Bump | Example | +|--------|-------------|---------| +| `feat!:` | Major | `feat!: redesign API endpoints` | +| `feat:` | Minor | `feat: add new workflow input` | +| `fix:` | Patch | `fix: correct tag format` | + +Other prefixes (`chore:`, `docs:`, `ci:`, `refactor:`, `test:`, `perf:`, `style:`) do not trigger releases on their own but are included in changelogs. + +## Using `gh-release.yaml` + +### Basic usage (whole repo) + +```yaml +jobs: + release: + uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v4 + with: + update_version_aliases: true + app_id: ${{ vars.APP_ID }} + secrets: + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +### Scoped usage (monorepo service) + +```yaml +jobs: + release: + uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v4 + with: + service_name: titan-api + service_path: services/titan-api + update_version_aliases: true + app_id: ${{ vars.APP_ID }} + secrets: + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +### Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `service_name` | No | `''` | Service name for scoped tag prefix. Leave empty for whole-repo releases. | +| `service_path` | No | `''` | Path to service directory for monorepo filtering. | +| `cliff_config` | No | `cliff.toml` | Path to git-cliff config. Uses embedded default if not found. | +| `update_version_aliases` | No | `false` | Auto-update major/minor alias tags. | +| `dry_run` | No | `false` | Preview release without creating it. | +| `metadata_file` | No | `''` | File with metadata to append to version (e.g., build info). | +| `app_id` | No | — | GitHub App ID for authentication. | + +### Outputs + +| Output | Description | +|--------|-------------| +| `new_release_published` | `'true'` or `'false'` | +| `new_release_version` | Version string (e.g., `1.3.0`) | +| `new_release_major_version` | Major version number | +| `new_release_minor_version` | Minor version number | +| `new_release_patch_version` | Patch version number | +| `new_release_git_tag` | Full git tag (e.g., `v1.3.0` or `titan-api-v1.3.0`) | +| `new_release_git_head` | SHA of the release commit | +| `dry_run` | Whether this was a dry-run | + +## Custom git-cliff Configuration + +The workflow uses an embedded default configuration if no `cliff.toml` is found at the specified path. To customize changelog generation, create a `cliff.toml` in your repository root. See [git-cliff documentation](https://git-cliff.org/docs/configuration) for options. + +--- + +## Migration Guide: v3 (semantic-release) → v4 (git-cliff) + +### What changed + +- **Release engine**: `cycjimmy/semantic-release-action` replaced by `orhun/git-cliff-action` +- **Scoped tags**: Native support via `service_name` + `service_path` inputs +- **Configuration**: `.releaserc.json` replaced by `cliff.toml` (optional — embedded default works) +- **Node.js dependency**: Removed — git-cliff is a standalone Rust binary + +### Before (v3) + +```yaml +jobs: + release: + uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v3 + with: + use_semantic_release: true + tag_format: "v${version}" + update_version_aliases: true + app_id: ${{ vars.APP_ID }} + secrets: + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +### After (v4) + +```yaml +jobs: + release: + uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v4 + with: + update_version_aliases: true + app_id: ${{ vars.APP_ID }} + secrets: + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +### Input mapping + +| v3 Input | v4 Equivalent | Notes | +|----------|--------------|-------| +| `use_semantic_release` | *(removed)* | git-cliff is always used | +| `tag_format` | *(removed)* | Determined by `service_name` presence | +| `tag` | *(removed)* | Manual tagging not supported; use `gh release create` directly | +| `working_directory` | `service_path` | Same purpose, renamed for clarity | +| `update_version_aliases` | `update_version_aliases` | Unchanged | +| `dry_run` | `dry_run` | Unchanged | +| `app_id` | `app_id` | Unchanged | +| *(new)* | `service_name` | Enables scoped tag prefix | +| *(new)* | `cliff_config` | Custom git-cliff configuration | +| *(new)* | `metadata_file` | Ported from `gh-release-on-main.yaml` | + +### For `gh-release-on-main.yaml` callers + +`gh-release-on-main.yaml` has been consolidated into `gh-release.yaml`. If you were using it: + +**Before:** +```yaml +uses: DND-IT/github-workflows/.github/workflows/gh-release-on-main.yaml@v3 +with: + metadata_file: "VERSION_METADATA" + update_version_aliases: true +``` + +**After:** +```yaml +uses: DND-IT/github-workflows/.github/workflows/gh-release.yaml@v4 +with: + metadata_file: "VERSION_METADATA" + update_version_aliases: true +``` + +### For `service-pipeline.yaml` callers + +No changes needed — `service-pipeline.yaml` uses internal self-references and automatically uses the new git-cliff release workflow. diff --git a/docs/designs/gitops-scoped-versioning.md b/docs/designs/gitops-scoped-versioning.md new file mode 100644 index 0000000..2c05935 --- /dev/null +++ b/docs/designs/gitops-scoped-versioning.md @@ -0,0 +1,57 @@ +--- +status: ACTIVE +--- +# CEO Plan: GitOps Scoped Versioning + git-cliff Migration + +Generated by /plan-ceo-review on 2026-03-20 +Branch: git-ops-update | Mode: SELECTIVE EXPANSION +Repo: DND-IT/github-workflows + +## Vision + +### 10x Check +Replace semantic-release with git-cliff for all release workflows. git-cliff natively supports scoped tags via `--tag-pattern` and monorepo path filtering via `--include-path`, making per-workflow independent versioning trivial. Ship as v4 — clean break, callers migrate at their own pace. + +### Tag Format +- Scoped: `{service_name}-v{version}` (e.g., `gitops-image-tag-v2.0.0`) +- Global (no service_name): `v{version}` (e.g., `v4.0.0`) +- Aliases: `{service_name}-v{major}` / `{service_name}-v{major}.{minor}` (scoped) or `v{major}` / `v{major}.{minor}` (global) +- git-cliff `--tag-pattern`: `'^{service_name}-v[0-9]'` (scoped) or default (global) + +### Implementation Notes (from eng review) +- `service-pipeline.yaml` uses self-reference (`./.github/workflows/gh-release.yaml`) for internal calls, not `@v4` +- `gh-release.yaml` embeds a default `cliff.toml` config; callers override via `cliff_config` input +- This repo has its own `cliff.toml` for release customization + +## Key Decisions + +1. **Implementation approach:** Big Bang — single PR with all changes +2. **Release engine:** git-cliff replaces semantic-release entirely (including this repo's own releases) +3. **Consolidation:** `gh-release-on-main.yaml` merged into `gh-release.yaml` +4. **Rollout:** Ship as v4, clean break. Callers on @v3 unaffected. +5. **First release:** Auto-creates 0.1.0 when no prior tags exist +6. **Deprecation:** None — both global (@v3) and new (@v4) paths coexist +7. **scope input:** Replaced by git-cliff's native `service_name` + `--tag-pattern` + +## Scope Decisions + +| # | Proposal | Effort | Decision | Reasoning | +|---|----------|--------|----------|-----------| +| 1 | Test coverage for scoped tag aliases | S | DEFERRED | Validate convention first | +| 2 | Migrate other yq/sed workflows | M | DEFERRED | Different use cases, assess individually | +| 3 | Add `scope` input to gh-release.yaml | S | ACCEPTED (evolved) | Became git-cliff's service_name input | +| 4 | Migration guide for gitops-image-tag callers | S | ACCEPTED | Prevents support burden | +| 5 | Deprecation warning on old path | S | SKIPPED | Both paths coexist, no deprecation needed | + +## Accepted Scope (this PR) +- Rewrite gh-release.yaml → git-cliff (replaces semantic-release) +- Consolidate gh-release-on-main.yaml into gh-release.yaml +- Update release.yaml (this repo's own release) for new inputs +- Rewrite gitops-image-tag.yaml → action-yaml-update +- Add argocd_server passthrough to service-pipeline.yaml +- Document scoped tagging convention + migration guide +- Rewrite _test-gh.yaml for git-cliff + +## Deferred to TODOS.md +- Test coverage for scoped tag alias creation/update +- Migrate aws-rds-version-management.yaml and tf-cleanup.yaml from sed diff --git a/tests/gitops-image-tag/values.yaml b/tests/gitops-image-tag/values.yaml new file mode 100644 index 0000000..f2da093 --- /dev/null +++ b/tests/gitops-image-tag/values.yaml @@ -0,0 +1,9 @@ +webapp: + image: + repository: registry.example.com/my-app + tag: "1.0.0" + +sidecar: + image: + repository: registry.example.com/sidecar + tag: "2.0.0"