Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/changelog-upload.yml
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing top-level permissions: {}. Both changelog-validate.yml (line 11) and changelog-submit.yml (line 15) deny all permissions at the workflow scope and then grant only what each job needs. Without this, the workflow inherits the repository's default token permissions.

Add before jobs::

permissions: {}

upload:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Upload changelog
uses: elastic/docs-actions/changelog/upload@main
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinned to @main (a mutable branch tip). Every other workflow in this repo uses @v1. With @main, any commit to the main branch silently changes behaviour for all consumers on their next run — this is the definition of a supply-chain risk.

Change to:

uses: elastic/docs-actions/changelog/upload@v1

with:
config: ${{ inputs.config }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.idea/
node_modules/
52 changes: 52 additions & 0 deletions changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions changelog/upload/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!-- Generated by https://github.com/reakaleek/gh-action-readme -->
# <!--name-->Changelog upload<!--/name-->
<!--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.
<!--/description-->

## Inputs
<!--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` |
<!--/inputs-->

## Outputs
<!--outputs-->
| Name | Description |
|------|-------------|
<!--/outputs-->

## Usage
<!--usage action="your/action" version="v1"-->
<!--/usage-->
64 changes: 64 additions & 0 deletions changelog/upload/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge_commit_sha is only populated when the triggering event is a merged pull_request. If empty, actions/checkout silently falls back to the default branch HEAD — a different commit than what was merged, with no error or warning.

Compare: changelog/submit/action.yml lines 93–103 explicitly verifies the checked-out SHA matches the expected value before proceeding.

Add a guard step before the checkout:

- name: Verify event context
  shell: bash
  env:
    MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
  run: |
    if [ -z "$MERGE_SHA" ]; then
      echo "::error::merge_commit_sha is empty — must be triggered from a merged pull_request event"
      exit 1
    fi

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}"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a risk here since PRODUCT comes from the YAML file content and gets used directly in the S3 key. Would it make sense to validate it in prepare-upload.js before it reaches this point? Something like checking it matches [a-zA-Z0-9_-]+ to avoid any unexpected path traversal if a product value ever contains ../ or similar characters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, doing it now via 1b90a61

aws s3 cp "${FRAGMENT}" "s3://elastic-docs-v3-changelog-bundles/${S3_KEY}" \
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing --no-follow-symlinks. A merged PR can include a symlink inside the changelog directory (e.g. docs/changelog/v9.2.0.yaml -> /home/runner/.config/...) and aws s3 cp will follow it and silently upload the target file's contents to S3.

docs-deploy.yml uses --no-follow-symlinks on all S3 copy operations for exactly this reason. Apply the same guard:

aws s3 cp "${FRAGMENT}" "s3://elastic-docs-v3-changelog-bundles/${S3_KEY}" \
  --no-follow-symlinks \
  --checksum-algorithm SHA256

--checksum-algorithm SHA256
done <<< "${UPLOAD_PAIRS}"
30 changes: 30 additions & 0 deletions changelog/upload/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions changelog/upload/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"private": true,
"dependencies": {
"js-yaml": "^4.1.0"
}
}
90 changes: 90 additions & 0 deletions changelog/upload/scripts/prepare-upload.js
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick thought, listFiles caps at 100 results per page here. Would it make sense to use github.paginate() instead, just in case a PR touches more than 100 files? Probably rare for changelog PRs but larger refactor PRs that also include a changelog entry could hit this silently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, and we used paginate for changelog additions too. Added in 1b90a61

});

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}`);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a space as the field separator between fragmentPath and product is fragile. A changelog filename with a space would cause the bash while IFS=' ' read -r FRAGMENT PRODUCT to split incorrectly — FRAGMENT gets the path up to the first space, PRODUCT gets the remainder, and the product regex silently rejects it.

Consider using a tab (\t) or a sentinel like :: as separator, and adjust the bash IFS accordingly. Or encode the pairs as JSON and parse in bash with jq.

}
}

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 (_) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to use a YAML parsing library here (like js-yaml) instead of parsing line by line? The regex approach could miss things like inline values, quoted strings, or comments after values. Since actions/github-script runs in Node, js-yaml should be available or easy to add. That would cover both this function and readProducts below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done at 1b90a61.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on using https://github.com/vercel/ncc, I think this is a common pattern to bundle the JS, including its npm dependencies.

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);
}
Loading