mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-27 13:47:33 -05:00
629 lines
28 KiB
YAML
629 lines
28 KiB
YAML
# .github/workflows/docs-needed.yml
|
|
#
|
|
# Place this file in ALL of:
|
|
# mattermost/mattermost -> .github/workflows/docs-needed.yml
|
|
# mattermost/mattermost-mobile -> .github/workflows/docs-needed.yml
|
|
# mattermost/desktop -> .github/workflows/docs-needed.yml
|
|
#
|
|
# Authentication — reuses the two-app pattern from changelog automation:
|
|
#
|
|
# Read app (vars.CHANGELOG_READ_CLIENT_ID / secrets.CHANGELOG_READ_PRIVATE_KEY)
|
|
# Installed on mattermost/mattermost with contents: read.
|
|
# Used only to look up milestones (step 3).
|
|
#
|
|
# Write app (vars.CHANGELOG_WRITE_CLIENT_ID / secrets.CHANGELOG_WRITE_PRIVATE_KEY)
|
|
# Installed on mattermost/docs with contents: write + pull-requests: write.
|
|
# Used to push the docs branch, open the draft PR, and manage milestones/reviewers.
|
|
# changelog-automation-docs[bot] must be on the CLA ignore list in mattermost/docs.
|
|
#
|
|
# Other required secrets (set in each repo's Settings -> Secrets and Variables):
|
|
# secrets.ANTHROPIC_API_KEY - Claude API key for doc generation
|
|
# vars.CHANGELOG_GIT_USERNAME - Git display name for automated commits
|
|
# vars.CHANGELOG_GIT_EMAIL - Git email for automated commits
|
|
#
|
|
# Behaviour:
|
|
# When Docs/Needed is added to a non-draft PR from within the same repo,
|
|
# opens one docs PR in mattermost/docs:
|
|
# Branch: docs/<repo-name>-pr-<number>
|
|
# e.g. docs/mattermost-pr-1234, docs/desktop-pr-42
|
|
# Targets the versioned release branch (e.g. v11.7-documentation) if it
|
|
# exists in mattermost/docs; falls back to master with a warning.
|
|
# Draft doc generated by Claude using the draft_docs.md prompt.
|
|
# Milestone on the docs PR mirrors the dev PR milestone.
|
|
# Dev PR author added as reviewer on the docs PR.
|
|
# Comment posted on the dev PR linking to the new docs PR.
|
|
# If Docs/Needed is re-applied after removal, a fresh run creates or
|
|
# updates the same branch (idempotent via the exists check).
|
|
|
|
name: "docs/needed - Open Docs PR"
|
|
|
|
on:
|
|
pull_request:
|
|
types: [labeled]
|
|
workflow_dispatch:
|
|
inputs:
|
|
pr_number:
|
|
description: >-
|
|
PR number to process. Intended use cases:
|
|
(1) smoke-test the workflow after merging without having to apply a label;
|
|
(2) re-run after a transient failure (e.g. Claude API timeout, push error)
|
|
without bouncing the Docs/Needed label;
|
|
(3) validate the workflow against a known PR during development or review.
|
|
required: true
|
|
type: string
|
|
|
|
# One run at a time per dev PR prevents a race if the label bounces quickly.
|
|
# cancel-in-progress is false because a mid-run cancellation can leave the
|
|
# docs branch in a partial state (e.g. commit pushed but PR not yet opened).
|
|
# Queuing is safer; the idempotency checks in each step handle duplicates.
|
|
concurrency:
|
|
group: >-
|
|
docs-needed-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
open-docs-pr:
|
|
name: "Open Docs PR"
|
|
runs-on: ubuntu-latest
|
|
# Fork guard: only run on PRs opened from within this repo, not forks.
|
|
# Draft guard: skip draft PRs - apply Docs/Needed when the PR is ready.
|
|
# workflow_dispatch bypasses both guards for manual testing.
|
|
if: |
|
|
github.event_name == 'workflow_dispatch' ||
|
|
(github.event.label.name == 'Docs/Needed' &&
|
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
github.event.pull_request.draft == false)
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
|
|
steps:
|
|
# 0. Generate short-lived installation tokens — one per app.
|
|
# Tokens expire automatically after 1 hour and are not tied to any
|
|
# individual user account.
|
|
#
|
|
# read-token: scoped to mattermost/mattermost for milestone lookups
|
|
# write-token: scoped to mattermost/docs for branch push, PR creation,
|
|
# milestone sync, and reviewer assignment
|
|
- name: Generate read token
|
|
id: read-token
|
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
with:
|
|
app-id: ${{ vars.CHANGELOG_READ_CLIENT_ID }}
|
|
private-key: ${{ secrets.CHANGELOG_READ_PRIVATE_KEY }}
|
|
repositories: mattermost
|
|
|
|
- name: Generate write token
|
|
id: write-token
|
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
with:
|
|
app-id: ${{ vars.CHANGELOG_WRITE_CLIENT_ID }}
|
|
private-key: ${{ secrets.CHANGELOG_WRITE_PRIVATE_KEY }}
|
|
repositories: docs
|
|
|
|
# 0b. Resolve PR data — normalises pull_request event fields and
|
|
# workflow_dispatch inputs into one set of outputs so all subsequent
|
|
# steps are trigger-agnostic. For workflow_dispatch the PR is fetched
|
|
# by number from this repo using the built-in GITHUB_TOKEN.
|
|
- name: Resolve PR data
|
|
id: pr-data
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
|
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
EVENT_MILESTONE_NUMBER: ${{ github.event.pull_request.milestone.number }}
|
|
EVENT_MILESTONE_TITLE: ${{ github.event.pull_request.milestone.title }}
|
|
EVENT_MILESTONE_DUE: ${{ github.event.pull_request.milestone.due_on }}
|
|
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
|
|
EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
run: |
|
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
|
DATA=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$INPUT_PR_NUMBER")
|
|
echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_number=$(echo "$DATA" | jq -r '.milestone.number // ""')" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_title=$(echo "$DATA" | jq -r '.milestone.title // ""')" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_due=$(echo "$DATA" | jq -r '.milestone.due_on // ""')" >> "$GITHUB_OUTPUT"
|
|
echo "pr_title=$(echo "$DATA" | jq -r '.title')" >> "$GITHUB_OUTPUT"
|
|
echo "pr_author=$(echo "$DATA" | jq -r '.user.login')" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_number=$EVENT_MILESTONE_NUMBER" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_title=$EVENT_MILESTONE_TITLE" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_due=$EVENT_MILESTONE_DUE" >> "$GITHUB_OUTPUT"
|
|
echo "pr_title=$EVENT_PR_TITLE" >> "$GITHUB_OUTPUT"
|
|
echo "pr_author=$EVENT_PR_AUTHOR" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# 1. Guard: milestone is required
|
|
- name: Require milestone
|
|
env:
|
|
MILESTONE: ${{ steps.pr-data.outputs.milestone_number }}
|
|
run: |
|
|
if [ -z "$MILESTONE" ]; then
|
|
echo "::error title=Missing milestone::Set a milestone on this PR before applying Docs/Needed."
|
|
exit 1
|
|
fi
|
|
|
|
# 2. Compute shared variables
|
|
- name: Set variables
|
|
id: vars
|
|
env:
|
|
MILESTONE_TITLE: ${{ steps.pr-data.outputs.milestone_title }}
|
|
MILESTONE_NUMBER: ${{ steps.pr-data.outputs.milestone_number }}
|
|
PR_NUMBER: ${{ steps.pr-data.outputs.pr_number }}
|
|
PR_TITLE: ${{ steps.pr-data.outputs.pr_title }}
|
|
PR_AUTHOR: ${{ steps.pr-data.outputs.pr_author }}
|
|
SOURCE_REPO: ${{ github.repository }}
|
|
run: |
|
|
REPO_NAME="${SOURCE_REPO##*/}"
|
|
BRANCH="docs/${REPO_NAME}-pr-${PR_NUMBER}"
|
|
DOCS_FILE="source/about/drafts/${REPO_NAME}-pr-${PR_NUMBER}.md"
|
|
|
|
echo "repo_name=$REPO_NAME" >> "$GITHUB_OUTPUT"
|
|
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
|
echo "docs_file=$DOCS_FILE" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_title=$MILESTONE_TITLE" >> "$GITHUB_OUTPUT"
|
|
echo "milestone_number=$MILESTONE_NUMBER" >> "$GITHUB_OUTPUT"
|
|
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
|
echo "pr_title=$PR_TITLE" >> "$GITHUB_OUTPUT"
|
|
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
|
|
echo "source_repo=$SOURCE_REPO" >> "$GITHUB_OUTPUT"
|
|
|
|
# 3. Resolve the versioned base branch in mattermost/docs
|
|
# mattermost/mattermost milestones (e.g. "v11.7.0") map directly to
|
|
# the docs branch (e.g. "v11.7-documentation").
|
|
# mattermost-mobile and desktop use different versioning schemes, but
|
|
# releases are coordinated with mattermost/mattermost by due date.
|
|
# mobile v2.40.0 due 2025-03-15 == mattermost v11.7.0 due 2025-03-15
|
|
# => docs branch v11.7-documentation.
|
|
- name: Resolve docs base branch
|
|
id: base-branch
|
|
env:
|
|
GH_TOKEN: ${{ steps.read-token.outputs.token }}
|
|
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
|
|
MILESTONE_DUE: ${{ steps.pr-data.outputs.milestone_due }}
|
|
REPO_NAME: ${{ steps.vars.outputs.repo_name }}
|
|
run: |
|
|
derive_branch_from_title() {
|
|
local title="$1"
|
|
local version
|
|
version=$(echo "$title" | grep -oE 'v[0-9]+\.[0-9]+' | head -1)
|
|
echo "$version"
|
|
}
|
|
|
|
if [ "$REPO_NAME" == "mattermost" ]; then
|
|
VERSION=$(derive_branch_from_title "$MILESTONE_TITLE")
|
|
if [ -z "$VERSION" ]; then
|
|
echo "::warning::Could not parse vMAJOR.MINOR from milestone" \
|
|
"'${MILESTONE_TITLE}' - targeting master."
|
|
echo "base=master" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
echo "Derived docs branch: ${VERSION}-documentation"
|
|
|
|
else
|
|
if [ -z "$MILESTONE_DUE" ] || [ "$MILESTONE_DUE" == "null" ]; then
|
|
echo "::warning::Milestone '${MILESTONE_TITLE}' has no due date" \
|
|
"- cannot match to a mattermost/mattermost release. Targeting master."
|
|
echo "base=master" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
MILESTONE_DATE="${MILESTONE_DUE%%T*}"
|
|
MM_MILESTONE=$(gh api repos/mattermost/mattermost/milestones \
|
|
--paginate \
|
|
| jq -r --arg date "$MILESTONE_DATE" \
|
|
'.[] | select(((.due_on // "")[0:10]) == $date) | .title' \
|
|
| head -1)
|
|
|
|
if [ -z "$MM_MILESTONE" ]; then
|
|
echo "::warning::No mattermost/mattermost milestone found with" \
|
|
"due date '${MILESTONE_DUE}'. Targeting master."
|
|
echo "base=master" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
VERSION=$(derive_branch_from_title "$MM_MILESTONE")
|
|
if [ -z "$VERSION" ]; then
|
|
echo "::warning::Could not parse vMAJOR.MINOR from matched" \
|
|
"milestone '${MM_MILESTONE}'. Targeting master."
|
|
echo "base=master" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Matched '${MILESTONE_TITLE}' to '${MM_MILESTONE}'" \
|
|
"=> ${VERSION}-documentation"
|
|
fi
|
|
|
|
DOCS_BRANCH="${VERSION}-documentation"
|
|
|
|
EXISTS=$(gh api "repos/mattermost/docs/branches/${DOCS_BRANCH}" \
|
|
--jq '.name' 2>/dev/null || echo "")
|
|
|
|
if [ -n "$EXISTS" ]; then
|
|
echo "base=$DOCS_BRANCH" >> "$GITHUB_OUTPUT"
|
|
echo "Docs branch '${DOCS_BRANCH}' found."
|
|
else
|
|
echo "base=master" >> "$GITHUB_OUTPUT"
|
|
echo "::warning::Branch '${DOCS_BRANCH}' not found in" \
|
|
"mattermost/docs - targeting master." \
|
|
"Ensure docs-branch-create.yml has run for this milestone."
|
|
fi
|
|
|
|
# 4. Check for an existing docs PR (idempotency)
|
|
- name: Check for existing docs PR
|
|
id: existing
|
|
env:
|
|
GH_TOKEN: ${{ steps.write-token.outputs.token }}
|
|
BRANCH: ${{ steps.vars.outputs.branch }}
|
|
run: |
|
|
RESULT=$(gh pr list \
|
|
--repo mattermost/docs \
|
|
--head "$BRANCH" \
|
|
--state open \
|
|
--json number,url \
|
|
--jq 'first // empty')
|
|
|
|
if [ -n "$RESULT" ]; then
|
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
echo "number=$(echo "$RESULT" | jq -r .number)" >> "$GITHUB_OUTPUT"
|
|
echo "url=$(echo "$RESULT" | jq -r .url)" >> "$GITHUB_OUTPUT"
|
|
EXISTING_URL=$(echo "$RESULT" | jq -r .url)
|
|
echo "::notice::Docs PR already open at ${EXISTING_URL} - updating content only."
|
|
else
|
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# 5. Checkout docs repo
|
|
# Pinned to a specific commit SHA for supply-chain security.
|
|
# To update: find the SHA for the desired tag at
|
|
# https://github.com/actions/checkout/releases
|
|
- name: Checkout docs repo
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
repository: mattermost/docs
|
|
token: ${{ steps.write-token.outputs.token }}
|
|
path: docs-repo
|
|
fetch-depth: 1
|
|
|
|
- name: Configure git identity
|
|
working-directory: docs-repo
|
|
run: |
|
|
git config user.name "${{ vars.CHANGELOG_GIT_USERNAME }}"
|
|
git config user.email "${{ vars.CHANGELOG_GIT_EMAIL }}"
|
|
|
|
- name: Set up branch
|
|
working-directory: docs-repo
|
|
env:
|
|
BRANCH: ${{ steps.vars.outputs.branch }}
|
|
BASE_BRANCH: ${{ steps.base-branch.outputs.base }}
|
|
run: |
|
|
if git ls-remote --exit-code origin "$BRANCH" > /dev/null 2>&1; then
|
|
git fetch origin "$BRANCH"
|
|
git checkout "$BRANCH"
|
|
else
|
|
git fetch origin "$BASE_BRANCH"
|
|
git checkout -b "$BRANCH" "origin/$BASE_BRANCH"
|
|
fi
|
|
|
|
# 6. Fetch PR content for Claude
|
|
- name: Fetch PR diff and description
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
|
|
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
|
|
run: |
|
|
gh pr diff "$PR_NUMBER" --repo "$SOURCE_REPO" 2>/dev/null \
|
|
| head -c 20000 > /tmp/pr-diff.txt || true
|
|
|
|
gh pr view "$PR_NUMBER" --repo "$SOURCE_REPO" \
|
|
--json body --jq '.body // ""' > /tmp/pr-body.txt
|
|
|
|
# 7. Generate documentation draft via Claude
|
|
#
|
|
# System prompt is read from mattermost/docs at runtime so it can be
|
|
# updated independently of this workflow file.
|
|
# File location in mattermost/docs: .github/prompts/draft_docs.md
|
|
#
|
|
# Raw API call (not anthropics/claude-code-action) because:
|
|
# - We supply a custom system prompt read from a file in the docs repo
|
|
# - We HTML-escape untrusted PR content before insertion (security)
|
|
# - We validate the response structure before writing any file
|
|
# claude-code-action is designed for code-execution tasks and does not
|
|
# support file-based system prompts or the output validation required
|
|
# by the security model here.
|
|
#
|
|
# Model: claude-sonnet-4-6 balances quality and cost for doc drafting
|
|
# and is consistent with other Mattermost docs automation workflows.
|
|
- name: Generate documentation draft
|
|
env:
|
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
|
|
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
|
|
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
|
|
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
|
|
run: |
|
|
PROMPT_FILE="docs-repo/.github/prompts/draft_docs.md"
|
|
if [ ! -f "$PROMPT_FILE" ]; then
|
|
echo "::error::Prompt file not found: $PROMPT_FILE"
|
|
echo "Add draft_docs.md to mattermost/docs at .github/prompts/draft_docs.md"
|
|
exit 1
|
|
fi
|
|
cp "$PROMPT_FILE" /tmp/system_prompt.txt
|
|
|
|
# Untrusted PR content is HTML-escaped then wrapped in XML tags.
|
|
# Escaping converts < > & to < > & so that an attacker
|
|
# cannot inject a closing tag (e.g. </pr_description>) to break out
|
|
# of the data section and inject arbitrary instructions.
|
|
escape_xml() {
|
|
python3 -c \
|
|
'import sys,html;print(html.escape(sys.stdin.read(),quote=False),end="")'
|
|
}
|
|
|
|
escape_xml < /tmp/pr-body.txt > /tmp/pr-body-safe.txt
|
|
escape_xml < /tmp/pr-diff.txt > /tmp/pr-diff-safe.txt
|
|
|
|
# Escape user-controlled metadata fields for the prompt.
|
|
# PR title is set by the PR author; milestone title is set by
|
|
# maintainers but escaped here for defence-in-depth.
|
|
PR_TITLE_SAFE=$(printf '%s' "$PR_TITLE" | escape_xml)
|
|
MILESTONE_SAFE=$(printf '%s' "$MILESTONE_TITLE" | escape_xml)
|
|
|
|
PR_URL="https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER}"
|
|
{
|
|
echo "<pr_metadata>"
|
|
echo "PR URL: ${PR_URL}"
|
|
echo "PR Title: ${PR_TITLE_SAFE}"
|
|
echo "Source Repository: ${SOURCE_REPO}"
|
|
echo "milestone.title: ${MILESTONE_SAFE}"
|
|
echo "</pr_metadata>"
|
|
echo ""
|
|
echo "<pr_description>"
|
|
cat /tmp/pr-body-safe.txt
|
|
echo "</pr_description>"
|
|
echo ""
|
|
echo "<code_diff>"
|
|
cat /tmp/pr-diff-safe.txt
|
|
echo "</code_diff>"
|
|
} > /tmp/user_message.txt
|
|
|
|
PAYLOAD=$(jq -n \
|
|
--rawfile system /tmp/system_prompt.txt \
|
|
--rawfile content /tmp/user_message.txt \
|
|
'{
|
|
model: "claude-sonnet-4-6",
|
|
max_tokens: 2048,
|
|
system: $system,
|
|
messages: [{
|
|
role: "user",
|
|
content: $content
|
|
}]
|
|
}')
|
|
|
|
# --max-time 60: fail fast if the API is unresponsive.
|
|
# Capture the full response before piping so we can inspect it.
|
|
RESPONSE=$(curl -sf --max-time 60 \
|
|
https://api.anthropic.com/v1/messages \
|
|
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
|
-H "anthropic-version: 2023-06-01" \
|
|
-H "content-type: application/json" \
|
|
-d "$PAYLOAD") || {
|
|
echo "::error::Claude API call failed (timeout or HTTP error)."
|
|
exit 1
|
|
}
|
|
|
|
echo "$RESPONSE" | jq -r '.content[0].text' > /tmp/generated-docs.md
|
|
|
|
# Fail immediately if the API returned null or an empty body.
|
|
# Log only the error type and message from the JSON response - not
|
|
# the full body - to avoid exposing API metadata in workflow logs.
|
|
if [ ! -s /tmp/generated-docs.md ] || \
|
|
[ "$(cat /tmp/generated-docs.md)" = "null" ]; then
|
|
ERROR_INFO=$(echo "$RESPONSE" | jq -r '
|
|
if .error then "\(.error.type): \(.error.message)"
|
|
else "unexpected null or empty content"
|
|
end' 2>/dev/null || echo "response could not be parsed")
|
|
echo "::error::Claude API returned empty/null content. ${ERROR_INFO}"
|
|
exit 1
|
|
fi
|
|
|
|
# Validate the output matches the required structure defined in the
|
|
# system prompt. Missing sections indicate the model was steered away
|
|
# from its instructions - a signal of possible prompt injection.
|
|
if ! grep -qF '=== CAPABILITY SUMMARY ===' /tmp/generated-docs.md; then
|
|
echo "::error::Output missing CAPABILITY SUMMARY section." \
|
|
"Possible prompt injection. Aborting."
|
|
exit 1
|
|
fi
|
|
if ! grep -qF '=== DOCUMENTATION DRAFT ===' /tmp/generated-docs.md; then
|
|
echo "::error::Output missing DOCUMENTATION DRAFT section." \
|
|
"Possible prompt injection. Aborting."
|
|
exit 1
|
|
fi
|
|
|
|
# 8. Write docs file
|
|
- name: Write docs file
|
|
working-directory: docs-repo
|
|
env:
|
|
DOCS_FILE: ${{ steps.vars.outputs.docs_file }}
|
|
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
|
|
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
|
|
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
|
|
PR_AUTHOR: ${{ steps.vars.outputs.pr_author }}
|
|
run: |
|
|
mkdir -p "$(dirname "$DOCS_FILE")"
|
|
|
|
# Sanitize PR title for the Markdown heading to prevent XSS if the
|
|
# docs are rendered as HTML (e.g. < > become < >).
|
|
PR_TITLE_SAFE=$(printf '%s' "$PR_TITLE" | \
|
|
python3 -c \
|
|
'import sys,html;print(html.escape(sys.stdin.read(),quote=False),end="")')
|
|
|
|
{
|
|
printf '# %s\n\n' "$PR_TITLE_SAFE"
|
|
printf '> **Status:** Draft - auto-generated. Requires editorial review.\n'
|
|
printf '> **Source:** [%s#%s](https://github.com/%s/pull/%s)\n' \
|
|
"$SOURCE_REPO" "$PR_NUMBER" "$SOURCE_REPO" "$PR_NUMBER"
|
|
printf '> **Dev reviewer:** @%s\n\n' "$PR_AUTHOR"
|
|
printf -- '---\n\n'
|
|
cat /tmp/generated-docs.md
|
|
} > "$DOCS_FILE"
|
|
|
|
# 9. Commit and push
|
|
- name: Commit and push
|
|
working-directory: docs-repo
|
|
env:
|
|
BRANCH: ${{ steps.vars.outputs.branch }}
|
|
DOCS_FILE: ${{ steps.vars.outputs.docs_file }}
|
|
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
|
|
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
|
|
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
|
|
run: |
|
|
# Stage only the specific docs file - not the entire working tree.
|
|
git add "$DOCS_FILE"
|
|
if git diff --cached --quiet; then
|
|
echo "No content changes - skipping commit."
|
|
else
|
|
# Sanitize PR title for commit message: PR_TITLE is author-controlled
|
|
# and may contain newlines, quotes, or be very long. Strip control
|
|
# characters and truncate to 72 chars (git convention).
|
|
PR_TITLE_SHORT=$(printf '%s' "$PR_TITLE" | tr -d '\n\r' | cut -c1-72)
|
|
# Use printf with a literal format string and positional arguments,
|
|
# then pipe to -F - so the message is never interpolated into a
|
|
# shell command string. This avoids any risk of shell metacharacters
|
|
# in PR_TITLE_SHORT being evaluated.
|
|
printf 'docs: draft for %s#%s - %s\n' \
|
|
"$SOURCE_REPO" "$PR_NUMBER" "$PR_TITLE_SHORT" \
|
|
| git commit -F -
|
|
git push origin "$BRANCH"
|
|
fi
|
|
|
|
# 10. Sync milestone to mattermost/docs
|
|
- name: Sync milestone
|
|
id: docs-milestone
|
|
env:
|
|
GH_TOKEN: ${{ steps.write-token.outputs.token }}
|
|
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
|
|
run: |
|
|
# Use --arg to pass the title safely - avoids jq injection if the
|
|
# milestone title contains quotes or other special characters.
|
|
NUM=$(gh api 'repos/mattermost/docs/milestones?state=all' \
|
|
--paginate \
|
|
--jq '.[] | select(.title == $title) | .number' \
|
|
--arg title "$MILESTONE_TITLE" \
|
|
| head -1)
|
|
|
|
if [ -z "$NUM" ]; then
|
|
NUM=$(gh api repos/mattermost/docs/milestones \
|
|
--method POST \
|
|
-f title="$MILESTONE_TITLE" \
|
|
--jq '.number')
|
|
echo "::notice::Created milestone '$MILESTONE_TITLE' (#$NUM) in mattermost/docs."
|
|
fi
|
|
|
|
echo "number=$NUM" >> "$GITHUB_OUTPUT"
|
|
|
|
# 11a. Create docs PR
|
|
- name: Create docs PR
|
|
id: create-pr
|
|
if: steps.existing.outputs.exists == 'false'
|
|
env:
|
|
GH_TOKEN: ${{ steps.write-token.outputs.token }}
|
|
BRANCH: ${{ steps.vars.outputs.branch }}
|
|
MILESTONE_TITLE: ${{ steps.vars.outputs.milestone_title }}
|
|
MILESTONE_NUMBER: ${{ steps.docs-milestone.outputs.number }}
|
|
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
|
|
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
|
|
PR_TITLE: ${{ steps.vars.outputs.pr_title }}
|
|
PR_AUTHOR: ${{ steps.vars.outputs.pr_author }}
|
|
run: |
|
|
PR_LINK="[${SOURCE_REPO}#${PR_NUMBER}]"
|
|
PR_LINK="${PR_LINK}(https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER})"
|
|
BODY=$(printf '%s\n\n%s\n\n%s\n%s\n%s\n%s\n%s\n\n%s' \
|
|
"Documentation draft for ${PR_LINK}." \
|
|
"> Auto-generated by Claude - requires editorial review before merging." \
|
|
"| Field | Value |" \
|
|
"|---|---|" \
|
|
"| **Source PR** | ${PR_LINK} |" \
|
|
"| **Dev reviewer** | @${PR_AUTHOR} |" \
|
|
"| **Milestone** | ${MILESTONE_TITLE} |" \
|
|
"<!-- dev-pr: ${SOURCE_REPO}#${PR_NUMBER} -->")
|
|
|
|
# Sanitize PR title for the docs PR title: same rules as commit message.
|
|
PR_TITLE_SHORT=$(printf '%s' "$PR_TITLE" | tr -d '\n\r' | cut -c1-72)
|
|
|
|
# Open as a draft so a human must explicitly un-draft before merging.
|
|
# This is the primary control against auto-committing injected content.
|
|
URL=$(gh pr create \
|
|
--repo mattermost/docs \
|
|
--head "$BRANCH" \
|
|
--base "${{ steps.base-branch.outputs.base }}" \
|
|
--title "docs: ${PR_TITLE_SHORT}" \
|
|
--body "$BODY" \
|
|
--milestone "$MILESTONE_NUMBER" \
|
|
--draft)
|
|
|
|
echo "number=$(echo "$URL" | grep -oE '[0-9]+$')" >> "$GITHUB_OUTPUT"
|
|
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
|
|
# 11b. Update existing docs PR milestone (re-trigger path)
|
|
- name: Update existing docs PR
|
|
if: steps.existing.outputs.exists == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ steps.write-token.outputs.token }}
|
|
DOCS_PR: ${{ steps.existing.outputs.number }}
|
|
MILESTONE_NUMBER: ${{ steps.docs-milestone.outputs.number }}
|
|
run: |
|
|
gh pr edit "$DOCS_PR" \
|
|
--repo mattermost/docs \
|
|
--milestone "$MILESTONE_NUMBER"
|
|
|
|
# 12. Resolve final docs PR reference
|
|
- name: Resolve docs PR reference
|
|
id: resolve
|
|
run: |
|
|
if [ "${{ steps.existing.outputs.exists }}" == "true" ]; then
|
|
echo "number=${{ steps.existing.outputs.number }}" >> "$GITHUB_OUTPUT"
|
|
echo "url=${{ steps.existing.outputs.url }}" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "number=${{ steps.create-pr.outputs.number }}" >> "$GITHUB_OUTPUT"
|
|
echo "url=${{ steps.create-pr.outputs.url }}" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# 13. Add dev PR author as reviewer
|
|
- name: Add reviewer
|
|
env:
|
|
GH_TOKEN: ${{ steps.write-token.outputs.token }}
|
|
PR_AUTHOR: ${{ steps.vars.outputs.pr_author }}
|
|
run: |
|
|
gh pr edit "${{ steps.resolve.outputs.number }}" \
|
|
--repo mattermost/docs \
|
|
--add-reviewer "$PR_AUTHOR" \
|
|
|| echo "::warning::Could not add @${PR_AUTHOR} as reviewer" \
|
|
"- they may need docs repo access first."
|
|
|
|
# 14. Comment on the dev PR
|
|
- name: Comment on dev PR
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
PR_NUMBER: ${{ steps.vars.outputs.pr_number }}
|
|
SOURCE_REPO: ${{ steps.vars.outputs.source_repo }}
|
|
DOCS_PR_NUMBER: ${{ steps.resolve.outputs.number }}
|
|
DOCS_PR_URL: ${{ steps.resolve.outputs.url }}
|
|
run: |
|
|
if [ "${{ steps.existing.outputs.exists }}" == "true" ]; then
|
|
ACTION="refreshed at"
|
|
else
|
|
ACTION="opened at"
|
|
fi
|
|
|
|
DOCS_LINK="[mattermost/docs#${DOCS_PR_NUMBER}](${DOCS_PR_URL})"
|
|
COMMENT=$(printf '%s\n\n%s\n\n%s' \
|
|
"A documentation draft has been ${ACTION} ${DOCS_LINK}." \
|
|
"The docs PR is opened as a **draft**. Please review the AI-generated content, edit as needed, then mark it ready for review before merging." \
|
|
"The Docs/Needed label will be replaced with Docs/Done once the docs PR is merged or closed.")
|
|
|
|
gh pr comment "$PR_NUMBER" \
|
|
--repo "$SOURCE_REPO" \
|
|
--body "$COMMENT"
|