diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 9ee37296..89df8030 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,33 @@ jobs: echo "PATH_PREFIX=${path_prefix}" >> "$GITHUB_ENV" echo "result=${path_prefix}" >> "$GITHUB_OUTPUT" + # Resolve the mutable :edge tag once, verify its build provenance, and + # use the same immutable image digest throughout this job. + - name: Pull and pin docs-builder image + id: docker-image + env: + GH_TOKEN: ${{ github.token }} + # language=bash + run: | + set -euo pipefail + 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 + + # Fail closed if the attestation is missing, malformed, or + # signed by an unexpected workflow. `-R` constrains the + # attestation issuer to the docs-builder repo so an attacker + # cannot publish a self-signed attestation under their own + # repo and have it accepted here. + gh attestation verify "oci://${DIGEST}" -R elastic/docs-builder + + 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 +493,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 +501,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 00000000..45f7900d --- /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 05aa77f4..94e83632 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 0f38fb86..05676a20 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 41ee66fd..85b06c13 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 232742ad..dfeb6cf7 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 3958d702..49052ead 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 f5dab1f8..ec58dc48 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 c2ad353f..e57ade71 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 00000000..947f053b --- /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 681bb756..8dff6485 100644 --- a/docs-builder/setup/action.yml +++ b/docs-builder/setup/action.yml @@ -22,7 +22,30 @@ 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 + # Resolve :edge to an immutable digest and fail closed unless its + # build-provenance attestation comes from elastic/docs-builder. + 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 + + # `-R` constrains the attestation issuer to the + # docs-builder repo so an attacker cannot publish a + # self-signed attestation under their own repo and have it + # accepted here. + gh attestation verify "oci://${EDGE_DIGEST}" -R elastic/docs-builder + + 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 55e22515..7b05e375 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 baf3a04c..c4b211c9 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'));