Skip to content
Merged
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
20 changes: 19 additions & 1 deletion .github/workflows/docs-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
outputs:
build_outcome: ${{ steps.docs-build.outcome == 'success' && 'success' || '' }}
skip: ${{ steps.docs-build.outputs.skip }}
Expand Down Expand Up @@ -450,6 +451,22 @@ jobs:
echo "PATH_PREFIX=${path_prefix}" >> "$GITHUB_ENV"
echo "result=${path_prefix}" >> "$GITHUB_OUTPUT"

# Resolve the mutable :edge tag once so every later use in this job is
# tied to the same immutable image digest.
- name: Pull and pin docs-builder image
id: docker-image
# language=bash
run: |
IMAGE="ghcr.io/elastic/docs-builder:edge"
docker pull "$IMAGE"
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")
if [[ -z "$DIGEST" ]]; then
echo "::error::Failed to resolve RepoDigest for ${IMAGE}"
exit 1
fi
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "::notice title=docs-builder image digest::${DIGEST}"

# Run docs-builder in Docker isolation. Only explicitly listed env vars are
# passed to the container — ACTIONS_RUNTIME_TOKEN, ACTIONS_CACHE_URL, and
# OIDC env vars are excluded to prevent cache poisoning and credential
Expand Down Expand Up @@ -478,14 +495,15 @@ jobs:
-e GITHUB_REF="refs/heads/${HEAD_BRANCH}" \
-e INPUT_PREFIX="${PATH_PREFIX}" \
-e INPUT_STRICT="${STRICT_FLAG}" \
ghcr.io/elastic/docs-builder:edge || EXIT_CODE=$?
"${IMAGE_DIGEST}" || EXIT_CODE=$?

if [ -s "$CONTAINER_OUTPUT" ]; then
cat "$CONTAINER_OUTPUT" >> "$GITHUB_OUTPUT"
fi

exit $EXIT_CODE
env:
IMAGE_DIGEST: ${{ steps.docker-image.outputs.digest }}
STRICT_FLAG: ${{ fromJSON(inputs.strict != '' && inputs.strict || 'true') }}

- name: Upload links artifact
Expand Down
36 changes: 36 additions & 0 deletions changelog/shared/scripts/pr-body.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const fs = require('fs');
const path = require('path');

const BODY_FILE_MAX_BYTES = 64 * 1024;

const truncateUtf8 = (value, maxBytes = BODY_FILE_MAX_BYTES) => {
const bytes = Buffer.from(String(value ?? ''), 'utf8');
if (bytes.length <= maxBytes) return bytes.toString('utf8');

// If the byte immediately after the cap is a continuation byte, the cap
// splits a multi-byte character. Drop that entire character rather than
// writing a replacement character to the staged body.
let end = maxBytes;
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
return bytes.subarray(0, end).toString('utf8');
};

const stagePrBody = (body, runnerTemp, fileName = 'changelog-pr-body.md') => {
if (!runnerTemp) throw new Error('RUNNER_TEMP is not set');

const sanitized = String(body ?? '').replace(/\u0000/g, '');
const content = truncateUtf8(sanitized);
const filePath = path.join(runnerTemp, fileName);

fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o600 });
fs.chmodSync(filePath, 0o600);

return {
path: filePath,
originalBytes: Buffer.byteLength(sanitized, 'utf8'),
writtenBytes: Buffer.byteLength(content, 'utf8'),
truncated: content !== sanitized,
};
};

module.exports = { BODY_FILE_MAX_BYTES, stagePrBody, truncateUtf8 };
20 changes: 18 additions & 2 deletions changelog/submit/apply/scripts/comment-helper.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
const TITLE = '### 📋 Changelog';

const escapeMarkdown = (s) => s.replace(/([[\]()\\`*_{}#+\-.!|])/g, '\\$1');
const longestBacktickRun = (value) => {
const runs = String(value ?? '').match(/`+/g) ?? [];
return runs.reduce((longest, run) => Math.max(longest, run.length), 0);
};

const wrapCodeFence = (content, language = '') => {
const text = String(content ?? '');
const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1));
return `${fence}${language}\n${text}\n${fence}`;
};

const wrapInlineCode = (value) => {
const text = String(value ?? '');
const delimiter = '`'.repeat(longestBacktickRun(text) + 1);
const padded = text.startsWith('`') || text.endsWith('`') ? ` ${text} ` : text;
return `${delimiter}${padded}${delimiter}`;
};

async function upsertComment({ github, context, prNumber, body }) {
const { owner, repo } = context.repo;
Expand All @@ -17,4 +33,4 @@ async function upsertComment({ github, context, prNumber, body }) {
}
}

module.exports = { TITLE, upsertComment, escapeMarkdown };
module.exports = { TITLE, upsertComment, wrapCodeFence, wrapInlineCode };
8 changes: 3 additions & 5 deletions changelog/submit/apply/scripts/post-comment-only.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const fs = require('fs');
const { TITLE, upsertComment, escapeMarkdown } = require('./comment-helper');
const { TITLE, upsertComment, wrapCodeFence, wrapInlineCode } = require('./comment-helper');

module.exports = async ({ github, context, core }) => {
const prNumber = parseInt(process.env.PR_NUMBER, 10);
Expand All @@ -25,11 +25,9 @@ module.exports = async ({ github, context, core }) => {
}

bodyParts.push(
`Generated changelog entry for \`${escapeMarkdown(changelogDir + '/' + files[0])}\`:`,
`Generated changelog entry for ${wrapInlineCode(changelogDir + '/' + files[0])}:`,
'',
'```yaml',
content,
'```',
wrapCodeFence(content, 'yaml'),
'',
guidance,
);
Expand Down
11 changes: 6 additions & 5 deletions changelog/submit/apply/scripts/post-failure-comment.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
const { TITLE, upsertComment } = require('./comment-helper');
const { TITLE, upsertComment, wrapInlineCode } = require('./comment-helper');

module.exports = async ({ github, context, core }) => {
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const configFile = process.env.CONFIG_FILE || 'docs/changelog.yml';
const labelRows = (process.env.LABEL_TABLE || '').trim();
const productLabelRows = (process.env.PRODUCT_LABEL_TABLE || '').trim();
const skipLabels = process.env.SKIP_LABELS || '';
const configFileCode = wrapInlineCode(configFile);

const hasTypeIssue = !!labelRows;
const hasProductIssue = !!productLabelRows;
Expand All @@ -24,7 +25,7 @@ module.exports = async ({ github, context, core }) => {
if (hasTypeIssue) {
sections.push(['', '🔖 Add one of these **type** labels to your PR:', '', labelRows].join('\n'));
} else if (!hasProductIssue) {
sections.push(`\nAdd a type label that matches your \`pivot.types\` configuration in \`${configFile}\`.`);
sections.push(`\nAdd a type label that matches your ${wrapInlineCode('pivot.types')} configuration in ${configFileCode}.`);
}

if (hasProductIssue) {
Expand All @@ -33,10 +34,10 @@ module.exports = async ({ github, context, core }) => {

let skipSection;
if (skipLabels.trim()) {
const formatted = skipLabels.split(',').map(l => `\`${l.trim()}\``).join(', ');
const formatted = skipLabels.split(',').map(label => wrapInlineCode(label.trim())).join(', ');
skipSection = `\n⏭️ To skip changelog generation, add one of these labels: ${formatted}`;
} else {
skipSection = `\n⏭️ No skip labels are configured. To allow skipping changelog generation, add a label to \`rules.create.exclude\` in \`${configFile}\`.`;
skipSection = `\n⏭️ No skip labels are configured. To allow skipping changelog generation, add a label to ${wrapInlineCode('rules.create.exclude')} in ${configFileCode}.`;
}

const body = [
Expand All @@ -46,7 +47,7 @@ module.exports = async ({ github, context, core }) => {
...sections,
skipSection,
'',
`📄 See \`${configFile}\` for the full changelog configuration.`,
`📄 See ${configFileCode} for the full changelog configuration.`,
].join('\n');

await upsertComment({ github, context, prNumber, body });
Expand Down
4 changes: 2 additions & 2 deletions changelog/submit/apply/scripts/post-success-comment.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { TITLE, upsertComment, escapeMarkdown } = require('./comment-helper');
const { TITLE, upsertComment, wrapInlineCode } = require('./comment-helper');

module.exports = async ({ github, context, core }) => {
const prNumber = parseInt(process.env.PR_NUMBER, 10);
Expand All @@ -13,7 +13,7 @@ module.exports = async ({ github, context, core }) => {
const body = [
TITLE,
'',
`📝 Changelog entry committed: [\`${escapeMarkdown(changelogFile)}\`](${viewUrl})`,
`📝 Changelog entry committed: [${wrapInlineCode(changelogFile)}](${viewUrl})`,
'',
`✏️ [Edit this changelog](${editUrl})`,
].join('\n');
Expand Down
3 changes: 1 addition & 2 deletions changelog/submit/evaluate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,7 @@ runs:
REPO_NAME: ${{ github.event.repository.name }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
PR_TITLE: ${{ steps.pr-data.outputs.title }}
# Not referenced below: docs-builder's evaluate-pr reads PR_BODY from the environment
PR_BODY: ${{ steps.pr-data.outputs.body }}
PR_BODY_FILE: ${{ steps.pr-data.outputs.body-file }}
PR_LABELS: ${{ steps.pr-data.outputs.labels }}
HEAD_REF: ${{ steps.pr-data.outputs.head-ref }}
HEAD_SHA: ${{ steps.pr-data.outputs.head-sha }}
Expand Down
38 changes: 35 additions & 3 deletions changelog/submit/evaluate/scripts/fetch-pr-data.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
const { stagePrBody } = require('../../../shared/scripts/pr-body');

const TITLE_MAX_LENGTH = 200;

const sanitizeInline = (value, maxLength) =>
String(value ?? '')
.replace(/\u0000/g, '')
.replace(/\r/g, '')
.slice(0, maxLength);

module.exports = async ({ github, context, core }) => {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
Expand All @@ -8,9 +18,31 @@ module.exports = async ({ github, context, core }) => {
core.info(`PR #${pr.number} is ${pr.state} — skipping`);
return;
}
core.setOutput('title', pr.title);
core.setOutput('body', pr.body || '');
core.setOutput('labels', pr.labels.map(l => l.name).join(','));
const labelNames = pr.labels.map(label => label.name);
const offendingLabel = labelNames.find(name => name.includes(','));
if (offendingLabel) {
core.setFailed(
`Label name contains ',' which would corrupt comma-separated parsing: ${JSON.stringify(offendingLabel)}`
);
return;
}

let staged;
try {
staged = stagePrBody(pr.body, process.env.RUNNER_TEMP);
} catch (error) {
core.setFailed(`Failed to stage PR body: ${error.message}`);
return;
}
if (staged.truncated) {
core.warning(
`PR body is ${staged.originalBytes} bytes; staged the first ${staged.writtenBytes} complete UTF-8 bytes.`
);
}

core.setOutput('title', sanitizeInline(pr.title, TITLE_MAX_LENGTH));
core.setOutput('body-file', staged.path);
core.setOutput('labels', labelNames.join(','));
core.setOutput('is-fork', String(pr.head.repo?.full_name !== pr.base.repo?.full_name));
core.setOutput('head-repo', pr.head.repo?.full_name || '');
core.setOutput('maintainer-can-modify', String(pr.maintainer_can_modify ?? false));
Expand Down
8 changes: 6 additions & 2 deletions changelog/validate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ runs:
version: edge
github-token: ${{ inputs.github-token }}

- name: Stage PR body
id: stage-body
shell: bash
run: node "${{ github.action_path }}/scripts/stage-pr-body.js"

- name: Evaluate PR
id: evaluate
shell: bash
Expand All @@ -44,8 +49,7 @@ runs:
REPO_NAME: ${{ github.event.repository.name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
# Not referenced below: docs-builder's evaluate-pr reads PR_BODY from the environment
PR_BODY: ${{ github.event.pull_request.body }}
PR_BODY_FILE: ${{ steps.stage-body.outputs.path }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
Expand Down
32 changes: 32 additions & 0 deletions changelog/validate/scripts/stage-pr-body.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const fs = require('fs');
const { stagePrBody } = require('../../shared/scripts/pr-body');

const main = (env = process.env) => {
if (!env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH is not set');
if (!env.GITHUB_OUTPUT) throw new Error('GITHUB_OUTPUT is not set');

const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
if (!event.pull_request) throw new Error('GitHub event does not contain a pull_request');

const staged = stagePrBody(event.pull_request.body, env.RUNNER_TEMP);
fs.appendFileSync(env.GITHUB_OUTPUT, `path=${staged.path}\n`, 'utf8');

if (staged.truncated) {
console.log(
`::warning::PR body is ${staged.originalBytes} bytes; staged the first ${staged.writtenBytes} complete UTF-8 bytes.`
);
}

return staged;
};

if (require.main === module) {
try {
main();
} catch (error) {
console.error(`::error::Failed to stage PR body: ${error.message}`);
process.exitCode = 1;
}
}

module.exports = { main };
16 changes: 15 additions & 1 deletion docs-builder/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,21 @@ runs:
mkdir -p "${INSTALL_DIR}"

if [[ "${DOCS_BUILDER_VERSION}" == "edge" ]]; then
docker cp $(docker create --name tc ghcr.io/elastic/docs-builder:edge):/app/docs-builder "${INSTALL_DIR}/docs-builder" && docker rm tc
EDGE_IMAGE="ghcr.io/elastic/docs-builder:edge"
docker pull "${EDGE_IMAGE}"
EDGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "${EDGE_IMAGE}")
if [[ -z "${EDGE_DIGEST}" ]]; then
echo "::error::Failed to resolve RepoDigest for ${EDGE_IMAGE}"
exit 1
fi
echo "::notice title=docs-builder image digest::${EDGE_DIGEST}"

CONTAINER_ID=$(docker create "${EDGE_DIGEST}")
cleanup_container() {
docker rm -f "${CONTAINER_ID}" >/dev/null 2>&1 || true
}
trap cleanup_container EXIT
docker cp "${CONTAINER_ID}:/app/docs-builder" "${INSTALL_DIR}/docs-builder"
else
if [[ "${DOCS_BUILDER_VERSION}" == "latest" ]]; then
DOCS_BUILDER_VERSION="" # empty string to get the latest version
Expand Down
35 changes: 27 additions & 8 deletions github/is-elastic-org-member/README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
<!-- Generated by https://github.com/reakaleek/gh-action-readme -->
# <!--name-->github/is-elastic-org-member<!--/name-->
<!--description-->
Checks whether a GitHub user is a member of the elastic org using a GitHub token with read:org scope.
Checks whether a GitHub user and the actors associated with a workflow_run are members of the elastic org using a GitHub token with read:org scope.
<!--/description-->

## Inputs
<!--inputs-->
| Name | Description | Required | Default |
|------------|---------------------------------------|----------|---------|
| `username` | GitHub username to check | `true` | ` ` |
| `token` | GitHub token with the necessary scope | `true` | ` ` |
| Name | Description | Required | Default |
|-----------------------------|--------------------------------------------------------------------------------------------------------------|----------|---------|
| `username` | GitHub username to check, typically the pull request opener | `true` | ` ` |
| `collect-from-workflow-run` | Also check workflow_run.actor and workflow_run.triggering_actor when a workflow_run payload is available<br> | `false` | `true` |
| `token` | GitHub token with the necessary scope | `true` | ` ` |
<!--/inputs-->

## Outputs
<!--outputs-->
| Name | Description |
|-------------|-------------------------------------------------------------------------|
| `is-member` | 'true' if the user is a confirmed elastic org member, 'false' otherwise |
| Name | Description |
|---------------------|------------------------------------------------------------------------------------|
| `is-member` | 'true' if every resolved user is a confirmed elastic org member, 'false' otherwise |
| `non-members` | Newline-separated usernames that could not be confirmed as elastic org members |
| `checked-usernames` | Newline-separated usernames checked after case-insensitive deduplication |
<!--/outputs-->

## Behavior

The caller supplies the pull request opener as `username`. When the action runs
from a `workflow_run` payload, it also checks `workflow_run.actor` (the account
associated with the upstream run) and `workflow_run.triggering_actor` (the
account that requested a re-run, when present). Every distinct account must be
an Elastic organization member.

The action fails closed by returning `is-member=false` when the token, explicit
username, or `workflow_run.actor` is unavailable. Commit authors and committers
are intentionally not checked: they describe authorship, not who pushed the
commit that triggered the workflow.

Set `collect-from-workflow-run: 'false'` only when validating an explicit user
outside a workflow-run trust gate.

## Usage
<!--usage action="elastic/docs-actions/github/is-elastic-org-member" version="v1"-->
```yaml
Expand Down
Loading
Loading