From c55ade1580af4b7c07f4a834b0a1ded96bd51a5b Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:25:02 -0300 Subject: [PATCH 1/4] security: pin docs-builder edge image per run --- .github/workflows/docs-deploy.yml | 20 +++++++++++++++++++- docs-builder/setup/action.yml | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 9ee3729..65dc78e 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -375,6 +375,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 }} @@ -437,6 +438,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 @@ -465,7 +482,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" @@ -473,6 +490,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/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 From 41a637839be89d8dc6f67aacd18e601405c2c409 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:26:51 -0300 Subject: [PATCH 2/4] security: stage changelog PR bodies in files --- changelog/shared/scripts/pr-body.js | 36 ++++++++++++++++++ changelog/submit/evaluate/action.yml | 2 +- .../submit/evaluate/scripts/fetch-pr-data.js | 38 +++++++++++++++++-- changelog/validate/action.yml | 7 +++- changelog/validate/scripts/stage-pr-body.js | 32 ++++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 changelog/shared/scripts/pr-body.js create mode 100644 changelog/validate/scripts/stage-pr-body.js 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/evaluate/action.yml b/changelog/submit/evaluate/action.yml index 3958d70..49052ea 100644 --- a/changelog/submit/evaluate/action.yml +++ b/changelog/submit/evaluate/action.yml @@ -164,7 +164,7 @@ runs: REPO_NAME: ${{ github.event.repository.name }} PR_NUMBER: ${{ steps.pr.outputs.number }} PR_TITLE: ${{ steps.pr-data.outputs.title }} - 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 c2ad353..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,7 +49,7 @@ runs: REPO_NAME: ${{ github.event.repository.name }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} - 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 }; From 07715f055d86ce04113b57dfce12f6d9fb3f8520 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:29:59 -0300 Subject: [PATCH 3/4] security: harden changelog PR comment rendering --- .../submit/apply/scripts/comment-helper.js | 20 +++++++++++++++++-- .../submit/apply/scripts/post-comment-only.js | 8 +++----- .../apply/scripts/post-failure-comment.js | 11 +++++----- .../apply/scripts/post-success-comment.js | 4 ++-- 4 files changed, 29 insertions(+), 14 deletions(-) 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 0f38fb8..05676a2 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); @@ -14,11 +14,9 @@ module.exports = async ({ github, context, core }) => { const bodyParts = [TITLE, '']; if (content) { bodyParts.push( - `Generated changelog entry for \`${escapeMarkdown(changelogDir + '/' + files[0])}\`:`, + `Generated changelog entry for ${wrapInlineCode(changelogDir + '/' + files[0])}:`, '', - '```yaml', - content, - '```', + wrapCodeFence(content, 'yaml'), '', 'This comment is informational — editing it does not change what gets uploaded. On merge, the entry is regenerated from the live PR record (title, labels) and uploaded to S3. To change the preview, edit the PR title or labels and let the changelog workflow re-run.', ); 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'); From 87573c5bee6f03aa5ec58299279b4273cacb70c9 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:33:00 -0300 Subject: [PATCH 4/4] security: verify workflow-run actors for fork trust gates --- github/is-elastic-org-member/README.md | 35 +++++-- github/is-elastic-org-member/action.yml | 116 +++++++++++++++++++----- 2 files changed, 118 insertions(+), 33 deletions(-) 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'));