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/.gitignore b/.gitignore index 9f11b75..1fe1b00 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea/ +node_modules/ 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/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 + + diff --git a/changelog/upload/action.yml b/changelog/upload/action.yml new file mode 100644 index 0000000..6c2a7b5 --- /dev/null +++ b/changelog/upload/action.yml @@ -0,0 +1,64 @@ +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: 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 + 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/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 new file mode 100644 index 0000000..cef6cea --- /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'); +const yaml = require('js-yaml'); + +const PRODUCT_RE = /^[a-zA-Z0-9_-]+$/; + +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 prFiles = await github.paginate(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) { + 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}`); + } + } + + 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'; + } + try { + const config = yaml.load(content); + return config?.bundle?.directory || 'docs/changelog'; + } catch (_) { + return 'docs/changelog'; + } +} + +function readProducts(content) { + 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); +}