Files
mattermost/.github/workflows/docs-needed.yml
T
Amy BlaisandMattermost Build 891b59d459 Server: Create product documentation automation (#36282)
* Create docs process automation

Create an automation for the process of opening docs PRs for dev PRs with the "Docs/Neded" label. This same automation will be applied to mobile/desktop repos.

Associated PR in the docs repo: mattermost/docs#8844.

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

* Update docs-needed.yml

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
2026-06-29 11:56:32 +03:00

632 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 for
${{ github.repository }}#${{ github.event.pull_request.number || inputs.pr_number }}
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 &lt; &gt; &amp; 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 &lt; &gt;).
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"