From 1dd68e0850584790653fc0ec6e1f5f42dc72f093 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 26 Mar 2026 14:09:41 -0300 Subject: [PATCH 1/3] Add S3 upload support for changelogs --- .github/workflows/changelog-upload.yml | 25 ++++++ changelog/README.md | 52 +++++++++++++ changelog/upload/action.yml | 60 +++++++++++++++ changelog/upload/scripts/prepare-upload.js | 90 ++++++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 .github/workflows/changelog-upload.yml create mode 100644 changelog/upload/action.yml create mode 100644 changelog/upload/scripts/prepare-upload.js diff --git a/.github/workflows/changelog-upload.yml b/.github/workflows/changelog-upload.yml new file mode 100644 index 0000000..9244261 --- /dev/null +++ b/.github/workflows/changelog-upload.yml @@ -0,0 +1,25 @@ +name: Changelog upload + +on: + workflow_call: + inputs: + config: + description: 'Path to changelog.yml configuration file' + type: string + default: 'docs/changelog.yml' + +concurrency: + group: changelog-upload-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + upload: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Upload changelog + uses: elastic/docs-actions/changelog/upload@main + with: + config: ${{ inputs.config }} diff --git a/changelog/README.md b/changelog/README.md index bf94d86..27ca7a4 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -179,3 +179,55 @@ 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. + +## Uploading to S3 + +When a PR is merged, the committed changelog file can be uploaded to the `elastic-docs-v3-changelog-bundles` S3 bucket under `{product}/changelogs/{filename}.yaml`, preserving the original filename as determined by the repository's `filename` strategy in `changelog.yml`. This makes it available for release bundling workflows. + +### 1. Add the upload workflow + +**`.github/workflows/changelog-upload.yml`** + +```yaml +name: changelog-upload + +on: + pull_request: + types: + - closed + +permissions: + contents: read + id-token: write + +jobs: + upload: + if: github.event.pull_request.merged == true + uses: elastic/docs-actions/.github/workflows/changelog-upload.yml@v1 +``` + +If your changelog configuration is not at `docs/changelog.yml`, pass the path explicitly: + +```yaml +jobs: + upload: + if: github.event.pull_request.merged == true + uses: elastic/docs-actions/.github/workflows/changelog-upload.yml@v1 + with: + config: path/to/changelog.yml +``` + +### 2. Enable OIDC access + +The upload workflow authenticates to AWS via GitHub Actions OIDC. Your repository must be listed in the `elastic-docs-v3-changelog-bundles` infrastructure to have an IAM role provisioned. Contact the docs-engineering team to add your repository. + +### How it works + +When a PR is merged, the upload workflow: + +1. Checks out the merge commit +2. Reads `bundle.directory` from your `changelog.yml` to locate the changelog folder +3. Queries the GitHub API for YAML files that were added or modified in that folder during the PR +4. For each file found, reads the `products` list and uploads the file to `{product}/changelogs/{filename}.yaml` in the bucket, preserving the original filename + +If the PR has no changelog file (for example, because changelog generation was skipped), the workflow exits silently without error. diff --git a/changelog/upload/action.yml b/changelog/upload/action.yml new file mode 100644 index 0000000..8d8e5ed --- /dev/null +++ b/changelog/upload/action.yml @@ -0,0 +1,60 @@ +name: Changelog upload +description: > + Uploads the changelog entry committed for a merged PR to the + elastic-docs-v3-changelog-bundles S3 bucket under {product}/changelogs/{filename}. + Preserves the original filename as set by the repository's changelog configuration. + Exits silently when no changelog file is found in the bundle.directory for this PR. + +inputs: + config: + description: 'Path to changelog.yml configuration file' + default: 'docs/changelog.yml' + github-token: + description: 'GitHub token with contents:read' + default: '${{ github.token }}' + aws-account-id: + description: 'The AWS account ID' + default: '197730964718' + +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + persist-credentials: false + + - name: Find changelog files and prepare upload targets + id: prepare + uses: actions/github-script@v8 + env: + CONFIG_FILE: ${{ inputs.config }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + github-token: ${{ inputs.github-token }} + script: | + const script = require('${{ github.action_path }}/scripts/prepare-upload.js'); + await script({ github, context, core }); + + - name: Authenticate with AWS + if: steps.prepare.outputs.has-uploads == 'true' + uses: elastic/docs-actions/aws/auth@v1 + with: + aws_account_id: ${{ inputs.aws-account-id }} + aws_role_name_prefix: elastic-docs-v3-changelog- + + - name: Upload changelogs to S3 + if: steps.prepare.outputs.has-uploads == 'true' + shell: bash + env: + UPLOAD_PAIRS: ${{ steps.prepare.outputs.upload-pairs }} + AWS_RETRY_MODE: standard + AWS_MAX_ATTEMPTS: 6 + run: | + while IFS=' ' read -r FRAGMENT PRODUCT; do + S3_KEY="${PRODUCT}/changelogs/$(basename "${FRAGMENT}")" + echo "Uploading ${FRAGMENT} → s3://elastic-docs-v3-changelog-bundles/${S3_KEY}" + aws s3 cp "${FRAGMENT}" "s3://elastic-docs-v3-changelog-bundles/${S3_KEY}" \ + --checksum-algorithm SHA256 + done <<< "${UPLOAD_PAIRS}" diff --git a/changelog/upload/scripts/prepare-upload.js b/changelog/upload/scripts/prepare-upload.js new file mode 100644 index 0000000..bd9009e --- /dev/null +++ b/changelog/upload/scripts/prepare-upload.js @@ -0,0 +1,90 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +'use strict'; + +const fs = require('fs'); + +module.exports = async ({ github, context, core }) => { + const configFile = process.env.CONFIG_FILE; + const prNumber = parseInt(process.env.PR_NUMBER, 10); + + const changelogDir = readChangelogDir(configFile); + + const { data: prFiles } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100, + }); + + const changelogFiles = prFiles + .filter(f => + f.filename.startsWith(changelogDir + '/') && + f.filename.endsWith('.yaml') && + (f.status === 'added' || f.status === 'modified') + ) + .map(f => f.filename); + + const pairs = []; + for (const fragmentPath of changelogFiles) { + let products; + try { + products = readProducts(fs.readFileSync(fragmentPath, 'utf8')); + } catch (e) { + core.warning(`Could not read fragment ${fragmentPath}: ${e.message}`); + continue; + } + for (const product of products) { + pairs.push(`${fragmentPath} ${product}`); + } + } + + if (pairs.length > 0) { + core.setOutput('has-uploads', 'true'); + core.setOutput('upload-pairs', pairs.join('\n')); + core.info(`Found ${pairs.length} upload target(s):`); + for (const pair of pairs) core.info(` ${pair}`); + } else { + core.setOutput('has-uploads', 'false'); + core.setOutput('upload-pairs', ''); + const reason = changelogFiles.length > 0 + ? 'no products found in changelog files' + : `no changelog files changed in ${changelogDir}/`; + core.info(`Nothing to upload (${reason})`); + } +}; + +function readChangelogDir(configFile) { + let content; + try { + content = fs.readFileSync(configFile, 'utf8'); + } catch (_) { + return 'docs/changelog'; + } + let inBundle = false; + for (const line of content.split('\n')) { + if (/^bundle:\s*$/.test(line)) { inBundle = true; continue; } + if (inBundle) { + const m = line.match(/^\s+directory:\s*(\S+)/); + if (m) return m[1]; + if (/^\S/.test(line) && !line.startsWith('#')) inBundle = false; + } + } + return 'docs/changelog'; +} + +function readProducts(content) { + const products = []; + let inProducts = false; + for (const line of content.split('\n')) { + if (/^products:\s*$/.test(line)) { inProducts = true; continue; } + if (inProducts) { + const m = line.match(/^\s+product:\s*(\S+)/); + if (m) { products.push(m[1]); continue; } + if (/^\S/.test(line) && !line.startsWith('#')) inProducts = false; + } + } + return products; +} From 612c5ee49741359f9a2ef9fa85c4022aa775c036 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 26 Mar 2026 14:18:36 -0300 Subject: [PATCH 2/3] Add README --- changelog/upload/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changelog/upload/README.md diff --git a/changelog/upload/README.md b/changelog/upload/README.md new file mode 100644 index 0000000..4455662 --- /dev/null +++ b/changelog/upload/README.md @@ -0,0 +1,24 @@ + +# Changelog upload + +Uploads the changelog entry committed for a merged PR to the elastic-docs-v3-changelog-bundles S3 bucket under {product}/changelogs/{filename}. Preserves the original filename as set by the repository's changelog configuration. Exits silently when no changelog file is found in the bundle.directory for this PR. + + +## Inputs + +| Name | Description | Required | Default | +|------------------|------------------------------------------|----------|-----------------------| +| `config` | Path to changelog.yml configuration file | `false` | `docs/changelog.yml` | +| `github-token` | GitHub token with contents:read | `false` | `${{ github.token }}` | +| `aws-account-id` | The AWS account ID | `false` | `197730964718` | + + +## Outputs + +| Name | Description | +|------|-------------| + + +## Usage + + From 1b90a61ebcea45b18f1e52974571f2d785cc031c Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 26 Mar 2026 20:35:40 -0300 Subject: [PATCH 3/3] Use js-yaml for path checking. --- .gitignore | 1 + changelog/upload/action.yml | 4 +++ changelog/upload/package-lock.json | 30 ++++++++++++++++ changelog/upload/package.json | 6 ++++ changelog/upload/scripts/prepare-upload.js | 42 +++++++++++----------- 5 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 changelog/upload/package-lock.json create mode 100644 changelog/upload/package.json diff --git a/.gitignore b/.gitignore index 9f11b75..1fe1b00 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea/ +node_modules/ diff --git a/changelog/upload/action.yml b/changelog/upload/action.yml index 8d8e5ed..6c2a7b5 100644 --- a/changelog/upload/action.yml +++ b/changelog/upload/action.yml @@ -25,6 +25,10 @@ runs: ref: ${{ github.event.pull_request.merge_commit_sha }} persist-credentials: false + - name: Install upload dependencies + shell: bash + run: npm ci --prefix ${{ github.action_path }} + - name: Find changelog files and prepare upload targets id: prepare uses: actions/github-script@v8 diff --git a/changelog/upload/package-lock.json b/changelog/upload/package-lock.json new file mode 100644 index 0000000..3b276bf --- /dev/null +++ b/changelog/upload/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "upload", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/changelog/upload/package.json b/changelog/upload/package.json new file mode 100644 index 0000000..4267179 --- /dev/null +++ b/changelog/upload/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/changelog/upload/scripts/prepare-upload.js b/changelog/upload/scripts/prepare-upload.js index bd9009e..cef6cea 100644 --- a/changelog/upload/scripts/prepare-upload.js +++ b/changelog/upload/scripts/prepare-upload.js @@ -5,6 +5,9 @@ 'use strict'; const fs = require('fs'); +const yaml = require('js-yaml'); + +const PRODUCT_RE = /^[a-zA-Z0-9_-]+$/; module.exports = async ({ github, context, core }) => { const configFile = process.env.CONFIG_FILE; @@ -12,7 +15,7 @@ module.exports = async ({ github, context, core }) => { const changelogDir = readChangelogDir(configFile); - const { data: prFiles } = await github.rest.pulls.listFiles({ + const prFiles = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, @@ -37,6 +40,13 @@ module.exports = async ({ github, context, core }) => { continue; } for (const product of products) { + if (!PRODUCT_RE.test(product)) { + core.warning( + `Skipping invalid product name "${product}" in ${fragmentPath} ` + + '(must match [a-zA-Z0-9_-]+)' + ); + continue; + } pairs.push(`${fragmentPath} ${product}`); } } @@ -63,28 +73,18 @@ function readChangelogDir(configFile) { } catch (_) { return 'docs/changelog'; } - let inBundle = false; - for (const line of content.split('\n')) { - if (/^bundle:\s*$/.test(line)) { inBundle = true; continue; } - if (inBundle) { - const m = line.match(/^\s+directory:\s*(\S+)/); - if (m) return m[1]; - if (/^\S/.test(line) && !line.startsWith('#')) inBundle = false; - } + try { + const config = yaml.load(content); + return config?.bundle?.directory || 'docs/changelog'; + } catch (_) { + return 'docs/changelog'; } - return 'docs/changelog'; } function readProducts(content) { - const products = []; - let inProducts = false; - for (const line of content.split('\n')) { - if (/^products:\s*$/.test(line)) { inProducts = true; continue; } - if (inProducts) { - const m = line.match(/^\s+product:\s*(\S+)/); - if (m) { products.push(m[1]); continue; } - if (/^\S/.test(line) && !line.startsWith('#')) inProducts = false; - } - } - return products; + const doc = yaml.load(content); + if (!doc || !Array.isArray(doc.products)) return []; + return doc.products + .map(entry => (typeof entry === 'string' ? entry : entry?.product)) + .filter(Boolean); }