Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/docs-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -437,6 +438,33 @@ jobs:
echo "PATH_PREFIX=${path_prefix}" >> "$GITHUB_ENV"
echo "result=${path_prefix}" >> "$GITHUB_OUTPUT"

# Resolve the mutable :edge tag once, verify its build provenance, and
# use the same immutable image digest throughout this job.
- name: Pull and pin docs-builder image
id: docker-image
env:
GH_TOKEN: ${{ github.token }}
# language=bash
run: |
set -euo pipefail
IMAGE="ghcr.io/elastic/docs-builder:edge"
docker pull "$IMAGE"
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")
if [[ -z "$DIGEST" ]]; then
echo "::error::Failed to resolve RepoDigest for ${IMAGE}"
exit 1
fi

# Fail closed if the attestation is missing, malformed, or
# signed by an unexpected workflow. `-R` constrains the
# attestation issuer to the docs-builder repo so an attacker
# cannot publish a self-signed attestation under their own
# repo and have it accepted here.
gh attestation verify "oci://${DIGEST}" -R elastic/docs-builder

echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "::notice title=docs-builder image digest::${DIGEST}"

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

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

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

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

const BODY_FILE_MAX_BYTES = 64 * 1024;

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

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

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

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

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

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

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

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

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

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

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

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

module.exports = async ({ github, context, core }) => {
const prNumber = parseInt(process.env.PR_NUMBER, 10);
Expand All @@ -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.',
);
Expand Down
11 changes: 6 additions & 5 deletions changelog/submit/apply/scripts/post-failure-comment.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
const { TITLE, upsertComment } = require('./comment-helper');
const { TITLE, upsertComment, wrapInlineCode } = require('./comment-helper');

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

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

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

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

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

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

module.exports = async ({ github, context, core }) => {
const prNumber = parseInt(process.env.PR_NUMBER, 10);
Expand All @@ -13,7 +13,7 @@ module.exports = async ({ github, context, core }) => {
const body = [
TITLE,
'',
`📝 Changelog entry committed: [\`${escapeMarkdown(changelogFile)}\`](${viewUrl})`,
`📝 Changelog entry committed: [${wrapInlineCode(changelogFile)}](${viewUrl})`,
'',
`✏️ [Edit this changelog](${editUrl})`,
].join('\n');
Expand Down
2 changes: 1 addition & 1 deletion changelog/submit/evaluate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
38 changes: 35 additions & 3 deletions changelog/submit/evaluate/scripts/fetch-pr-data.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
const { stagePrBody } = require('../../../shared/scripts/pr-body');

const TITLE_MAX_LENGTH = 200;

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

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

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

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

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

- name: Evaluate PR
id: evaluate
shell: bash
Expand All @@ -44,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 }}
Expand Down
32 changes: 32 additions & 0 deletions changelog/validate/scripts/stage-pr-body.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const fs = require('fs');
const { stagePrBody } = require('../../shared/scripts/pr-body');

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

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

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

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

return staged;
};

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

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

if [[ "${DOCS_BUILDER_VERSION}" == "edge" ]]; then
docker cp $(docker create --name tc ghcr.io/elastic/docs-builder:edge):/app/docs-builder "${INSTALL_DIR}/docs-builder" && docker rm tc
# Resolve :edge to an immutable digest and fail closed unless its
# build-provenance attestation comes from elastic/docs-builder.
EDGE_IMAGE="ghcr.io/elastic/docs-builder:edge"
docker pull "${EDGE_IMAGE}"
EDGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "${EDGE_IMAGE}")
if [[ -z "${EDGE_DIGEST}" ]]; then
echo "::error::Failed to resolve RepoDigest for ${EDGE_IMAGE}"
exit 1
fi

# `-R` constrains the attestation issuer to the
# docs-builder repo so an attacker cannot publish a
# self-signed attestation under their own repo and have it
# accepted here.
gh attestation verify "oci://${EDGE_DIGEST}" -R elastic/docs-builder

echo "::notice title=docs-builder image digest::${EDGE_DIGEST}"

CONTAINER_ID=$(docker create "${EDGE_DIGEST}")
cleanup_container() {
docker rm -f "${CONTAINER_ID}" >/dev/null 2>&1 || true
}
trap cleanup_container EXIT
docker cp "${CONTAINER_ID}:/app/docs-builder" "${INSTALL_DIR}/docs-builder"
else
if [[ "${DOCS_BUILDER_VERSION}" == "latest" ]]; then
DOCS_BUILDER_VERSION="" # empty string to get the latest version
Expand Down
Loading
Loading