From 3c55db674a3a131d47c1a8b41bac98ae11607995 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 12 May 2026 05:53:33 -0300 Subject: [PATCH 01/13] Pin vale lint/report --- .github/workflows/docs-build.yml | 2 +- .github/workflows/docs-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index da9f1138..b60c2c10 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -347,7 +347,7 @@ jobs: persist-credentials: false - name: Run Vale Linter id: lint - uses: elastic/vale-rules/lint@main + uses: elastic/vale-rules/lint@f1d7270dfe289989a3acbdfcaab8c97a4a32d7d1 # v1.4.0 with: files: ${{ needs.check.outputs.all_changed_files }} vale-paths: ${{ inputs.vale-paths }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index f8b9b477..6609c94e 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -776,7 +776,7 @@ jobs: - name: Post Vale Results if: steps.vale-artifact.outputs.found == 'true' - uses: elastic/vale-rules/report@main + uses: elastic/vale-rules/report@f1d7270dfe289989a3acbdfcaab8c97a4a32d7d1 # v1.4.0 # Uploads links.json to the shared link index S3 bucket. # Runs concurrently with deploy-preview — both depend only on build. From 6379be9989b807dff9a508432eb6ff0ec5f63644 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 12 May 2026 06:01:13 -0300 Subject: [PATCH 02/13] Use a digest reference for the docs-builder image --- .github/workflows/docs-deploy.yml | 23 ++++++++++++++++++++++- docs-builder/setup/action.yml | 14 +++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 6609c94e..ca261ca8 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -352,6 +352,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 }} @@ -414,11 +415,30 @@ jobs: echo "PATH_PREFIX=${path_prefix}" >> "$GITHUB_ENV" echo "result=${path_prefix}" >> "$GITHUB_OUTPUT" + # Resolve the mutable :edge tag to an immutable RepoDigest before + # running the container. + - 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 # theft if the build tool is compromised via malicious content. # + # The image is referenced by digest (resolved above) so the run is + # immutable for this workflow execution. + # # Future: add --network none once docs-builder has an init command to # preload the link index before the build. - name: Build documentation @@ -442,7 +462,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" @@ -451,6 +471,7 @@ jobs: exit $EXIT_CODE env: STRICT_FLAG: ${{ fromJSON(inputs.strict != '' && inputs.strict || 'true') }} + IMAGE_DIGEST: ${{ steps.docker-image.outputs.digest }} - name: Upload links artifact id: upload-links diff --git a/docs-builder/setup/action.yml b/docs-builder/setup/action.yml index 134c8b51..3a98a1f9 100644 --- a/docs-builder/setup/action.yml +++ b/docs-builder/setup/action.yml @@ -22,7 +22,19 @@ 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 a RepoDigest before extracting the binary so + # the rest of this run uses an immutable image reference. + 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}") + trap "docker rm -f ${CONTAINER_ID} >/dev/null 2>&1 || true" 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 bbcba118cd7f165c59618f25e368a89a05b32ab5 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 12 May 2026 09:47:23 -0300 Subject: [PATCH 03/13] Increase pr fields boundaries --- changelog/submit/evaluate/action.yml | 2 +- .../submit/evaluate/scripts/fetch-pr-data.js | 47 +++++++++++++++++-- changelog/validate/action.yml | 13 ++++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/changelog/submit/evaluate/action.yml b/changelog/submit/evaluate/action.yml index 5b427a15..7f7ede2c 100644 --- a/changelog/submit/evaluate/action.yml +++ b/changelog/submit/evaluate/action.yml @@ -162,7 +162,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..973e2bcb 100644 --- a/changelog/submit/evaluate/scripts/fetch-pr-data.js +++ b/changelog/submit/evaluate/scripts/fetch-pr-data.js @@ -1,3 +1,15 @@ +const fs = require('fs'); +const path = require('path'); + +const TITLE_MAX_LEN = 200; +const BODY_FILE_MAX_BYTES = 64 * 1024; + +const sanitizeInline = (value, maxLen) => + (value || '') + .replace(/\u0000/g, '') + .replace(/\r/g, '') + .slice(0, maxLen); + module.exports = async ({ github, context, core }) => { const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, @@ -8,9 +20,38 @@ 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(l => l.name); + const offendingLabel = labelNames.find(name => name.includes(',')); + if (offendingLabel) { + core.setFailed( + `Label name contains ',' which would corrupt comma-joined parsing: ${JSON.stringify(offendingLabel)}` + ); + return; + } + + // Stage the body in a file rather than passing it inline. + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) { + core.setFailed('RUNNER_TEMP is not set; cannot stage PR body file'); + return; + } + const bodyFile = path.join(runnerTemp, 'changelog-pr-body.md'); + const rawBody = (pr.body || '').replace(/\u0000/g, ''); + const bodyBytes = Buffer.from(rawBody, 'utf8'); + const cappedBody = bodyBytes.length > BODY_FILE_MAX_BYTES + ? bodyBytes.subarray(0, BODY_FILE_MAX_BYTES).toString('utf8') + : rawBody; + if (bodyBytes.length > BODY_FILE_MAX_BYTES) { + core.warning( + `PR body exceeds ${BODY_FILE_MAX_BYTES} bytes (${bodyBytes.length}); truncating.` + ); + } + fs.writeFileSync(bodyFile, cappedBody, { encoding: 'utf8', mode: 0o600 }); + + core.setOutput('title', sanitizeInline(pr.title, TITLE_MAX_LEN)); + core.setOutput('body-file', bodyFile); + 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 6df464fd..efb1b45b 100644 --- a/changelog/validate/action.yml +++ b/changelog/validate/action.yml @@ -32,6 +32,17 @@ runs: version: edge github-token: ${{ inputs.github-token }} + - name: Stage PR body + id: stage-body + shell: bash + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + BODY_FILE="${RUNNER_TEMP}/changelog-pr-body.md" + printf '%s' "${PR_BODY:-}" > "$BODY_FILE" + chmod 600 "$BODY_FILE" + echo "path=${BODY_FILE}" >> "$GITHUB_OUTPUT" + - name: Evaluate PR id: evaluate shell: bash @@ -42,7 +53,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 }} From 2a9ebea4c8e9479ac166d2aefbdbb9f8c450b8ac Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 13 May 2026 11:33:15 -0300 Subject: [PATCH 04/13] Tighten trust boundaries --- .../submit/apply/scripts/comment-helper.js | 49 ++++++++++++++++++- .../submit/apply/scripts/post-comment-only.js | 9 ++-- .../apply/scripts/post-failure-comment.js | 12 +++-- .../apply/scripts/post-success-comment.js | 9 +++- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/changelog/submit/apply/scripts/comment-helper.js b/changelog/submit/apply/scripts/comment-helper.js index 05aa77f4..a9b367f6 100644 --- a/changelog/submit/apply/scripts/comment-helper.js +++ b/changelog/submit/apply/scripts/comment-helper.js @@ -1,6 +1,51 @@ +// Trust boundary: +// All env-var inputs that flow into the comment body — CHANGELOG_FILE, +// CHANGELOG_DIR, HEAD_REF, LABEL_TABLE, PRODUCT_LABEL_TABLE, SKIP_LABELS, +// CONFIG_FILE, and the staged YAML content — originate from PR metadata or +// repo configuration that an attacker can influence. Use the helpers below +// when interpolating any of those values into a Markdown comment: +// - escapeMarkdown() for inline text. Escapes Markdown punctuation *and* +// HTML-significant characters (<, >, &) so a hostile value cannot +// introduce raw HTML. +// - wrapCodeFence() for multi-line content embedded as a code block. +// Picks a backtick fence longer than any sequence in the content so a +// stray ``` cannot break out of the block. const TITLE = '### 📋 Changelog'; -const escapeMarkdown = (s) => s.replace(/([[\]()\\`*_{}#+\-.!|])/g, '\\$1'); +// Escapes Markdown punctuation and HTML-significant characters. Sticking +// to ASCII printable range; the OutputSanitizer in docs-builder already +// strips C0/DEL controls before these values reach the runner. +const escapeMarkdown = (s) => + String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/([[\]()\\`*_{}#+\-.!|])/g, '\\$1'); + +// Returns content wrapped in a backtick fence whose length is one greater +// than the longest run of backticks already present in `content`. Prevents +// the embedded content from closing the outer fence prematurely. +const wrapCodeFence = (content, language = '') => { + const matches = String(content ?? '').match(/`+/g) ?? []; + const longest = matches.reduce((max, run) => Math.max(max, run.length), 0); + const fence = '`'.repeat(Math.max(3, longest + 1)); + return `${fence}${language}\n${content}\n${fence}`; +}; + +// Returns the text wrapped as inline code using a backtick run longer than +// any run inside the text. Prefer this over `escapeMarkdown(s)` wrapped in +// single backticks when `s` may itself contain backticks (e.g., user- +// supplied label or path values). +const wrapInlineCode = (s) => { + const text = String(s ?? ''); + const matches = text.match(/`+/g) ?? []; + const longest = matches.reduce((max, run) => Math.max(max, run.length), 0); + const tick = '`'.repeat(longest + 1); + // CommonMark: pad with a single space if the content starts or ends with + // a backtick, so the boundary backticks aren't absorbed into the run. + const padded = (text.startsWith('`') || text.endsWith('`')) ? ` ${text} ` : text; + return `${tick}${padded}${tick}`; +}; async function upsertComment({ github, context, prNumber, body }) { const { owner, repo } = context.repo; @@ -17,4 +62,4 @@ async function upsertComment({ github, context, prNumber, body }) { } } -module.exports = { TITLE, upsertComment, escapeMarkdown }; +module.exports = { TITLE, upsertComment, escapeMarkdown, wrapCodeFence, wrapInlineCode }; diff --git a/changelog/submit/apply/scripts/post-comment-only.js b/changelog/submit/apply/scripts/post-comment-only.js index 0f38fb86..3486178a 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, escapeMarkdown, wrapCodeFence } = require('./comment-helper'); module.exports = async ({ github, context, core }) => { const prNumber = parseInt(process.env.PR_NUMBER, 10); @@ -16,9 +16,10 @@ module.exports = async ({ github, context, core }) => { bodyParts.push( `Generated changelog entry for \`${escapeMarkdown(changelogDir + '/' + files[0])}\`:`, '', - '```yaml', - content, - '```', + // wrapCodeFence picks a backtick run longer than any in `content`, so + // attacker-supplied YAML cannot close the fence early and inject + // arbitrary Markdown after the block. + 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 f77fb32d..7eff5b88 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); @@ -7,6 +7,8 @@ module.exports = async ({ github, context, core }) => { const productLabelRows = process.env.PRODUCT_LABEL_TABLE || ''; const skipLabels = process.env.SKIP_LABELS || ''; + const configFileCode = wrapInlineCode(configFile); + let labelSection; if (labelRows.trim()) { labelSection = [ @@ -16,7 +18,7 @@ module.exports = async ({ github, context, core }) => { labelRows, ].join('\n'); } else { - labelSection = `\nAdd a type label that matches your \`pivot.types\` configuration in \`${configFile}\`.`; + labelSection = `\nAdd a type label that matches your ${wrapInlineCode('pivot.types')} configuration in ${configFileCode}.`; } let productSection = ''; @@ -31,10 +33,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(l => wrapInlineCode(l.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 = [ @@ -45,7 +47,7 @@ module.exports = async ({ github, context, core }) => { productSection, 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..07a9b35f 100644 --- a/changelog/submit/apply/scripts/post-success-comment.js +++ b/changelog/submit/apply/scripts/post-success-comment.js @@ -1,5 +1,10 @@ -const { TITLE, upsertComment, escapeMarkdown } = require('./comment-helper'); +const { TITLE, upsertComment, wrapInlineCode } = require('./comment-helper'); +// changelogFile / branch are validated upstream by ref-name regex +// (`^[a-zA-Z0-9._/+-]+$`) plus OutputSanitizer in docs-builder, so they +// are constrained to a small alphabet. wrapInlineCode is still used for +// the visible filename so a stray backtick (or future loosening of the +// upstream regex) cannot break out of the inline code span. module.exports = async ({ github, context, core }) => { const prNumber = parseInt(process.env.PR_NUMBER, 10); const branch = process.env.HEAD_REF; @@ -13,7 +18,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 549cfd777a403a72b4dad2d3763821a92c9e3566 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 13 May 2026 11:55:15 -0300 Subject: [PATCH 05/13] Check all involved pushers --- .github/workflows/changelog-submit.yml | 29 +++++- .github/workflows/docs-deploy.yml | 31 +++++- github/is-elastic-org-member/README.md | 42 ++++++-- github/is-elastic-org-member/action.yml | 124 ++++++++++++++++++++---- 4 files changed, 198 insertions(+), 28 deletions(-) diff --git a/.github/workflows/changelog-submit.yml b/.github/workflows/changelog-submit.yml index 0dc03b25..5ffca565 100644 --- a/.github/workflows/changelog-submit.yml +++ b/.github/workflows/changelog-submit.yml @@ -33,7 +33,7 @@ jobs: should-submit: ${{ steps.evaluate.outputs.should-submit }} is-org-member: ${{ steps.check-org-membership.outputs.is-member }} steps: - - name: Resolve PR author + - name: Resolve involved logins id: pr-author if: github.event.workflow_run.head_repository.full_name != github.repository uses: actions/github-script@v9 @@ -65,6 +65,31 @@ jobs: }); core.setOutput('login', pr.user.login); + const headRepoFull = run.head_repository.full_name; + const [forkOwner, forkRepo] = headRepoFull.split('/'); + + let headCommitAuthor; + let headCommitCommitter; + try { + const { data: headCommit } = await github.rest.repos.getCommit({ + owner: forkOwner, repo: forkRepo, ref: run.head_sha + }); + headCommitAuthor = headCommit.author?.login; + headCommitCommitter = headCommit.committer?.login; + } catch (err) { + core.warning(`Could not fetch head commit ${run.head_sha} from ${headRepoFull}: ${err.message}`); + } + + const logins = [ + pr.user?.login, + run.actor?.login, + run.triggering_actor?.login, + headCommitAuthor, + headCommitCommitter, + ].filter(Boolean); + + core.setOutput('involved-logins', logins.join('\n')); + - name: Fetch ephemeral GitHub token if: github.event.workflow_run.head_repository.full_name != github.repository id: fetch-ephemeral-token @@ -78,7 +103,7 @@ jobs: if: github.event.workflow_run.head_repository.full_name != github.repository uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: - username: ${{ steps.pr-author.outputs.login }} + usernames: ${{ steps.pr-author.outputs.involved-logins }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} - name: Evaluate diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index ca261ca8..f513ff7f 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -153,6 +153,35 @@ jobs: core.setOutput('base-ref', pr.base.ref); core.setOutput('pr-author', pr.user.login); + // Collect every distinct login involved in producing this + // workflow_run so the org-membership gate can verify the + // *pusher* and not just the PR opener + + const headRepoFull = run.head_repository.full_name; + const [forkOwner, forkRepo] = headRepoFull.split('/'); + + let headCommitAuthor; + let headCommitCommitter; + try { + const { data: headCommit } = await github.rest.repos.getCommit({ + owner: forkOwner, repo: forkRepo, ref: headSha + }); + headCommitAuthor = headCommit.author?.login; + headCommitCommitter = headCommit.committer?.login; + } catch (err) { + core.warning(`Could not fetch head commit ${headSha} from ${headRepoFull}: ${err.message}`); + } + + const logins = [ + pr.user?.login, + run.actor?.login, + run.triggering_actor?.login, + headCommitAuthor, + headCommitCommitter, + ].filter(Boolean); + + core.setOutput('involved-logins', logins.join('\n')); + // --- Changed-files check (docs-relevant files only) --- const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: prNumber @@ -236,7 +265,7 @@ jobs: if: steps.context.outputs.is-fork == 'true' && steps.context.outputs.event == 'pull_request' uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: - username: ${{ steps.context.outputs.pr-author }} + usernames: ${{ steps.context.outputs.involved-logins }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} - name: Evaluate diff --git a/github/is-elastic-org-member/README.md b/github/is-elastic-org-member/README.md index 55e22515..832b6545 100644 --- a/github/is-elastic-org-member/README.md +++ b/github/is-elastic-org-member/README.md @@ -1,25 +1,39 @@ # 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 one or more GitHub users are members of the elastic org using a GitHub token with read:org scope. When multiple usernames are supplied, all of them must be confirmed members for `is-member` to be `true` (fail-closed semantics for trust gates). ## Inputs -| Name | Description | Required | Default | -|------------|---------------------------------------|----------|---------| -| `username` | GitHub username to check | `true` | ` ` | -| `token` | GitHub token with the necessary scope | `true` | ` ` | +| Name | Description | Required | Default | +|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------| +| `username` | Single GitHub username to check. Mutually exclusive with `usernames` in practice, but if both are set the values are merged and deduped. | `false` | ` ` | +| `usernames` | Newline- or comma-separated list of GitHub usernames. Used by callers that need to verify the union of logins involved in a PR (opener, pusher, head-commit author/committer) — see [elastic/docs-eng-team#511](https://github.com/elastic/docs-eng-team/issues/511). All must be members for `is-member` to be `true`. | `false` | `''` | +| `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 supplied user is a confirmed elastic org member, `'false'` otherwise (including when no usernames were supplied). | +| `non-members` | Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. Useful for logging which account caused the gate to fail. | +## Behavior + +- Empty values, blank lines, and the GitHub `web-flow` ghost account (used as + committer for web-UI edits and squash merges) are skipped. +- Each remaining value is validated against the GitHub username regex + (`^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$`) before being interpolated into a URL. + Anything that doesn't match is silently dropped. +- If no valid usernames remain, the action fails closed (`is-member=false`). + ## Usage + +Single user (legacy): + ```yaml on: push @@ -27,3 +41,15 @@ steps: - uses: elastic/docs-actions/github/is-elastic-org-member@v1 ``` + +Multiple users (recommended for trust gates on `workflow_run`): + +```yaml +- uses: elastic/docs-actions/github/is-elastic-org-member@v1 + with: + usernames: | + ${{ steps.context.outputs.pr-author }} + ${{ github.event.workflow_run.actor.login }} + ${{ github.event.workflow_run.triggering_actor.login }} + token: ${{ steps.fetch-ephemeral-token.outputs.token }} +``` diff --git a/github/is-elastic-org-member/action.yml b/github/is-elastic-org-member/action.yml index baf3a04c..d596c2bf 100644 --- a/github/is-elastic-org-member/action.yml +++ b/github/is-elastic-org-member/action.yml @@ -1,20 +1,41 @@ 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 one or more GitHub users are members of the elastic org + using a GitHub token with read:org scope. When multiple usernames are + supplied, all of them must be confirmed members for `is-member` to be + `true` (fail-closed semantics for trust gates). inputs: username: - description: GitHub username to check - required: true + description: > + Single GitHub username to check. Mutually exclusive with `usernames` + in practice, but if both are set the values are merged and deduped. + required: false + usernames: + description: > + Newline- or comma-separated list of GitHub usernames. Used by callers + that need to verify the union of logins involved in a PR (opener, + pusher, head-commit author/committer) — see elastic/docs-eng-team#511. + All must be members for `is-member` to be `true`. Empty values, blank + lines, and known GitHub system accounts (e.g. `web-flow`) are skipped. + required: false + default: '' 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 supplied user is a confirmed elastic org member, + 'false' otherwise (including when no usernames were supplied)." value: ${{ steps.check.outputs.is-member }} + non-members: + description: > + Newline-separated list of usernames that failed the membership + check. Empty when `is-member` is `true`. Useful for logging which + account caused the gate to fail. + value: ${{ steps.check.outputs.non-members }} runs: using: composite @@ -25,22 +46,91 @@ runs: env: GH_TOKEN: ${{ inputs.token }} USERNAME: ${{ inputs.username }} + USERNAMES: ${{ inputs.usernames }} + # language=bash 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" + set -euo pipefail + + if [[ -z "$GH_TOKEN" ]]; then + { + echo "is-member=false" + echo "non-members<> "$GITHUB_OUTPUT" + echo "::warning::Token not available — cannot verify org membership" + exit 0 + fi + + # Merge `username` + `usernames`, splitting on newline/comma, trim + # whitespace, drop empties, drop the GitHub web-flow ghost account + # (used as committer for web-UI edits and squash merges), and dedupe. + # Keep only logins matching GitHub's username regex + # (1-39 chars, alphanumeric + dash, no leading/trailing dash) so the + # value can be safely interpolated into a URL path segment. + # Filter pipeline: split on newline/comma, trim whitespace, drop the + # GitHub web-flow ghost account (used as committer for web-UI edits + # and squash merges), then keep only logins matching GitHub's + # username regex (1-39 chars, alphanumeric + dash, no leading or + # trailing dash). The final regex implicitly drops empty lines. + normalized=$( + printf '%s\n%s\n' "${USERNAME:-}" "${USERNAMES:-}" \ + | tr ',' '\n' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \ + | grep -vxE 'web-flow' \ + | grep -E '^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$' \ + | sort -u || true + ) + + if [[ -z "$normalized" ]]; then + { + echo "is-member=false" + echo "non-members<> "$GITHUB_OUTPUT" + echo "::warning::No valid usernames provided — 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}") + non_members=() + all_members=true + + # Loop over each username (NUL-safe via process substitution would + # be ideal, but `normalized` is already validated against the + # username regex above, so newline-splitting is safe here). + while IFS= read -r u; do + [[ -z "$u" ]] && continue + http_code=$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/elastic/members/${u}") + + if [[ "$http_code" == "204" ]]; then + echo "::notice::${u} is a member of the elastic org" + else + all_members=false + non_members+=("$u") + echo "::notice::${u} is not a confirmed member of the elastic org (HTTP ${http_code})" + fi + done <<< "$normalized" - if [[ "$HTTP_CODE" == "204" ]]; then - echo "is-member=true" >> "$GITHUB_OUTPUT" - echo "::notice::${USERNAME} is a member of the elastic org" + if [[ "$all_members" == "true" ]]; then + { + echo "is-member=true" + echo "non-members<> "$GITHUB_OUTPUT" else - echo "is-member=false" >> "$GITHUB_OUTPUT" - echo "::notice::${USERNAME} is not a confirmed member of the elastic org (HTTP ${HTTP_CODE})" + { + echo "is-member=false" + echo "non-members<> "$GITHUB_OUTPUT" fi From 5b0f2a637e2e930939eae6471fdc947024b0ecff Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 13 May 2026 12:20:14 -0300 Subject: [PATCH 06/13] Move logic to the appropriate action --- .github/workflows/changelog-submit.yml | 58 ----- .github/workflows/docs-deploy.yml | 29 --- github/is-elastic-org-member/README.md | 73 ++++-- github/is-elastic-org-member/action.yml | 288 +++++++++++++++--------- 4 files changed, 233 insertions(+), 215 deletions(-) diff --git a/.github/workflows/changelog-submit.yml b/.github/workflows/changelog-submit.yml index 5ffca565..9e9a4b44 100644 --- a/.github/workflows/changelog-submit.yml +++ b/.github/workflows/changelog-submit.yml @@ -33,63 +33,6 @@ jobs: should-submit: ${{ steps.evaluate.outputs.should-submit }} is-org-member: ${{ steps.check-org-membership.outputs.is-member }} steps: - - name: Resolve involved logins - id: pr-author - if: github.event.workflow_run.head_repository.full_name != github.repository - uses: actions/github-script@v9 - with: - # language=js - script: | - const run = context.payload.workflow_run; - const { owner, repo } = context.repo; - - let prNumber; - if (run.pull_requests?.length > 0) { - prNumber = run.pull_requests[0].number; - } else { - const headLabel = `${run.head_repository.owner.login}:${run.head_branch}`; - const { data: prs } = await github.rest.pulls.list({ - owner, repo, state: 'open', head: headLabel - }); - const match = prs.find(pr => pr.head.sha === run.head_sha); - if (match) prNumber = match.number; - } - - if (!prNumber) { - core.setFailed('Could not resolve PR number for fork — cannot verify org membership. Failing closed.'); - return; - } - - const { data: pr } = await github.rest.pulls.get({ - owner, repo, pull_number: prNumber - }); - core.setOutput('login', pr.user.login); - - const headRepoFull = run.head_repository.full_name; - const [forkOwner, forkRepo] = headRepoFull.split('/'); - - let headCommitAuthor; - let headCommitCommitter; - try { - const { data: headCommit } = await github.rest.repos.getCommit({ - owner: forkOwner, repo: forkRepo, ref: run.head_sha - }); - headCommitAuthor = headCommit.author?.login; - headCommitCommitter = headCommit.committer?.login; - } catch (err) { - core.warning(`Could not fetch head commit ${run.head_sha} from ${headRepoFull}: ${err.message}`); - } - - const logins = [ - pr.user?.login, - run.actor?.login, - run.triggering_actor?.login, - headCommitAuthor, - headCommitCommitter, - ].filter(Boolean); - - core.setOutput('involved-logins', logins.join('\n')); - - name: Fetch ephemeral GitHub token if: github.event.workflow_run.head_repository.full_name != github.repository id: fetch-ephemeral-token @@ -103,7 +46,6 @@ jobs: if: github.event.workflow_run.head_repository.full_name != github.repository uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: - usernames: ${{ steps.pr-author.outputs.involved-logins }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} - name: Evaluate diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index f513ff7f..952f86de 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -153,34 +153,6 @@ jobs: core.setOutput('base-ref', pr.base.ref); core.setOutput('pr-author', pr.user.login); - // Collect every distinct login involved in producing this - // workflow_run so the org-membership gate can verify the - // *pusher* and not just the PR opener - - const headRepoFull = run.head_repository.full_name; - const [forkOwner, forkRepo] = headRepoFull.split('/'); - - let headCommitAuthor; - let headCommitCommitter; - try { - const { data: headCommit } = await github.rest.repos.getCommit({ - owner: forkOwner, repo: forkRepo, ref: headSha - }); - headCommitAuthor = headCommit.author?.login; - headCommitCommitter = headCommit.committer?.login; - } catch (err) { - core.warning(`Could not fetch head commit ${headSha} from ${headRepoFull}: ${err.message}`); - } - - const logins = [ - pr.user?.login, - run.actor?.login, - run.triggering_actor?.login, - headCommitAuthor, - headCommitCommitter, - ].filter(Boolean); - - core.setOutput('involved-logins', logins.join('\n')); // --- Changed-files check (docs-relevant files only) --- const files = await github.paginate(github.rest.pulls.listFiles, { @@ -265,7 +237,6 @@ jobs: if: steps.context.outputs.is-fork == 'true' && steps.context.outputs.event == 'pull_request' uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: - usernames: ${{ steps.context.outputs.involved-logins }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} - name: Evaluate diff --git a/github/is-elastic-org-member/README.md b/github/is-elastic-org-member/README.md index 832b6545..f486017d 100644 --- a/github/is-elastic-org-member/README.md +++ b/github/is-elastic-org-member/README.md @@ -1,38 +1,69 @@ # github/is-elastic-org-member -Checks whether one or more GitHub users are members of the elastic org using a GitHub token with read:org scope. When multiple usernames are supplied, all of them must be confirmed members for `is-member` to be `true` (fail-closed semantics for trust gates). +Verifies that one or more GitHub users are members of the elastic org using a GitHub token with `read:org` scope. When `collect-from-workflow-run` is `true`, the action also auto-collects every login involved in the upstream `workflow_run` event (PR opener + `actor` + `triggering_actor` + head-commit author/committer) so callers don't duplicate that logic. **All resolved users must be confirmed members for `is-member` to be `true`** — fail-closed semantics for trust gates. + +See [elastic/docs-eng-team#511](https://github.com/elastic/docs-eng-team/issues/511) for the security review that motivates the multi-login check. ## Inputs -| Name | Description | Required | Default | -|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------| -| `username` | Single GitHub username to check. Mutually exclusive with `usernames` in practice, but if both are set the values are merged and deduped. | `false` | ` ` | -| `usernames` | Newline- or comma-separated list of GitHub usernames. Used by callers that need to verify the union of logins involved in a PR (opener, pusher, head-commit author/committer) — see [elastic/docs-eng-team#511](https://github.com/elastic/docs-eng-team/issues/511). All must be members for `is-member` to be `true`. | `false` | `''` | -| `token` | GitHub token with the necessary scope | `true` | ` ` | +| Name | Description | Required | Default | +|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------| +| `username` | Single GitHub username to check. Backward-compat input for callers that only need to verify one user. Merged with `usernames` and any auto-collected logins. | `false` | `''` | +| `usernames` | Newline- or comma-separated list of additional GitHub usernames. Merged with `username` and any auto-collected logins. | `false` | `''` | +| `collect-from-workflow-run` | When `'true'` (default) and the calling workflow was triggered by `workflow_run`, automatically include the PR opener, the upstream run's `actor` and `triggering_actor`, and the head commit's author and committer in the membership check. Set to `'false'` to validate only the explicit `username` / `usernames` inputs. | `false` | `'true'` | +| `token` | GitHub token with `read:org` scope (for membership checks) and at minimum read access to public commits (for fork head-commit lookups). Typically an ephemeral Vault-issued token. | `true` | ` ` | ## Outputs -| Name | Description | -|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `is-member` | `'true'` if every supplied user is a confirmed elastic org member, `'false'` otherwise (including when no usernames were supplied). | -| `non-members` | Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. Useful for logging which account caused the gate to fail. | +| Name | Description | +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `is-member` | `'true'` if every resolved user is a confirmed elastic org member, `'false'` otherwise (including when no usernames were resolved). | +| `non-members` | Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. | +| `checked-usernames` | Newline-separated list of usernames that were validated and checked (after deduplication and bot/invalid filtering). Useful for diagnostic logging. | ## Behavior -- Empty values, blank lines, and the GitHub `web-flow` ghost account (used as - committer for web-UI edits and squash merges) are skipped. -- Each remaining value is validated against the GitHub username regex - (`^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$`) before being interpolated into a URL. - Anything that doesn't match is silently dropped. -- If no valid usernames remain, the action fails closed (`is-member=false`). +- The action is implemented in JavaScript via `actions/github-script@v9`. All + string handling, splitting, deduplication, and validation happens in JS to + avoid shell-quoting and `grep`/`sed` portability concerns. +- Inputs are split on newline **and** comma, trimmed, and case-insensitively + deduped. +- The GitHub `web-flow` ghost account (used as committer for web-UI edits and + squash merges) and `noreply` are filtered out before validation. +- Each remaining value is validated against the GitHub username grammar + (`^[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}$`) before being passed to the API. + Anything that doesn't match (including bot logins ending in `[bot]`) is + silently dropped. +- If no valid usernames remain after filtering, the action fails closed + (`is-member=false`). +- Each membership check uses `octokit.orgs.checkMembershipForUser`. Any + non-200 response (including the documented `302 Found` for + not-publicly-visible memberships and `404 Not Found` for non-members) is + treated as "not a confirmed member" and added to `non-members`. ## Usage -Single user (legacy): +### Workflow_run trust gate (recommended) + +Pass only the token and let the action collect every login that touched the +upstream run: + +```yaml +- uses: elastic/docs-actions/github/is-elastic-org-member@v1 + with: + token: ${{ steps.fetch-ephemeral-token.outputs.token }} +``` + +This is equivalent to listing the PR opener, `workflow_run.actor.login`, +`workflow_run.triggering_actor.login`, and the head commit's `author.login` +and `committer.login` — and requiring **all** of them to be elastic +members. + +### Single user (legacy) ```yaml @@ -42,14 +73,14 @@ steps: ``` -Multiple users (recommended for trust gates on `workflow_run`): +### Explicit username list (auto-collection disabled) ```yaml - uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: + collect-from-workflow-run: 'false' usernames: | - ${{ steps.context.outputs.pr-author }} - ${{ github.event.workflow_run.actor.login }} - ${{ github.event.workflow_run.triggering_actor.login }} + ${{ inputs.user-a }} + ${{ inputs.user-b }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} ``` diff --git a/github/is-elastic-org-member/action.yml b/github/is-elastic-org-member/action.yml index d596c2bf..ddb5b7ec 100644 --- a/github/is-elastic-org-member/action.yml +++ b/github/is-elastic-org-member/action.yml @@ -1,136 +1,210 @@ name: github/is-elastic-org-member description: > - Checks whether one or more GitHub users are members of the elastic org - using a GitHub token with read:org scope. When multiple usernames are - supplied, all of them must be confirmed members for `is-member` to be - `true` (fail-closed semantics for trust gates). + Verifies that one or more GitHub users are members of the elastic org + using a GitHub token with read:org scope. When `collect-from-workflow-run` + is true, also auto-collects every login involved in the upstream + workflow_run (PR opener + actor + triggering_actor + head-commit + author/committer) so callers don't duplicate that logic. All resolved + users must be confirmed members for `is-member` to be `true` (fail-closed + semantics for trust gates — see elastic/docs-eng-team#511). inputs: username: description: > - Single GitHub username to check. Mutually exclusive with `usernames` - in practice, but if both are set the values are merged and deduped. + Single GitHub username to check. Backward-compat input for callers + that only need to verify one user. Merged with `usernames` and any + auto-collected logins. required: false + default: '' usernames: description: > - Newline- or comma-separated list of GitHub usernames. Used by callers - that need to verify the union of logins involved in a PR (opener, - pusher, head-commit author/committer) — see elastic/docs-eng-team#511. - All must be members for `is-member` to be `true`. Empty values, blank - lines, and known GitHub system accounts (e.g. `web-flow`) are skipped. + Newline- or comma-separated list of additional GitHub usernames. + Merged with `username` and any auto-collected logins. required: false default: '' + collect-from-workflow-run: + description: > + When 'true' (default) and the calling workflow was triggered by + `workflow_run`, automatically include the PR opener, the upstream + run's `actor` and `triggering_actor`, and the head commit's author + and committer in the membership check. Set to 'false' to validate + only the explicit `username` / `usernames` inputs. + required: false + default: 'true' token: - description: GitHub token with the necessary scope + description: > + GitHub token with `read:org` scope (for membership checks) and at + minimum read access to public commits (for fork head-commit + lookups). Typically an ephemeral Vault-issued token. required: true outputs: is-member: description: > - "'true' if every supplied user is a confirmed elastic org member, - 'false' otherwise (including when no usernames were supplied)." + "'true' if every resolved user is a confirmed elastic org member, + 'false' otherwise (including when no usernames were resolved)." value: ${{ steps.check.outputs.is-member }} non-members: description: > Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. Useful for logging which - account caused the gate to fail. + account(s) caused the gate to fail. value: ${{ steps.check.outputs.non-members }} + checked-usernames: + description: > + Newline-separated list of usernames that were validated and + checked (after deduplication and filtering of bots/invalid logins). + Useful for diagnostic logging. + value: ${{ steps.check.outputs.checked-usernames }} runs: using: composite steps: - - name: Check org membership + - name: Check elastic org membership id: check - shell: bash + uses: actions/github-script@v9 env: - GH_TOKEN: ${{ inputs.token }} - USERNAME: ${{ inputs.username }} - USERNAMES: ${{ inputs.usernames }} - # language=bash - run: | - set -euo pipefail - - if [[ -z "$GH_TOKEN" ]]; then - { - echo "is-member=false" - echo "non-members<> "$GITHUB_OUTPUT" - echo "::warning::Token not available — cannot verify org membership" - exit 0 - fi - - # Merge `username` + `usernames`, splitting on newline/comma, trim - # whitespace, drop empties, drop the GitHub web-flow ghost account - # (used as committer for web-UI edits and squash merges), and dedupe. - # Keep only logins matching GitHub's username regex - # (1-39 chars, alphanumeric + dash, no leading/trailing dash) so the - # value can be safely interpolated into a URL path segment. - # Filter pipeline: split on newline/comma, trim whitespace, drop the - # GitHub web-flow ghost account (used as committer for web-UI edits - # and squash merges), then keep only logins matching GitHub's - # username regex (1-39 chars, alphanumeric + dash, no leading or - # trailing dash). The final regex implicitly drops empty lines. - normalized=$( - printf '%s\n%s\n' "${USERNAME:-}" "${USERNAMES:-}" \ - | tr ',' '\n' \ - | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \ - | grep -vxE 'web-flow' \ - | grep -E '^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$' \ - | sort -u || true - ) - - if [[ -z "$normalized" ]]; then - { - echo "is-member=false" - echo "non-members<> "$GITHUB_OUTPUT" - echo "::warning::No valid usernames provided — cannot verify org membership" - exit 0 - fi - - non_members=() - all_members=true - - # Loop over each username (NUL-safe via process substitution would - # be ideal, but `normalized` is already validated against the - # username regex above, so newline-splitting is safe here). - while IFS= read -r u; do - [[ -z "$u" ]] && continue - http_code=$(curl -sS -o /dev/null -w "%{http_code}" \ - -H "Authorization: token $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/orgs/elastic/members/${u}") - - if [[ "$http_code" == "204" ]]; then - echo "::notice::${u} is a member of the elastic org" - else - all_members=false - non_members+=("$u") - echo "::notice::${u} is not a confirmed member of the elastic org (HTTP ${http_code})" - fi - done <<< "$normalized" - - if [[ "$all_members" == "true" ]]; then - { - echo "is-member=true" - echo "non-members<> "$GITHUB_OUTPUT" - else - { - echo "is-member=false" - echo "non-members<> "$GITHUB_OUTPUT" - fi + ORG_MEMBER_TOKEN: ${{ inputs.token }} + EXPLICIT_USERNAME: ${{ inputs.username }} + EXPLICIT_USERNAMES: ${{ inputs.usernames }} + COLLECT_FROM_WORKFLOW_RUN: ${{ inputs.collect-from-workflow-run }} + with: + # Use a separate Octokit instance so the org-membership token + # (read:org) is not conflated with the default GITHUB_TOKEN. + github-token: ${{ inputs.token }} + # language=js + script: | + // GitHub username grammar: 1-39 chars, alphanumeric + dash, + // no leading or trailing dash, no consecutive dashes. + // Source: https://github.com/shinnn/github-username-regex. + const USERNAME_REGEX = /^[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}$/; + + // Logins that are real GitHub accounts but never represent a + // human committer for our trust-gate purposes: + // - `web-flow` → squash-merge / web-UI commit signer + // - `noreply` → defensive; no real user + // Anything ending in `[bot]` is filtered by USERNAME_REGEX + // (the brackets are not in the username alphabet). + const FILTERED_LOGINS = new Set(['web-flow', 'noreply']); + + const split = (s) => + String(s ?? '') + .split(/[\n,]/) + .map((x) => x.trim()) + .filter(Boolean); + + const explicit = [ + ...split(process.env.EXPLICIT_USERNAME), + ...split(process.env.EXPLICIT_USERNAMES), + ]; + + const collected = []; + + if (process.env.COLLECT_FROM_WORKFLOW_RUN === 'true') { + const run = context.payload?.workflow_run; + if (!run) { + core.info('collect-from-workflow-run is true but no workflow_run payload is present — skipping auto-collection'); + } else { + // The PR opener (upstream-resolved). Falls back to nothing + // if no PR is associated with the run. + try { + const prRef = run.pull_requests?.[0]; + if (prRef?.number) { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prRef.number, + }); + if (pr.user?.login) collected.push(pr.user.login); + } + } catch (err) { + core.warning(`Could not resolve PR opener: ${err.message}`); + } + + // The user(s) who triggered the upstream workflow_run. + if (run.actor?.login) collected.push(run.actor.login); + if (run.triggering_actor?.login) collected.push(run.triggering_actor.login); + + // Head commit author/committer — fetched against the + // (possibly fork) head repo. Fail-soft: if we cannot + // resolve the commit, the explicit + actor logins still + // constrain the check. + const headRepoFull = run.head_repository?.full_name; + const headSha = run.head_sha; + if (headRepoFull && headSha) { + const [forkOwner, forkRepo] = headRepoFull.split('/'); + try { + const { data: commit } = await github.rest.repos.getCommit({ + owner: forkOwner, + repo: forkRepo, + ref: headSha, + }); + if (commit.author?.login) collected.push(commit.author.login); + if (commit.committer?.login) collected.push(commit.committer.login); + } catch (err) { + core.warning(`Could not fetch head commit ${headSha} from ${headRepoFull}: ${err.message}`); + } + } + } + } + + // Merge, filter bots, validate against the GitHub username + // grammar, and dedupe. Validation happens before any value is + // interpolated into an API path. + const candidates = [...explicit, ...collected]; + const seen = new Set(); + const checked = []; + for (const raw of candidates) { + if (FILTERED_LOGINS.has(raw)) continue; + if (!USERNAME_REGEX.test(raw)) { + core.info(`Skipping invalid login candidate: ${JSON.stringify(raw)}`); + continue; + } + const key = raw.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + checked.push(raw); + } + + core.setOutput('checked-usernames', checked.join('\n')); + + if (!process.env.ORG_MEMBER_TOKEN) { + core.warning('No token supplied — failing closed (is-member=false)'); + core.setOutput('is-member', 'false'); + core.setOutput('non-members', checked.join('\n')); + return; + } + + if (checked.length === 0) { + core.warning('No valid usernames resolved — failing closed (is-member=false)'); + 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 (err) { + // Octokit raises a HttpError with status 302 (not visible + // to the caller — public membership check) or 404 (not a + // member) for non-members. Treat any non-200 as "not a + // confirmed member". + const status = err.status ?? 'unknown'; + core.info(`${username} is not a confirmed member of the elastic org (status ${status})`); + nonMembers.push(username); + } + } + + if (nonMembers.length === 0) { + core.setOutput('is-member', 'true'); + core.setOutput('non-members', ''); + } else { + core.setOutput('is-member', 'false'); + core.setOutput('non-members', nonMembers.join('\n')); + } From 0acc4181e37c85c3f5e0fd886c853f4f74e20e3f Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Sat, 16 May 2026 20:11:01 -0300 Subject: [PATCH 07/13] Verify docs-builder :edge attestation before use Add `gh attestation verify` to both consumer paths that pull `ghcr.io/elastic/docs-builder:edge`: - `.github/workflows/docs-deploy.yml` (Pull and pin docs-builder image) - `docs-builder/setup/action.yml` (edge branch) Both already resolve :edge to an immutable RepoDigest. They now also verify the SLSA build-provenance attestation minted by docs-builder's prerelease.yml (elastic/docs-eng-team#518), constraining the attestation issuer to `elastic/docs-builder` via `-R` so an attacker cannot self-sign under their own repo and have it accepted. Depends on the producer side shipping in elastic/docs-builder first and at least one signed :edge push existing in the registry; the verify call fails closed otherwise. Co-authored-by: Cursor --- .github/workflows/docs-deploy.yml | 20 ++++++++++++++++++-- docs-builder/setup/action.yml | 16 ++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 952f86de..c95ad3b2 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -415,12 +415,20 @@ jobs: echo "PATH_PREFIX=${path_prefix}" >> "$GITHUB_ENV" echo "result=${path_prefix}" >> "$GITHUB_OUTPUT" - # Resolve the mutable :edge tag to an immutable RepoDigest before - # running the container. + # Resolve the mutable :edge tag to an immutable RepoDigest, then + # verify the SLSA build-provenance attestation that + # `elastic/docs-builder`'s prerelease.yml mints for every push + # via `actions/attest-build-provenance` (see + # elastic/docs-eng-team#518). This proves the image was built by + # a known workflow on a known commit and has not been tampered + # with on the registry side. - 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") @@ -428,6 +436,14 @@ jobs: 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}" diff --git a/docs-builder/setup/action.yml b/docs-builder/setup/action.yml index 3a98a1f9..d698a188 100644 --- a/docs-builder/setup/action.yml +++ b/docs-builder/setup/action.yml @@ -22,8 +22,13 @@ runs: mkdir -p "${INSTALL_DIR}" if [[ "${DOCS_BUILDER_VERSION}" == "edge" ]]; then - # Resolve :edge to a RepoDigest before extracting the binary so - # the rest of this run uses an immutable image reference. + # Resolve :edge to a RepoDigest, then verify the SLSA + # build-provenance attestation minted by docs-builder's + # prerelease.yml (elastic/docs-eng-team#518). This matches + # the non-edge path's `gh attestation verify` step on the + # release zip below — :edge is no longer the unverified + # cousin. Fail closed if the attestation is missing or + # signed by an unexpected workflow. EDGE_IMAGE="ghcr.io/elastic/docs-builder:edge" docker pull "${EDGE_IMAGE}" EDGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "${EDGE_IMAGE}") @@ -31,6 +36,13 @@ runs: 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}") trap "docker rm -f ${CONTAINER_ID} >/dev/null 2>&1 || true" EXIT From 1f399fc009ca10b330d2b9d92afb51b68ad40dbb Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 4 Jun 2026 21:52:23 -0300 Subject: [PATCH 08/13] Fix pre-commit --- .github/aw/actions-lock.json | 5 ++++ .../gh-aw-docs-applies-to-sweep.lock.yml | 6 ++--- .../gh-aw-docs-coherence-sweep.lock.yml | 6 ++--- .../gh-aw-docs-frontmatter-sweep.lock.yml | 6 ++--- .../gh-aw-docs-openings-sweep.lock.yml | 6 ++--- .../gh-aw-docs-staleness-sweep.lock.yml | 6 ++--- .../workflows/gh-aw-docs-style-sweep.lock.yml | 6 ++--- .../workflows/gh-aw-docs-typos-sweep.lock.yml | 6 ++--- github/is-elastic-org-member/README.md | 26 +++++++++---------- 9 files changed, 38 insertions(+), 35 deletions(-) diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 82e4910f..91373f4c 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -10,6 +10,11 @@ "version": "v5.0.5", "sha": "27d5ce7f107fe9357f9df03efb73ab90386fccae" }, + "actions/checkout@v6": { + "repo": "actions/checkout", + "version": "v6", + "sha": "df4cb1c069e1874edd31b4311f1884172cec0e10" + }, "actions/checkout@v6.0.2": { "repo": "actions/checkout", "version": "v6.0.2", diff --git a/.github/workflows/gh-aw-docs-applies-to-sweep.lock.yml b/.github/workflows/gh-aw-docs-applies-to-sweep.lock.yml index 899750b4..48d27c9b 100644 --- a/.github/workflows/gh-aw-docs-applies-to-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-applies-to-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"0fed7cba1098883ebd07c37ad7926ab3adc379e9f9577fcbf70a99c024c5a5a5","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -43,7 +43,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -628,7 +628,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 30 persist-credentials: false diff --git a/.github/workflows/gh-aw-docs-coherence-sweep.lock.yml b/.github/workflows/gh-aw-docs-coherence-sweep.lock.yml index 4c257ece..e3ae6ed5 100644 --- a/.github/workflows/gh-aw-docs-coherence-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-coherence-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"da184731259d4126ad359ba043c1c4730c4ef2437f3364fe0336575e32c82368","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -44,7 +44,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -643,7 +643,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 30 persist-credentials: false diff --git a/.github/workflows/gh-aw-docs-frontmatter-sweep.lock.yml b/.github/workflows/gh-aw-docs-frontmatter-sweep.lock.yml index 125f9089..748c5d16 100644 --- a/.github/workflows/gh-aw-docs-frontmatter-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-frontmatter-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"0092af990d55dc70c7e1d94ccbd243fefa27c2f8e2be5042f7659897b24b6c31","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -44,7 +44,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -631,7 +631,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 30 persist-credentials: false diff --git a/.github/workflows/gh-aw-docs-openings-sweep.lock.yml b/.github/workflows/gh-aw-docs-openings-sweep.lock.yml index 2499bb2b..cf9a7dee 100644 --- a/.github/workflows/gh-aw-docs-openings-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-openings-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"6f92b7dd643845ebdd84409b45c39f5eba3493453cabd41e9fe06738576586d8","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -43,7 +43,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -639,7 +639,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 30 persist-credentials: false diff --git a/.github/workflows/gh-aw-docs-staleness-sweep.lock.yml b/.github/workflows/gh-aw-docs-staleness-sweep.lock.yml index 1ad4cc3c..0777e4f5 100644 --- a/.github/workflows/gh-aw-docs-staleness-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-staleness-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"83730e892c4686ac69269d501b9fd18336ef3252ddd9da9245805b0dfcf2b865","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -44,7 +44,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -650,7 +650,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/gh-aw-docs-style-sweep.lock.yml b/.github/workflows/gh-aw-docs-style-sweep.lock.yml index ce2c2bce..a1c3ef6a 100644 --- a/.github/workflows/gh-aw-docs-style-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-style-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"3a87b6e135615b0fde1701690ded47f089dd65413730cf7dc8118c04758314d0","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -43,7 +43,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -631,7 +631,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 30 persist-credentials: false diff --git a/.github/workflows/gh-aw-docs-typos-sweep.lock.yml b/.github/workflows/gh-aw-docs-typos-sweep.lock.yml index c79391c3..5d238792 100644 --- a/.github/workflows/gh-aw-docs-typos-sweep.lock.yml +++ b/.github/workflows/gh-aw-docs-typos-sweep.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"3be42550899dd610111872c641e30947ba676b126cb692bf2bdb7d8e0ce5cf12","compiler_version":"v0.75.0","agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"a309ff8b426b58ec0e2a45f0f869d46889d02405","version":"v6.2.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"a309ff8b426b58ec0e2a45f0f869d46889d02405","version":"v6.2.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f889c9c3c06adeaabccefc06e29c42733ee05dff","version":"v0.75.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.51"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.17"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -42,7 +42,7 @@ # # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -593,7 +593,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Checkout source docs repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (source v6) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 1 persist-credentials: false diff --git a/github/is-elastic-org-member/README.md b/github/is-elastic-org-member/README.md index f486017d..9dae05f6 100644 --- a/github/is-elastic-org-member/README.md +++ b/github/is-elastic-org-member/README.md @@ -1,28 +1,26 @@ # github/is-elastic-org-member -Verifies that one or more GitHub users are members of the elastic org using a GitHub token with `read:org` scope. When `collect-from-workflow-run` is `true`, the action also auto-collects every login involved in the upstream `workflow_run` event (PR opener + `actor` + `triggering_actor` + head-commit author/committer) so callers don't duplicate that logic. **All resolved users must be confirmed members for `is-member` to be `true`** — fail-closed semantics for trust gates. - -See [elastic/docs-eng-team#511](https://github.com/elastic/docs-eng-team/issues/511) for the security review that motivates the multi-login check. +Verifies that one or more GitHub users are members of the elastic org using a GitHub token with read:org scope. When `collect-from-workflow-run` is true, also auto-collects every login involved in the upstream workflow_run (PR opener + actor + triggering_actor + head-commit author/committer) so callers don't duplicate that logic. All resolved users must be confirmed members for `is-member` to be `true` (fail-closed semantics for trust gates — see elastic/docs-eng-team#511). ## Inputs -| Name | Description | Required | Default | -|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------| -| `username` | Single GitHub username to check. Backward-compat input for callers that only need to verify one user. Merged with `usernames` and any auto-collected logins. | `false` | `''` | -| `usernames` | Newline- or comma-separated list of additional GitHub usernames. Merged with `username` and any auto-collected logins. | `false` | `''` | -| `collect-from-workflow-run` | When `'true'` (default) and the calling workflow was triggered by `workflow_run`, automatically include the PR opener, the upstream run's `actor` and `triggering_actor`, and the head commit's author and committer in the membership check. Set to `'false'` to validate only the explicit `username` / `usernames` inputs. | `false` | `'true'` | -| `token` | GitHub token with `read:org` scope (for membership checks) and at minimum read access to public commits (for fork head-commit lookups). Typically an ephemeral Vault-issued token. | `true` | ` ` | +| Name | Description | Required | Default | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------| +| `username` | Single GitHub username to check. Backward-compat input for callers that only need to verify one user. Merged with `usernames` and any auto-collected logins.
| `false` | ` ` | +| `usernames` | Newline- or comma-separated list of additional GitHub usernames. Merged with `username` and any auto-collected logins.
| `false` | ` ` | +| `collect-from-workflow-run` | When 'true' (default) and the calling workflow was triggered by `workflow_run`, automatically include the PR opener, the upstream run's `actor` and `triggering_actor`, and the head commit's author and committer in the membership check. Set to 'false' to validate only the explicit `username` / `usernames` inputs.
| `false` | `true` | +| `token` | GitHub token with `read:org` scope (for membership checks) and at minimum read access to public commits (for fork head-commit lookups). Typically an ephemeral Vault-issued token.
| `true` | ` ` | ## Outputs -| Name | Description | -|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `is-member` | `'true'` if every resolved user is a confirmed elastic org member, `'false'` otherwise (including when no usernames were resolved). | -| `non-members` | Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. | -| `checked-usernames` | Newline-separated list of usernames that were validated and checked (after deduplication and bot/invalid filtering). Useful for diagnostic logging. | +| Name | Description | +|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `is-member` | "'true' if every resolved user is a confirmed elastic org member, 'false' otherwise (including when no usernames were resolved)."
| +| `non-members` | Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. Useful for logging which account(s) caused the gate to fail.
| +| `checked-usernames` | Newline-separated list of usernames that were validated and checked (after deduplication and filtering of bots/invalid logins). Useful for diagnostic logging.
| ## Behavior From c55ade1580af4b7c07f4a834b0a1ded96bd51a5b Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:25:02 -0300 Subject: [PATCH 09/13] 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 9ee37296..65dc78ea 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 681bb756..277d154e 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 10/13] 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 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/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 }; From 07715f055d86ce04113b57dfce12f6d9fb3f8520 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:29:59 -0300 Subject: [PATCH 11/13] 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 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'); From 87573c5bee6f03aa5ec58299279b4273cacb70c9 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 13 Jul 2026 20:33:00 -0300 Subject: [PATCH 12/13] 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 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')); From 8219ddaf8e0def5e8a20d169cef617679c227f12 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Sat, 16 May 2026 20:11:01 -0300 Subject: [PATCH 13/13] Verify docs-builder :edge attestation before use Add `gh attestation verify` to both consumer paths that pull `ghcr.io/elastic/docs-builder:edge`: - `.github/workflows/docs-deploy.yml` (Pull and pin docs-builder image) - `docs-builder/setup/action.yml` (edge branch) Both already resolve :edge to an immutable RepoDigest. They now also verify the SLSA build-provenance attestation minted by docs-builder's prerelease.yml (elastic/docs-eng-team#518), constraining the attestation issuer to `elastic/docs-builder` via `-R` so an attacker cannot self-sign under their own repo and have it accepted. Depends on the producer side shipping in elastic/docs-builder first and at least one signed :edge push existing in the registry; the verify call fails closed otherwise. Co-authored-by: Cursor --- .github/workflows/docs-deploy.yml | 15 +++++++++++++-- docs-builder/setup/action.yml | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 65dc78ea..89df8030 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -438,12 +438,15 @@ 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. + # 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") @@ -451,6 +454,14 @@ jobs: 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}" diff --git a/docs-builder/setup/action.yml b/docs-builder/setup/action.yml index 277d154e..8dff6485 100644 --- a/docs-builder/setup/action.yml +++ b/docs-builder/setup/action.yml @@ -22,6 +22,8 @@ runs: mkdir -p "${INSTALL_DIR}" if [[ "${DOCS_BUILDER_VERSION}" == "edge" ]]; then + # 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}") @@ -29,6 +31,13 @@ runs: 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}")