diff --git a/.github/workflows/changelog-bundle.yml b/.github/workflows/changelog-bundle.yml new file mode 100644 index 0000000..304f510 --- /dev/null +++ b/.github/workflows/changelog-bundle.yml @@ -0,0 +1,98 @@ +name: Changelog bundle + +on: + workflow_call: + inputs: + config: + description: 'Path to changelog.yml configuration file' + type: string + default: 'docs/changelog.yml' + profile: + description: > + Bundle profile name from bundle.profiles in changelog.yml. + Mutually exclusive with release-version and prs. + type: string + version: + description: > + Version string for profile mode (e.g. 9.2.0). Only valid with profile. + type: string + release-version: + description: > + GitHub release tag used as the PR filter source (e.g. v9.2.0). + Mutually exclusive with profile, report, and prs. + type: string + report: + description: > + Buildkite promotion report HTTPS URL or local file path. + Mutually exclusive with release-version and prs in option mode. + In profile mode, passed as a positional argument. + type: string + prs: + description: > + Comma-separated PR URLs or numbers, or a path to a newline-delimited file. + Mutually exclusive with profile, release-version, and report. + type: string + output: + description: > + Output file path for the bundle (e.g. docs/releases/v9.2.0.yaml). + Optional. When not provided, the path is determined by the config + (bundle.output_directory) and discovered automatically after generation. + type: string + base-branch: + description: 'Base branch for the pull request (defaults to repository default branch)' + type: string + repo: + description: 'GitHub repository name; falls back to bundle.repo in changelog.yml' + type: string + owner: + description: 'GitHub repository owner; falls back to bundle.owner in changelog.yml' + type: string + docs-builder-version: + description: > + docs-builder version to use (e.g. 0.1.100, latest, edge). + Non-edge versions are attestation-verified by the setup action. + type: string + default: 'edge' + +permissions: {} + +concurrency: + group: changelog-bundle-${{ inputs.output || inputs.profile }} + cancel-in-progress: false + +jobs: + generate: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + output: ${{ steps.bundle.outputs.output }} + steps: + - id: bundle + uses: elastic/docs-actions/changelog/bundle-create@v1 + with: + config: ${{ inputs.config }} + profile: ${{ inputs.profile }} + version: ${{ inputs.version }} + release-version: ${{ inputs.release-version }} + report: ${{ inputs.report }} + prs: ${{ inputs.prs }} + output: ${{ inputs.output }} + repo: ${{ inputs.repo }} + owner: ${{ inputs.owner }} + docs-builder-version: ${{ inputs.docs-builder-version }} + github-token: ${{ github.token }} + + create-pr: + needs: generate + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: elastic/docs-actions/changelog/bundle-pr@v1 + with: + output: ${{ needs.generate.outputs.output }} + base-branch: ${{ inputs.base-branch }} + github-token: ${{ github.token }} diff --git a/changelog/README.md b/changelog/README.md index afbfbc5..6d55388 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -178,3 +178,223 @@ If a human edits the changelog file directly (i.e., the last commit to the chang ## Output Each PR produces a file at `docs/changelog/{filename}.yaml` on the PR branch (where the filename is determined by the `docs-builder changelog add` command). These files are consumed by `docs-builder` during documentation builds to produce a rendered changelog page. + +## Bundling changelogs + +Individual changelog files accumulate on the default branch as PRs merge. The bundle action generates a fully-resolved YAML file containing only the changelog entries that match a given filter. The action supports two modes: + +**Option mode** — you specify the filter and output path directly: + +- **GitHub release version** (`release-version`) — pulls PR references directly from GitHub release notes. Used for stack and product releases triggered by `on: release`. +- **Buildkite promotion report** (`report`) — extracts PR URLs from a promotion report. Used for serverless releases discovered by a scheduled workflow. +- **PR list** (`prs`) — an explicit list of PR URLs or numbers (comma-separated), or a path to a newline-delimited file. Used when the caller already knows which PRs to include. + +Exactly one filter source must be provided. The `output` path is optional — when not provided, the action runs `docs-builder changelog bundle --plan` to resolve the output path from the config (`bundle.output_directory`) before generating the bundle. + +**Profile mode** — all configuration comes from `bundle.profiles` in `changelog.yml`: + +- **Profile** (`profile`) — a named profile that defines the product filter, output filename pattern, and other settings. The `version` input provides the value for `{version}` substitution in profile patterns. +- An optional `report` can be passed as a positional argument to filter by promotion report. +- The `output` path is optional — if not provided, it's resolved from `bundle.output_directory` in the config via the `--plan` step. +- `bundle.resolve: true` must be set in the config (it cannot be forced via CLI in profile mode). + +The bundle always includes the full content of each matching entry, so downstream consumers can render changelogs without access to the original files. + +### Prerequisites + +Your `docs/changelog.yml` must include a `bundle` section so docs-builder knows where to find changelog files. Setting `bundle.repo` and `bundle.owner` ensures PR and issue links are generated correctly in the bundle output. + +```yaml +bundle: + directory: docs/changelog +``` + +The reusable workflow splits into two jobs with separate permissions: `generate` (read-only, produces the bundle artifact) and `create-pr` (write access, opens a pull request with the bundle file). + +### Setup + +The bundle action supports multiple trigger patterns depending on your release process. + +#### Stack / product releases (`on: release`) + +When a GitHub release is published, the action uses `--release-version` to pull PR references directly from the release notes and filter changelog entries accordingly. The release tag provides the version for the output filename. + +**`.github/workflows/changelog-bundle.yml`** + +```yaml +name: changelog-bundle + +on: + release: + types: [published] + +permissions: + contents: write + pull-requests: write + +jobs: + bundle: + uses: elastic/docs-actions/.github/workflows/changelog-bundle.yml@v1 + with: + release-version: ${{ github.event.release.tag_name }} + output: docs/releases/${{ github.event.release.tag_name }}.yaml +``` + +The `github.event.release.tag_name` (e.g. `v9.2.0`) is passed as the release version filter and used to construct the output filename. If you prefer to strip the `v` prefix, you can do so in an earlier job step and pass the result as an input. + +#### Serverless / scheduled releases (`on: schedule`) + +When a Buildkite promotion report provides the list of PRs in a release, a scheduled workflow discovers the report and passes it to the bundle action. The output filename typically uses a date or timestamp. + +**`.github/workflows/changelog-bundle.yml`** + +```yaml +name: changelog-bundle + +on: + schedule: + # At 08:00 AM, Monday through Friday + - cron: '0 8 * * 1-5' + workflow_dispatch: + inputs: + report: + description: 'Buildkite promotion report URL' + required: true + output: + description: 'Output file path for the bundle' + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + discover-report: + runs-on: ubuntu-latest + outputs: + report-url: ${{ steps.discover.outputs.report-url }} + release-date: ${{ steps.discover.outputs.release-date }} + steps: + - id: discover + run: echo "# your logic to find the latest promotion report" + + bundle: + needs: discover-report + uses: elastic/docs-actions/.github/workflows/changelog-bundle.yml@v1 + with: + report: ${{ needs.discover-report.outputs.report-url }} + output: docs/releases/${{ needs.discover-report.outputs.release-date }}.yaml +``` + +#### Explicit PR list + +When the caller already knows which PRs to include, pass them directly. The `prs` input accepts either comma-separated values or a path to a newline-delimited file. PR numbers can be used instead of full URLs when `bundle.repo` and `bundle.owner` are set in the changelog config. + +**Inline PR numbers** — pass them directly in the workflow call: + +```yaml +name: changelog-bundle + +on: + workflow_dispatch: + inputs: + prs: + description: 'Comma-separated PR URLs or numbers' + required: true + output: + description: 'Output file path for the bundle' + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + bundle: + uses: elastic/docs-actions/.github/workflows/changelog-bundle.yml@v1 + with: + prs: ${{ inputs.prs }} + output: ${{ inputs.output }} +``` + +For example, triggering this workflow with `prs: "12345,67890"` bundles the changelog entries whose `prs` field matches those PR numbers. + +**File-based PR list** — when a prior step produces the list, write it to a file (one PR URL per line) and pass the path: + +```yaml +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + prs-file: ${{ steps.generate.outputs.prs-file }} + steps: + - id: generate + run: | + echo "https://github.com/elastic/my-repo/pull/12345" > prs.txt + echo "https://github.com/elastic/my-repo/pull/67890" >> prs.txt + echo "prs-file=prs.txt" >> "$GITHUB_OUTPUT" + + bundle: + needs: prepare + uses: elastic/docs-actions/.github/workflows/changelog-bundle.yml@v1 + with: + prs: ${{ needs.prepare.outputs.prs-file }} + output: docs/releases/my-bundle.yaml +``` + +When using a file, every line must be a fully-qualified GitHub PR URL (e.g. `https://github.com/owner/repo/pull/123`). Bare numbers are only supported in the comma-separated format. + +#### Profile-based bundling + +When your repository has `bundle.profiles` configured in `changelog.yml`, the profile drives which changelogs to include and where to write the bundle. Set `bundle.resolve: true` in the config so entry contents are inlined. + +```yaml +bundle: + directory: docs/changelog + output_directory: docs/releases + resolve: true + repo: my-repo + owner: elastic + profiles: + my-release: + products: "my-product {version} {lifecycle}" + output: "{version}.yaml" +``` + +**`.github/workflows/changelog-bundle.yml`** + +```yaml +name: changelog-bundle + +on: + release: + types: [published] + +permissions: + contents: write + pull-requests: write + +jobs: + bundle: + uses: elastic/docs-actions/.github/workflows/changelog-bundle.yml@v1 + with: + profile: my-release + version: ${{ github.event.release.tag_name }} +``` + +The `output` input is not needed — the action resolves the output path from `bundle.output_directory` and the profile's `output` pattern via the `--plan` step. If a promotion report is also needed, pass it via the `report` input. + +#### Custom config path + +If your changelog configuration is not at `docs/changelog.yml`, pass the path explicitly: + +```yaml + with: + config: path/to/changelog.yml + release-version: ${{ github.event.release.tag_name }} + output: docs/releases/${{ github.event.release.tag_name }}.yaml +``` + +### Output + +The reusable workflow opens a pull request on a branch named `changelog-bundle/` (e.g. `changelog-bundle/v9.2.0`). The PR contains the fully-resolved bundle file at the path specified by the `output` input. If a PR already exists for that branch, the bundle is updated in place. If the generated bundle is identical to what's already in the repository, no commit or PR is created. diff --git a/changelog/bundle-create/README.md b/changelog/bundle-create/README.md new file mode 100644 index 0000000..fa43529 --- /dev/null +++ b/changelog/bundle-create/README.md @@ -0,0 +1,52 @@ + +# Changelog bundle create + +Checks out the repository, runs docs-builder changelog bundle in Docker to generate a fully-resolved bundle file, and uploads the result as an artifact. Supports option-based filtering (release-version, report, prs) and profile-based bundling. Uses --network none where possible. + + +## Inputs + +| Name | Description | Required | Default | +|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-----------------------| +| `config` | Path to changelog.yml configuration file | `false` | `docs/changelog.yml` | +| `profile` | Bundle profile name from bundle.profiles in changelog.yml. Mutually exclusive with release-version and prs. When set, all paths and filters come from the config.
| `false` | ` ` | +| `version` | Version string for profile mode (e.g. 9.2.0, 2026-03). Used for {version} substitution in profile patterns. Only valid with profile.
| `false` | ` ` | +| `release-version` | GitHub release tag used as the PR filter source (e.g. v9.2.0). Mutually exclusive with profile, report, and prs.
| `false` | ` ` | +| `report` | Buildkite promotion report URL or local file path used as the PR filter source. In option mode, mutually exclusive with release-version and prs. In profile mode, passed as a positional argument. Local paths must be relative to the repo root.
| `false` | ` ` | +| `prs` | Comma-separated PR URLs or numbers, or a path to a newline-delimited file. Mutually exclusive with profile, release-version, and report. Bare numbers require repo/owner to be set in changelog.yml or inputs.
| `false` | ` ` | +| `output` | Output file path for the bundle, relative to the repo root. Optional in both modes. When not provided, docs-builder writes to the path determined by the config (bundle.output_directory) and the plan resolves the generated file path automatically.
| `false` | ` ` | +| `repo` | GitHub repository name. Falls back to bundle.repo in changelog.yml.
| `false` | ` ` | +| `owner` | GitHub repository owner. Falls back to bundle.owner in changelog.yml, then to elastic.
| `false` | ` ` | +| `docs-builder-version` | docs-builder version to use (e.g. 0.1.100, latest, edge). Non-edge versions are attestation-verified by the setup action.
| `false` | `edge` | +| `artifact-name` | Name for the uploaded artifact (must match bundle-pr artifact-name) | `false` | `changelog-bundle` | +| `github-token` | GitHub token (needed for release-version and source: github_release profiles) | `false` | `${{ github.token }}` | + + +## Outputs + +| Name | Description | +|----------|------------------------------------------| +| `output` | Resolved output file path for the bundle | + + +## Usage + + +Option mode: +```yaml +steps: + - uses: elastic/docs-actions/changelog/bundle-create@v1 + with: + release-version: v9.2.0 + output: docs/releases/v9.2.0.yaml +``` + +Profile mode: +```yaml +steps: + - uses: elastic/docs-actions/changelog/bundle-create@v1 + with: + profile: elasticsearch-release + version: 9.2.0 +``` + diff --git a/changelog/bundle-create/action.yml b/changelog/bundle-create/action.yml new file mode 100644 index 0000000..84ce7f4 --- /dev/null +++ b/changelog/bundle-create/action.yml @@ -0,0 +1,251 @@ +name: Changelog bundle create +description: > + Checks out the repository, runs docs-builder changelog bundle in Docker + to generate a fully-resolved bundle file, and uploads the result as an + artifact. Supports option-based filtering (release-version, report, prs) + and profile-based bundling. Uses --network none where possible. + +inputs: + config: + description: 'Path to changelog.yml configuration file' + default: 'docs/changelog.yml' + profile: + description: > + Bundle profile name from bundle.profiles in changelog.yml. + Mutually exclusive with release-version and prs. When set, + all paths and filters come from the config. + version: + description: > + Version string for profile mode (e.g. 9.2.0, 2026-03). + Used for {version} substitution in profile patterns. + Only valid with profile. + release-version: + description: > + GitHub release tag used as the PR filter source (e.g. v9.2.0). + Mutually exclusive with profile, report, and prs. + report: + description: > + Buildkite promotion report URL or local file path used as the + PR filter source. In option mode, mutually exclusive with + release-version and prs. In profile mode, passed as a positional + argument. Local paths must be relative to the repo root. + prs: + description: > + Comma-separated PR URLs or numbers, or a path to a newline-delimited + file. Mutually exclusive with profile, release-version, and report. + Bare numbers require repo/owner to be set in changelog.yml or inputs. + output: + description: > + Output file path for the bundle, relative to the repo root. + Optional in both modes. When not provided, docs-builder writes + to the path determined by the config (bundle.output_directory) + and the plan resolves the generated file path automatically. + repo: + description: > + GitHub repository name. Falls back to bundle.repo in changelog.yml. + owner: + description: > + GitHub repository owner. Falls back to bundle.owner in changelog.yml, + then to elastic. + docs-builder-version: + description: > + docs-builder version to use (e.g. 0.1.100, latest, edge). + Non-edge versions are attestation-verified by the setup action. + default: 'edge' + artifact-name: + description: 'Name for the uploaded artifact (must match bundle-pr artifact-name)' + default: 'changelog-bundle' + github-token: + description: 'GitHub token (needed for release-version and source: github_release profiles)' + default: '${{ github.token }}' + +outputs: + output: + description: 'Resolved output file path for the bundle' + value: ${{ steps.plan.outputs.output_path }} + +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Validate inputs + shell: bash + env: + CONFIG: ${{ inputs.config }} + PROFILE: ${{ inputs.profile }} + VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ inputs.release-version }} + OUTPUT: ${{ inputs.output }} + REPORT: ${{ inputs.report }} + PRS: ${{ inputs.prs }} + REPO: ${{ inputs.repo }} + OWNER: ${{ inputs.owner }} + run: | + validate_path() { + local value="$1" name="$2" + [ -z "$value" ] && return + if [[ "$value" == /* ]]; then + echo "::error::${name} must be a relative path: ${value}"; exit 1 + fi + if [[ "$value" == *..* ]]; then + echo "::error::${name} must not contain '..': ${value}"; exit 1 + fi + } + + validate_identifier() { + local value="$1" name="$2" pattern="$3" + [ -z "$value" ] && return + if [[ ! "$value" =~ $pattern ]]; then + echo "::error::${name} contains disallowed characters: ${value}"; exit 1 + fi + } + + validate_path "$CONFIG" "config" + validate_path "$OUTPUT" "output" + + if [ -n "$REPORT" ] && [[ "$REPORT" != https://* ]] && [[ "$REPORT" != http://* ]]; then + validate_path "$REPORT" "report" + fi + if [ -n "$PRS" ] && { [[ "$PRS" == */* ]] || [[ "$PRS" == *.txt ]]; }; then + validate_path "$PRS" "prs" + fi + + validate_identifier "$PROFILE" "profile" '^[a-zA-Z0-9._-]+$' + validate_identifier "$VERSION" "version" '^[a-zA-Z0-9._+-]+$' + validate_identifier "$RELEASE_VERSION" "release-version" '^[a-zA-Z0-9._+-]+$' + validate_identifier "$REPO" "repo" '^[a-zA-Z0-9._-]+$' + validate_identifier "$OWNER" "owner" '^[a-zA-Z0-9._-]+$' + + if [ -n "$REPORT" ] && [[ "$REPORT" == http://* ]]; then + echo "::error::Report URL must use HTTPS: ${REPORT}"; exit 1 + fi + + - name: Setup docs-builder + uses: elastic/docs-actions/docs-builder/setup@v1 + with: + version: ${{ inputs.docs-builder-version }} + github-token: ${{ inputs.github-token }} + + - name: Resolve bundle plan + id: plan + shell: bash + env: + CONFIG: ${{ inputs.config }} + PROFILE: ${{ inputs.profile }} + VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ inputs.release-version }} + REPORT: ${{ inputs.report }} + PRS: ${{ inputs.prs }} + OUTPUT: ${{ inputs.output }} + REPO: ${{ inputs.repo }} + OWNER: ${{ inputs.owner }} + run: | + ARGS=() + ARGS+=(changelog bundle --plan) + if [ -n "$PROFILE" ]; then + ARGS+=("$PROFILE") + [ -n "$VERSION" ] && ARGS+=("$VERSION") + [ -n "$REPORT" ] && ARGS+=("$REPORT") + else + ARGS+=(--config "$CONFIG" --resolve) + [ -n "$RELEASE_VERSION" ] && ARGS+=(--release-version "$RELEASE_VERSION") + [ -n "$REPORT" ] && ARGS+=(--report "$REPORT") + [ -n "$PRS" ] && ARGS+=(--prs "$PRS") + [ -n "$OUTPUT" ] && ARGS+=(--output "$OUTPUT") + [ -n "$REPO" ] && ARGS+=(--repo "$REPO") + [ -n "$OWNER" ] && ARGS+=(--owner "$OWNER") + fi + + docs-builder "${ARGS[@]}" + + - name: Verify plan output + shell: bash + env: + OUTPUT_PATH: ${{ steps.plan.outputs.output_path }} + run: | + if [ -z "$OUTPUT_PATH" ]; then + echo "::error::Plan did not resolve an output path. Set 'output' input or configure bundle.output_directory in changelog.yml." + exit 1 + fi + + - name: Download report + if: inputs.report != '' && startsWith(inputs.report, 'https://') + shell: bash + env: + REPORT: ${{ inputs.report }} + run: curl --fail --silent --show-error --location --max-redirs 5 --max-time 30 "$REPORT" -o .bundle-report.html + + - name: Pull and pin Docker image + id: docker-image + shell: bash + env: + BUILDER_VERSION: ${{ inputs.docs-builder-version }} + run: | + IMAGE="ghcr.io/elastic/docs-builder:${BUILDER_VERSION}" + docker pull "$IMAGE" + DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE") + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "::notice title=Docker image digest::${DIGEST}" + + - name: Generate changelog bundle + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + NEEDS_NETWORK: ${{ steps.plan.outputs.needs_network }} + NEEDS_GITHUB_TOKEN: ${{ steps.plan.outputs.needs_github_token }} + IMAGE_DIGEST: ${{ steps.docker-image.outputs.digest }} + CONFIG: ${{ inputs.config }} + PROFILE: ${{ inputs.profile }} + VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ inputs.release-version }} + REPORT: ${{ inputs.report }} + PRS: ${{ inputs.prs }} + OUTPUT: ${{ inputs.output }} + REPO: ${{ inputs.repo }} + OWNER: ${{ inputs.owner }} + run: | + DOCKER_ARGS=(--rm -v "${PWD}:/github/workspace" -w /github/workspace) + + if [ "$NEEDS_NETWORK" = "true" ]; then + [ "$NEEDS_GITHUB_TOKEN" = "true" ] && DOCKER_ARGS+=(-e GITHUB_TOKEN) + else + DOCKER_ARGS+=(--network none) + fi + + resolve_report() { + if [ -n "$1" ] && [[ "$1" == https://* ]]; then + echo ".bundle-report.html" + else + echo "$1" + fi + } + + BUNDLE_ARGS=() + BUNDLE_ARGS+=(changelog bundle) + if [ -n "$PROFILE" ]; then + BUNDLE_ARGS+=("$PROFILE") + [ -n "$VERSION" ] && BUNDLE_ARGS+=("$VERSION") + [ -n "$REPORT" ] && BUNDLE_ARGS+=("$(resolve_report "$REPORT")") + else + BUNDLE_ARGS+=(--config "$CONFIG" --resolve) + [ -n "$RELEASE_VERSION" ] && BUNDLE_ARGS+=(--release-version "$RELEASE_VERSION") + [ -n "$REPORT" ] && BUNDLE_ARGS+=(--report "$(resolve_report "$REPORT")") + [ -n "$PRS" ] && BUNDLE_ARGS+=(--prs "$PRS") + [ -n "$OUTPUT" ] && BUNDLE_ARGS+=(--output "$OUTPUT") + [ -n "$REPO" ] && BUNDLE_ARGS+=(--repo "$REPO") + [ -n "$OWNER" ] && BUNDLE_ARGS+=(--owner "$OWNER") + fi + + docker run "${DOCKER_ARGS[@]}" "$IMAGE_DIGEST" "${BUNDLE_ARGS[@]}" + + - name: Upload bundle artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.artifact-name }} + path: ${{ steps.plan.outputs.output_path }} + if-no-files-found: error + retention-days: 1 diff --git a/changelog/bundle-pr/README.md b/changelog/bundle-pr/README.md new file mode 100644 index 0000000..8ec07da --- /dev/null +++ b/changelog/bundle-pr/README.md @@ -0,0 +1,31 @@ + +# Changelog bundle PR + +Downloads a changelog bundle artifact and opens a pull request to add it to the repository. If a PR already exists for the same bundle, it is updated in place. + + +## Inputs + +| Name | Description | Required | Default | +|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-----------------------| +| `output` | Output file path for the bundle, relative to the repo root (e.g. docs/releases/v9.2.0.yaml). Must match the path used by the bundle-create action.
| `true` | ` ` | +| `base-branch` | Base branch for the pull request (defaults to repository default branch) | `false` | ` ` | +| `artifact-name` | Name of the artifact uploaded by bundle-create | `false` | `changelog-bundle` | +| `github-token` | GitHub token with contents:write and pull-requests:write permissions | `false` | `${{ github.token }}` | + + +## Outputs + +| Name | Description | +|------|-------------| + + +## Usage + +```yaml +steps: + - uses: elastic/docs-actions/changelog/bundle-pr@v1 + with: + output: docs/releases/v9.2.0.yaml +``` + diff --git a/changelog/bundle-pr/action.yml b/changelog/bundle-pr/action.yml new file mode 100644 index 0000000..8ad21ca --- /dev/null +++ b/changelog/bundle-pr/action.yml @@ -0,0 +1,107 @@ +name: Changelog bundle PR +description: > + Downloads a changelog bundle artifact and opens a pull request to add it + to the repository. If a PR already exists for the same bundle, it is + updated in place. + +inputs: + output: + description: > + Output file path for the bundle, relative to the repo root + (e.g. docs/releases/v9.2.0.yaml). Must match the path used + by the bundle-create action. + required: true + base-branch: + description: 'Base branch for the pull request (defaults to repository default branch)' + default: '' + artifact-name: + description: 'Name of the artifact uploaded by bundle-create' + default: 'changelog-bundle' + github-token: + description: 'GitHub token with contents:write and pull-requests:write permissions' + default: '${{ github.token }}' + +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Validate output path + shell: bash + env: + OUTPUT: ${{ inputs.output }} + run: | + if [[ "$OUTPUT" != *.yml && "$OUTPUT" != *.yaml ]]; then + echo "::error::Output path must end in .yml or .yaml: ${OUTPUT}" + exit 1 + fi + if [[ "$OUTPUT" == /* ]]; then + echo "::error::Output path must be relative: ${OUTPUT}" + exit 1 + fi + if [[ "$OUTPUT" == *..* ]]; then + echo "::error::Output path must not contain '..': ${OUTPUT}" + exit 1 + fi + + - name: Download bundle artifact + uses: actions/download-artifact@v6 + with: + name: ${{ inputs.artifact-name }} + path: .bundle-artifact + + - name: Create pull request + shell: bash + env: + OUTPUT: ${{ inputs.output }} + BASE_BRANCH: ${{ inputs.base-branch }} + GH_TOKEN: ${{ inputs.github-token }} + GIT_REPOSITORY: ${{ github.repository }} + run: | + BUNDLE_NAME=$(basename "$OUTPUT" .yaml) + BUNDLE_NAME=$(basename "$BUNDLE_NAME" .yml) + + if [[ ! "$BUNDLE_NAME" =~ ^[a-zA-Z0-9._+-]+$ ]]; then + echo "::error::Bundle name contains disallowed characters: ${BUNDLE_NAME}" + exit 1 + fi + + BRANCH="changelog-bundle/${BUNDLE_NAME}" + + mkdir -p "$(dirname "$OUTPUT")" + cp ".bundle-artifact/$(basename "$OUTPUT")" "$OUTPUT" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git checkout -b "$BRANCH" + git add "$OUTPUT" + + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "Add changelog bundle ${BUNDLE_NAME}" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GIT_REPOSITORY}.git" + git push --force-with-lease origin "$BRANCH" + git remote set-url origin "https://github.com/${GIT_REPOSITORY}.git" + + BASE_FLAG=() + if [ -n "$BASE_BRANCH" ]; then + BASE_FLAG=(--base "$BASE_BRANCH") + fi + + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING_PR" ]; then + echo "PR #${EXISTING_PR} already exists for branch ${BRANCH}, updated with latest bundle" + else + gh pr create \ + --title "Add changelog bundle ${BUNDLE_NAME}" \ + --body "Auto-generated changelog bundle for ${BUNDLE_NAME}." \ + --head "$BRANCH" \ + "${BASE_FLAG[@]}" + fi