diff --git a/.github/workflows/docs-needed.yml b/.github/workflows/docs-needed.yml index 857a64d7527..853a9008c3b 100644 --- a/.github/workflows/docs-needed.yml +++ b/.github/workflows/docs-needed.yml @@ -1,4 +1,3 @@ ---- # .github/workflows/docs-needed.yml # # Place this file in ALL of: @@ -35,9 +34,9 @@ # 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] @@ -52,7 +51,7 @@ on: (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). @@ -61,12 +60,10 @@ 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 }} + 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. @@ -79,7 +76,7 @@ jobs: 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 @@ -95,7 +92,7 @@ jobs: 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 @@ -103,7 +100,7 @@ jobs: 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 @@ -136,7 +133,7 @@ jobs: 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: @@ -146,7 +143,7 @@ jobs: 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 @@ -161,7 +158,7 @@ jobs: 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" @@ -171,7 +168,7 @@ jobs: 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"). @@ -193,7 +190,7 @@ jobs: 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 @@ -203,7 +200,7 @@ jobs: 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" \ @@ -211,21 +208,21 @@ jobs: 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" \ @@ -233,16 +230,16 @@ jobs: 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." @@ -252,7 +249,7 @@ jobs: "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 @@ -266,7 +263,7 @@ jobs: --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" @@ -276,7 +273,7 @@ jobs: 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 @@ -288,13 +285,13 @@ jobs: 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: @@ -308,7 +305,7 @@ jobs: 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: @@ -318,10 +315,10 @@ jobs: 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 @@ -353,7 +350,7 @@ jobs: 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. ) to break out @@ -362,16 +359,16 @@ jobs: 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 "" @@ -389,7 +386,7 @@ jobs: cat /tmp/pr-diff-safe.txt echo "" } > /tmp/user_message.txt - + PAYLOAD=$(jq -n \ --rawfile system /tmp/system_prompt.txt \ --rawfile content /tmp/user_message.txt \ @@ -402,7 +399,7 @@ jobs: 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 \ @@ -414,9 +411,9 @@ jobs: 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. @@ -429,7 +426,7 @@ jobs: 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. @@ -443,7 +440,7 @@ jobs: "Possible prompt injection. Aborting." exit 1 fi - + # 8. Write docs file - name: Write docs file working-directory: docs-repo @@ -455,13 +452,13 @@ jobs: 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' @@ -471,7 +468,7 @@ jobs: printf -- '---\n\n' cat /tmp/generated-docs.md } > "$DOCS_FILE" - + # 9. Commit and push - name: Commit and push working-directory: docs-repo @@ -500,7 +497,7 @@ jobs: | git commit -F - git push origin "$BRANCH" fi - + # 10. Sync milestone to mattermost/docs - name: Sync milestone id: docs-milestone @@ -515,7 +512,7 @@ jobs: --jq '.[] | select(.title == $title) | .number' \ --arg title "$MILESTONE_TITLE" \ | head -1) - + if [ -z "$NUM" ]; then NUM=$(gh api repos/mattermost/docs/milestones \ --method POST \ @@ -523,9 +520,9 @@ jobs: --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 @@ -551,10 +548,10 @@ jobs: "| **Dev reviewer** | @${PR_AUTHOR} |" \ "| **Milestone** | ${MILESTONE_TITLE} |" \ "") - + # 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 \ @@ -565,10 +562,10 @@ jobs: --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' @@ -580,7 +577,7 @@ jobs: gh pr edit "$DOCS_PR" \ --repo mattermost/docs \ --milestone "$MILESTONE_NUMBER" - + # 12. Resolve final docs PR reference - name: Resolve docs PR reference id: resolve @@ -592,7 +589,7 @@ jobs: 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: @@ -604,7 +601,7 @@ jobs: --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: @@ -619,13 +616,13 @@ jobs: 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"