# Consumer entrypoint: pin this workflow to a reviewed full commit SHA. name: "Reusable: Changelog Lifecycle" on: workflow_call: inputs: app-client-id: description: Optional GitHub App client ID; requires app-private-key and the matching automation-actor. type: string default: '' runs-on: description: JSON runner labels; Linux with Docker, Bash and Git is required. type: string default: '["ubuntu-latest"]' base-branch: description: Release line; empty uses the repository default branch. type: string default: '' fragment-directory: type: string default: '.changelog' changelog-file: type: string default: 'CHANGELOG.md' locale: type: string default: 'en' tag-prefix: type: string default: 'v' source: type: string default: 'auto' resolve-pull-requests: description: 'Resolve verified fragment PR references during versioning and publication.' type: boolean default: true managed-branch: type: string default: 'changelog/version' version-pr-title: description: 'Version PR title; accepts {current_version} and {next_version}.' type: string default: 'chore: update changelog' automation-actor: type: string default: 'github-actions[bot]' waiver-label: type: string default: 'changelog-not-required' maintenance-label: type: string default: 'changelog-maintenance' comment-mode: description: always, on-failure or never. type: string default: 'always' prepare-release: description: Prepare a reviewable version PR on base pushes or manual dispatch. type: boolean default: true publish: description: Publish the approved merged version PR; false consolidates history without creating its tag or GitHub Release. type: boolean default: true dry-run: description: Preview version and publication without remote content writes. type: boolean default: false secrets: app-private-key: description: Optional GitHub App private key; mutually exclusive with token. required: false token: description: Optional scoped App token for trusted operations; PR checks use github.token. required: false outputs: version-pr: value: ${{ jobs.version.outputs.pull-request }} version-head: value: ${{ jobs.version.outputs.head-sha }} published-version: value: ${{ jobs.publish.outputs.version }} published-tag: value: ${{ jobs.publish.outputs.tag }} published-sha: value: ${{ jobs.publish.outputs.sha }} permissions: {} jobs: check: name: Check Contribution if: >- github.event_name == 'pull_request' && github.base_ref == (inputs.base-branch || github.event.repository.default_branch) permissions: contents: read pull-requests: read issues: read uses: ./.github/workflows/changelog-check.yml with: runs-on: ${{ inputs.runs-on }} pull-request: ${{ github.event.pull_request.number }} head-sha: ${{ github.event.pull_request.head.sha }} base-sha: ${{ github.event.pull_request.base.sha }} fragment-directory: ${{ inputs.fragment-directory }} changelog-file: ${{ inputs.changelog-file }} locale: ${{ inputs.locale }} tag-prefix: ${{ inputs.tag-prefix }} source: ${{ inputs.source }} managed-branch: ${{ inputs.managed-branch }} resolve-pull-requests: ${{ inputs.resolve-pull-requests }} automation-actor: ${{ inputs.automation-actor }} waiver-label: ${{ inputs.waiver-label }} maintenance-label: ${{ inputs.maintenance-label }} dependabot: name: Author Dependabot Fragment if: >- github.event_name == 'pull_request_target' && github.event.action != 'closed' && github.base_ref == (inputs.base-branch || github.event.repository.default_branch) && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository permissions: contents: write pull-requests: read uses: ./.github/workflows/changelog-dependabot.yml with: app-client-id: ${{ inputs.app-client-id }} runs-on: ${{ inputs.runs-on }} pull-request: ${{ github.event.pull_request.number }} head-sha: ${{ github.event.pull_request.head.sha }} fragment-directory: ${{ inputs.fragment-directory }} changelog-file: ${{ inputs.changelog-file }} locale: ${{ inputs.locale }} tag-prefix: ${{ inputs.tag-prefix }} source: ${{ inputs.source }} secrets: token: ${{ secrets.token }} app-private-key: ${{ secrets.app-private-key }} feedback: name: PR Feedback needs: dependabot if: >- !cancelled() && github.event_name == 'pull_request_target' && github.event.action != 'closed' && github.base_ref == (inputs.base-branch || github.event.repository.default_branch) permissions: contents: read pull-requests: write issues: read statuses: write uses: ./.github/workflows/changelog-pr-status.yml with: app-client-id: ${{ inputs.app-client-id }} runs-on: ${{ inputs.runs-on }} pull-request: ${{ github.event.pull_request.number }} head-sha: ${{ needs.dependabot.outputs.head-sha || github.event.pull_request.head.sha }} # Fragment creation may finish after the PR base moves; resolve its current API base. base-sha: ${{ needs.dependabot.result != 'success' && github.event.pull_request.base.sha || '' }} base-branch: ${{ inputs.base-branch || github.event.repository.default_branch }} fragment-directory: ${{ inputs.fragment-directory }} changelog-file: ${{ inputs.changelog-file }} locale: ${{ inputs.locale }} tag-prefix: ${{ inputs.tag-prefix }} source: ${{ inputs.source }} managed-branch: ${{ inputs.managed-branch }} resolve-pull-requests: ${{ inputs.resolve-pull-requests }} automation-actor: ${{ inputs.automation-actor }} waiver-label: ${{ inputs.waiver-label }} maintenance-label: ${{ inputs.maintenance-label }} comment-mode: ${{ inputs.comment-mode }} secrets: token: ${{ secrets.token }} app-private-key: ${{ secrets.app-private-key }} version: name: Prepare Version PR if: >- inputs.prepare-release && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == format('refs/heads/{0}', inputs.base-branch || github.event.repository.default_branch) permissions: contents: write pull-requests: write issues: read uses: ./.github/workflows/changelog-version.yml with: title: ${{ inputs.version-pr-title }} app-client-id: ${{ inputs.app-client-id }} runs-on: ${{ inputs.runs-on }} base-branch: ${{ inputs.base-branch || github.event.repository.default_branch }} managed-branch: ${{ inputs.managed-branch }} automation-actor: ${{ inputs.automation-actor }} fragment-directory: ${{ inputs.fragment-directory }} changelog-file: ${{ inputs.changelog-file }} locale: ${{ inputs.locale }} tag-prefix: ${{ inputs.tag-prefix }} source: ${{ inputs.source }} resolve-pull-requests: ${{ inputs.resolve-pull-requests }} dry-run: ${{ inputs.dry-run }} secrets: token: ${{ secrets.token }} app-private-key: ${{ secrets.app-private-key }} validate-version: name: Validate Generated Version Head needs: version if: >- needs.version.result == 'success' && !inputs.dry-run && needs.version.outputs.pull-request != '' && needs.version.outputs.head-sha != '' permissions: contents: read pull-requests: write issues: read statuses: write uses: ./.github/workflows/changelog-pr-status.yml with: app-client-id: ${{ inputs.app-client-id }} runs-on: ${{ inputs.runs-on }} pull-request: ${{ fromJSON(needs.version.outputs.pull-request) }} head-sha: ${{ needs.version.outputs.head-sha }} base-branch: ${{ inputs.base-branch || github.event.repository.default_branch }} fragment-directory: ${{ inputs.fragment-directory }} changelog-file: ${{ inputs.changelog-file }} locale: ${{ inputs.locale }} tag-prefix: ${{ inputs.tag-prefix }} source: ${{ inputs.source }} managed-branch: ${{ inputs.managed-branch }} resolve-pull-requests: ${{ inputs.resolve-pull-requests }} automation-actor: ${{ inputs.automation-actor }} waiver-label: ${{ inputs.waiver-label }} maintenance-label: ${{ inputs.maintenance-label }} comment-mode: ${{ inputs.comment-mode }} secrets: token: ${{ secrets.token }} app-private-key: ${{ secrets.app-private-key }} publish: name: Publish Approved Consolidation if: >- inputs.publish && github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == (inputs.base-branch || github.event.repository.default_branch) && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == inputs.managed-branch permissions: contents: write pull-requests: read uses: ./.github/workflows/changelog-publish.yml with: app-client-id: ${{ inputs.app-client-id }} runs-on: ${{ inputs.runs-on }} pull-request: ${{ github.event.pull_request.number }} target-sha: ${{ github.event.pull_request.merge_commit_sha }} expected-head-sha: ${{ github.event.pull_request.head.sha }} base-branch: ${{ inputs.base-branch || github.event.repository.default_branch }} managed-branch: ${{ inputs.managed-branch }} automation-actor: ${{ inputs.automation-actor }} fragment-directory: ${{ inputs.fragment-directory }} changelog-file: ${{ inputs.changelog-file }} locale: ${{ inputs.locale }} tag-prefix: ${{ inputs.tag-prefix }} source: ${{ inputs.source }} resolve-pull-requests: ${{ inputs.resolve-pull-requests }} dry-run: ${{ inputs.dry-run }} secrets: token: ${{ secrets.token }} app-private-key: ${{ secrets.app-private-key }} report: name: Explain Version And Publication Results needs: [version, publish] if: >- !cancelled() && (((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == format('refs/heads/{0}', inputs.base-branch || github.event.repository.default_branch)) || (github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == inputs.managed-branch && github.event.pull_request.base.ref == (inputs.base-branch || github.event.repository.default_branch))) runs-on: ${{ fromJSON(inputs.runs-on) }} timeout-minutes: 5 permissions: {} steps: - name: Summarize The Lifecycle Without Changing Its Outcome shell: bash env: FF_PREPARATION: ${{ needs.version.result }} FF_PREPARATION_ENABLED: ${{ inputs.prepare-release }} FF_PUBLICATION: ${{ needs.publish.result }} FF_VERSION_STATUS: ${{ needs.version.outputs.status }} FF_PUBLICATION_STATE: ${{ needs.publish.outputs.state }} FF_PUBLICATION_ENABLED: ${{ inputs.publish }} FF_DRY_RUN: ${{ inputs.dry-run }} FF_PULL_REQUEST: ${{ needs.version.outputs.pull-request }} FF_VERSION: ${{ needs.version.outputs.version }} FF_TAG: ${{ needs.publish.outputs.tag }} FF_SHA: ${{ needs.publish.outputs.sha }} run: | set -euo pipefail for outcome in "$FF_PREPARATION" "$FF_PUBLICATION"; do case "$outcome" in success|failure|skipped|cancelled) ;; *) echo '::error::The lifecycle outcome is unavailable.'; exit 1 ;; esac done case "$FF_PUBLICATION_ENABLED:$FF_DRY_RUN" in true:true|true:false|false:true|false:false) ;; *) echo '::error::The lifecycle publication settings are invalid.'; exit 1 ;; esac case "$FF_PREPARATION_ENABLED" in true|false) ;; *) echo '::error::The lifecycle preparation setting is invalid.'; exit 1 ;; esac # Markdown backticks are literal formatting, never command substitutions. # shellcheck disable=SC2016 { printf '## Fast Forward Changelog\n\n' printf '| Stage | Result |\n| --- | --- |\n' printf '| Prepare version PR | %s |\n' "$FF_PREPARATION" printf '| Publish approved tag and GitHub Release | %s |\n\n' "$FF_PUBLICATION" case "$FF_VERSION_STATUS" in none|unchanged|created|updated|dry-run) printf 'Preparation result: `%s`.\n\n' "$FF_VERSION_STATUS" ;; esac case "$FF_PUBLICATION_STATE" in maintenance|dry-run|published|unchanged) printf 'Publication result: `%s`.\n\n' "$FF_PUBLICATION_STATE" ;; esac if [[ "$FF_DRY_RUN" == true ]]; then printf '**Preview only:** no version PR, tag or release is created by dry-run operations.\n\n' elif [[ "$FF_PREPARATION_ENABLED" == false ]]; then printf '**Version preparation is disabled (`prepare-release: false`).** This run does not create or update a version PR.\n\n' if [[ "$FF_PUBLICATION_ENABLED" == true ]]; then printf 'Publication remains enabled for approved merges of the configured managed version PR.\n\n' else printf 'Publication is also disabled (`publish: false`); no tag or GitHub Release is created.\n\n' fi elif [[ "$FF_PUBLICATION_ENABLED" == false ]]; then printf '> **Publication is disabled (`publish: false`).** Merging a version PR updates `CHANGELOG.md` and consumes its fragments, but creates neither its tag nor its GitHub Release. New version PRs continue from the consolidated version. Remove the override to publish future approved merges; older versions require explicit recovery.\n\n' else printf '**Automatic publication is enabled.** Preparation creates a reviewable PR. Its approved merge runs the separate tag and GitHub Release stage; preparation alone does not publish. Older consolidated versions are not published retroactively.\n\n' fi if [[ "$FF_PULL_REQUEST" =~ ^[1-9][0-9]*$ ]]; then printf 'Reported version PR: #%s. Confirm the preparation result and review its version transition, history and fragment deletions before merging.\n\n' "$FF_PULL_REQUEST" fi if [[ "$FF_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf 'Planned version: `%s`. A plan is not proof of consolidation or publication.\n\n' "$FF_VERSION" fi if [[ "$FF_TAG" =~ ^[A-Za-z0-9._/-]+$ && "$FF_SHA" =~ ^[a-f0-9]{40}$ ]]; then printf 'Reported publication target: `%s` at `%s`. Confirm the stage result; previews also report targets.\n\n' "$FF_TAG" "$FF_SHA" fi if [[ "$FF_PREPARATION" == failure || "$FF_PUBLICATION" == failure ]]; then printf '### Action required\n\nOpen the failed stage for its diagnostic. A failed publication can leave a matching tag without a release; inspect the exact approved commit before recovery. Version preparation does not depend on the previous publication succeeding.\n' fi } >> "$GITHUB_STEP_SUMMARY" if [[ "$FF_PREPARATION_ENABLED" == true && "$FF_PUBLICATION_ENABLED" == false && "$FF_DRY_RUN" == false ]]; then echo '::warning::Publication is explicitly disabled. The version PR updates history without creating a tag or GitHub Release.' fi if [[ "$FF_PREPARATION" == failure || "$FF_PUBLICATION" == failure ]]; then echo '::error::A changelog stage failed. Open the job summary and failed stage before retrying the exact approved operation.' fi