diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 0e71c15..70f0ae1 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -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 }} @@ -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 @@ -478,7 +495,7 @@ 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" @@ -486,6 +503,7 @@ jobs: exit $EXIT_CODE env: + IMAGE_DIGEST: ${{ steps.docker-image.outputs.digest }} STRICT_FLAG: ${{ fromJSON(inputs.strict != '' && inputs.strict || 'true') }} - name: Upload links artifact diff --git a/changelog/shared/scripts/pr-body.js b/changelog/shared/scripts/pr-body.js new file mode 100644 index 0000000..45f7900 --- /dev/null +++ b/changelog/shared/scripts/pr-body.js @@ -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 }; diff --git a/changelog/submit/apply/scripts/comment-helper.js b/changelog/submit/apply/scripts/comment-helper.js index 05aa77f..94e8363 100644 --- a/changelog/submit/apply/scripts/comment-helper.js +++ b/changelog/submit/apply/scripts/comment-helper.js @@ -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; @@ -17,4 +33,4 @@ async function upsertComment({ github, context, prNumber, body }) { } } -module.exports = { TITLE, upsertComment, escapeMarkdown }; +module.exports = { TITLE, upsertComment, wrapCodeFence, wrapInlineCode }; diff --git a/changelog/submit/apply/scripts/post-comment-only.js b/changelog/submit/apply/scripts/post-comment-only.js index bb489f1..d6661b1 100644 --- a/changelog/submit/apply/scripts/post-comment-only.js +++ b/changelog/submit/apply/scripts/post-comment-only.js @@ -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); @@ -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, ); diff --git a/changelog/submit/apply/scripts/post-failure-comment.js b/changelog/submit/apply/scripts/post-failure-comment.js index 41ee66f..85b06c1 100644 --- a/changelog/submit/apply/scripts/post-failure-comment.js +++ b/changelog/submit/apply/scripts/post-failure-comment.js @@ -1,4 +1,4 @@ -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); @@ -6,6 +6,7 @@ module.exports = async ({ github, context, core }) => { 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; @@ -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) { @@ -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 = [ @@ -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 }); diff --git a/changelog/submit/apply/scripts/post-success-comment.js b/changelog/submit/apply/scripts/post-success-comment.js index 232742a..dfeb6cf 100644 --- a/changelog/submit/apply/scripts/post-success-comment.js +++ b/changelog/submit/apply/scripts/post-success-comment.js @@ -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); @@ -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'); diff --git a/changelog/submit/evaluate/action.yml b/changelog/submit/evaluate/action.yml index 7f93946..49052ea 100644 --- a/changelog/submit/evaluate/action.yml +++ b/changelog/submit/evaluate/action.yml @@ -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 }} diff --git a/changelog/submit/evaluate/scripts/fetch-pr-data.js b/changelog/submit/evaluate/scripts/fetch-pr-data.js index f5dab1f..ec58dc4 100644 --- a/changelog/submit/evaluate/scripts/fetch-pr-data.js +++ b/changelog/submit/evaluate/scripts/fetch-pr-data.js @@ -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, @@ -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)); diff --git a/changelog/validate/action.yml b/changelog/validate/action.yml index 85b0b04..e57ade7 100644 --- a/changelog/validate/action.yml +++ b/changelog/validate/action.yml @@ -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 @@ -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 }} diff --git a/changelog/validate/scripts/stage-pr-body.js b/changelog/validate/scripts/stage-pr-body.js new file mode 100644 index 0000000..947f053 --- /dev/null +++ b/changelog/validate/scripts/stage-pr-body.js @@ -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 }; diff --git a/docs-builder/setup/action.yml b/docs-builder/setup/action.yml index 681bb75..277d154 100644 --- a/docs-builder/setup/action.yml +++ b/docs-builder/setup/action.yml @@ -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 diff --git a/github/is-elastic-org-member/README.md b/github/is-elastic-org-member/README.md index 55e2251..7b05e37 100644 --- a/github/is-elastic-org-member/README.md +++ b/github/is-elastic-org-member/README.md @@ -1,24 +1,43 @@ # github/is-elastic-org-member -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. ## 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
| `false` | `true` | +| `token` | GitHub token with the necessary scope | `true` | ` ` | ## 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 | +## 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 ```yaml diff --git a/github/is-elastic-org-member/action.yml b/github/is-elastic-org-member/action.yml index baf3a04..c4b211c 100644 --- a/github/is-elastic-org-member/action.yml +++ b/github/is-elastic-org-member/action.yml @@ -1,46 +1,112 @@ name: github/is-elastic-org-member 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. inputs: username: - description: GitHub username to check + description: GitHub username to check, typically the pull request opener required: true + collect-from-workflow-run: + description: > + Also check workflow_run.actor and workflow_run.triggering_actor when a + workflow_run payload is available + required: false + default: 'true' token: description: GitHub token with the necessary scope required: true outputs: is-member: - description: "'true' if the user is a confirmed elastic org member, 'false' otherwise" + description: "'true' if every resolved user is a confirmed elastic org member, 'false' otherwise" value: ${{ steps.check.outputs.is-member }} + non-members: + description: Newline-separated usernames that could not be confirmed as elastic org members + value: ${{ steps.check.outputs.non-members }} + checked-usernames: + description: Newline-separated usernames checked after case-insensitive deduplication + value: ${{ steps.check.outputs.checked-usernames }} runs: using: composite steps: - name: Check org membership id: check - shell: bash + uses: actions/github-script@v9 env: - GH_TOKEN: ${{ inputs.token }} + COLLECT_FROM_WORKFLOW_RUN: ${{ inputs.collect-from-workflow-run }} + ORG_MEMBER_TOKEN: ${{ inputs.token }} USERNAME: ${{ inputs.username }} - run: | - if [[ -z "$GH_TOKEN" || -z "$USERNAME" ]]; then - echo "is-member=false" >> "$GITHUB_OUTPUT" - echo "::warning::Token or username not available — cannot verify org membership" - exit 0 - fi - - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: token $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/orgs/elastic/members/${USERNAME}") - - if [[ "$HTTP_CODE" == "204" ]]; then - echo "is-member=true" >> "$GITHUB_OUTPUT" - echo "::notice::${USERNAME} is a member of the elastic org" - else - echo "is-member=false" >> "$GITHUB_OUTPUT" - echo "::notice::${USERNAME} is not a confirmed member of the elastic org (HTTP ${HTTP_CODE})" - fi + with: + github-token: ${{ inputs.token }} + # language=js + script: | + const candidates = []; + const explicitUsername = String(process.env.USERNAME ?? '').trim(); + if (explicitUsername) candidates.push(explicitUsername); + + let resolutionFailed = false; + if (process.env.COLLECT_FROM_WORKFLOW_RUN === 'true') { + const run = context.payload?.workflow_run; + if (run) { + if (run.actor?.login) { + candidates.push(run.actor.login); + } else { + core.warning('workflow_run.actor is unavailable — failing closed'); + resolutionFailed = true; + } + + // On a re-run, triggering_actor identifies the account that + // requested the re-run. On the original run it normally matches + // actor; either way, every distinct account must be a member. + if (run.triggering_actor?.login) { + candidates.push(run.triggering_actor.login); + } + } else { + core.info('No workflow_run payload is present; checking only the explicit username'); + } + } + + const seen = new Set(); + const checked = []; + for (const candidate of candidates) { + const key = candidate.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + checked.push(candidate); + } + + core.setOutput('checked-usernames', checked.join('\n')); + + if (!process.env.ORG_MEMBER_TOKEN) { + core.warning('Token is unavailable — failing closed'); + core.setOutput('is-member', 'false'); + core.setOutput('non-members', checked.join('\n')); + return; + } + + if (resolutionFailed || checked.length === 0) { + if (checked.length === 0) core.warning('No usernames were resolved — failing closed'); + core.setOutput('is-member', 'false'); + core.setOutput('non-members', ''); + return; + } + + const nonMembers = []; + for (const username of checked) { + try { + await github.rest.orgs.checkMembershipForUser({ + org: 'elastic', + username, + }); + core.info(`${username} is a member of the elastic org`); + } catch (error) { + const status = error.status ?? 'unknown'; + core.info(`${username} is not a confirmed member of the elastic org (status ${status})`); + nonMembers.push(username); + } + } + + core.setOutput('is-member', nonMembers.length === 0 ? 'true' : 'false'); + core.setOutput('non-members', nonMembers.join('\n'));