Add auto-leave-spaces playbook #47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Validate then publish to integration | |
| on: | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| pull-requests: write | |
| contents: read | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| folder_count: ${{ steps.changed.outputs.count }} | |
| folders: ${{ steps.changed.outputs.folders }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Get changed Playbook folders | |
| id: changed | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const path = require('path'); | |
| const { playbookFoldersFromFilenames } = require(path.join(process.env.GITHUB_WORKSPACE, 'scripts/ci/playbook-folders-from-pr-files.js')); | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number, | |
| per_page: 100 | |
| }); | |
| const { folders, count } = playbookFoldersFromFilenames(files.map((f) => f.filename)); | |
| core.setOutput('folders', folders.join('\n')); | |
| core.setOutput('count', count.toString()); | |
| - name: Enforce single playbook per PR | |
| if: steps.changed.outputs.count != '0' | |
| run: | | |
| COUNT="${{ steps.changed.outputs.count }}" | |
| if [ "$COUNT" -gt 1 ]; then | |
| echo "::error::PRs must touch only one playbook folder. This PR changes $COUNT playbooks. Split into separate PRs." | |
| echo "Changed: ${{ steps.changed.outputs.folders }}" | |
| exit 1 | |
| fi | |
| - name: Setup Node.js for validation scripts | |
| if: steps.changed.outputs.count != '0' | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: 'npm' | |
| - name: Install npm dependencies | |
| if: steps.changed.outputs.count != '0' | |
| run: npm ci | |
| - name: Validate Playbooks | |
| id: validate | |
| run: | | |
| FOLDERS="${{ steps.changed.outputs.folders }}" | |
| if [ -z "$FOLDERS" ]; then | |
| echo "No Playbook folders changed. Skipping validation." | |
| echo "result=pass" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| FAILED=0 | |
| REPORT="" | |
| while IFS= read -r FOLDER; do | |
| [ -z "$FOLDER" ] && continue | |
| PART=$(bash scripts/ci/validate-playbook-folder.sh "$FOLDER") | |
| EC=$? | |
| REPORT="${REPORT}${PART}\n" | |
| if [ "$EC" -ne 0 ]; then | |
| FAILED=1 | |
| fi | |
| done <<< "$FOLDERS" | |
| echo "result=$([ "$FAILED" -eq 0 ] && echo 'pass' || echo 'fail')" >> "$GITHUB_OUTPUT" | |
| echo -e "# Playbook Validation Results\n\n$REPORT" > validation-report.md | |
| echo "Validation complete. Result: $([ "$FAILED" -eq 0 ] && echo 'pass' || echo 'fail')" | |
| - name: Post validation results | |
| uses: actions/github-script@v8 | |
| if: steps.changed.outputs.count != '0' | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const result = '${{ steps.validate.outputs.result }}'; | |
| const body = fs.readFileSync('validation-report.md', 'utf8'); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Playbook Validation Results') | |
| ); | |
| const commentBody = body + '\n\n---\n*Validation ' + (result === 'pass' ? 'passed' : 'failed') + '.*'; | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: commentBody | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: commentBody | |
| }); | |
| } | |
| - name: Extract Playbook Details from APPHUB.yaml | |
| id: playbook_details | |
| if: steps.changed.outputs.count != '0' | |
| run: | | |
| FOLDERS="${{ steps.changed.outputs.folders }}" | |
| yaml_array_items() { awk -v key="$1" '$0 ~ "^" key ":"{f=1;next} f{if($0~/^[a-zA-Z_][a-zA-Z0-9_]*:/)exit; if($0~/^[[:space:]]+-[[:space:]]/)print}' "$2"; } | |
| yaml_scalar() { grep -E "^${2}:" "$1" | head -1 | sed -E "s/^${2}:[[:space:]]*['\"]?([^'\"]*)['\"]?[[:space:]]*(#.*)?$/\1/" | tr -d '\r'; } | |
| FOLDER=$(echo "$FOLDERS" | head -1) | |
| [ -z "$FOLDER" ] || [ ! -f "${FOLDER}/APPHUB.yaml" ] && exit 0 | |
| TITLE=$(yaml_scalar "${FOLDER}/APPHUB.yaml" "title" || echo "—") | |
| TOOL=$(yaml_scalar "${FOLDER}/APPHUB.yaml" "third_party_tool" || echo "—") | |
| TIME=$(yaml_scalar "${FOLDER}/APPHUB.yaml" "estimated_implementation_time" || echo "—") | |
| CATS="" | |
| while IFS= read -r line; do | |
| [ -z "$line" ] && continue | |
| C=$(echo "$line" | sed -E "s/^[[:space:]]*-[[:space:]]*['\"]?([^'\"]*)['\"]?.*/\1/" | tr -d ' ') | |
| [ -n "$C" ] && CATS="${CATS}${CATS:+, }${C}" | |
| done <<< "$(yaml_array_items "categories" "${FOLDER}/APPHUB.yaml")" | |
| [ -z "$CATS" ] && CATS="—" | |
| { | |
| echo "# Playbook Details (from APPHUB.yaml)" | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "|-------|-------|" | |
| echo "| **Playbook title** | ${TITLE} |" | |
| echo "| **Third-party tool** | ${TOOL} |" | |
| echo "| **Categories** | ${CATS} |" | |
| echo "| **Estimated implementation time** | ${TIME} |" | |
| } > playbook-details.md | |
| - name: Post Playbook Details comment | |
| if: steps.changed.outputs.count != '0' && steps.playbook_details.outcome == 'success' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| let body; | |
| try { | |
| body = fs.readFileSync('playbook-details.md', 'utf8'); | |
| } catch (e) { | |
| body = null; | |
| } | |
| if (!body || body.trim().length === 0) return; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Playbook Details (from APPHUB.yaml)') | |
| ); | |
| const commentBody = body + '\n\n---\n*Auto-generated from APPHUB.yaml.*'; | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: commentBody | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: commentBody | |
| }); | |
| } | |
| - name: Fail if validation failed | |
| if: steps.changed.outputs.count != '0' | |
| run: | | |
| if [ "${{ steps.validate.outputs.result }}" = "fail" ]; then | |
| echo "Validation failed. Fix the issues above and push again." | |
| exit 1 | |
| fi | |
| publish-integration: | |
| needs: validate | |
| if: needs.validate.outputs.folder_count != '0' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Check for friendly_id changes | |
| id: friendly_id_check | |
| run: | | |
| BASE="${{ github.event.pull_request.base.sha }}" | |
| FOLDERS="${{ needs.validate.outputs.folders }}" | |
| REPORT="" | |
| HAS_CHANGES="false" | |
| while IFS= read -r FOLDER; do | |
| [ -z "$FOLDER" ] && continue | |
| [ ! -f "${FOLDER}/APPHUB.yaml" ] && continue | |
| OLD_YAML=$(git show "${BASE}:${FOLDER}/APPHUB.yaml" 2>/dev/null) || true | |
| if [ -z "$OLD_YAML" ]; then | |
| continue | |
| fi | |
| OLD_ID=$(echo "$OLD_YAML" | grep -E "^friendly_id:" | sed -E "s/^friendly_id:[[:space:]]*['\"]?([^'\"]*)['\"]?.*/\1/" | tr -d ' ') | |
| NEW_ID=$(grep -E "^friendly_id:" "${FOLDER}/APPHUB.yaml" | sed -E "s/^friendly_id:[[:space:]]*['\"]?([^'\"]*)['\"]?.*/\1/" | tr -d ' ') | |
| if [ -n "$OLD_ID" ] && [ -n "$NEW_ID" ] && [ "$OLD_ID" != "$NEW_ID" ]; then | |
| REPORT="${REPORT}- **${FOLDER}**: \`${OLD_ID}\` → \`${NEW_ID}\`\n" | |
| HAS_CHANGES="true" | |
| echo "::warning::friendly_id changed in ${FOLDER}: ${OLD_ID} → ${NEW_ID}. Old Contentstack entry may be orphaned." | |
| fi | |
| done <<< "$FOLDERS" | |
| if [ "$HAS_CHANGES" = "true" ]; then | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| echo -e "# Contentstack: friendly_id changed\n\n\`friendly_id\` was changed in the following playbook(s). The previous Contentstack entry may be orphaned. Consider unpublishing or deleting it manually in Contentstack.\n\n${REPORT}" > friendly-id-changes.md | |
| else | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Publish playbooks to Integration | |
| id: publish | |
| env: | |
| CONTENTSTACK_MANAGEMENT_TOKEN: ${{ secrets.CONTENTSTACK_MANAGEMENT_TOKEN }} | |
| CMS_API_KEY: ${{ secrets.CMS_API_KEY }} | |
| PLAYBOOK_PREVIEW_PR_URL: ${{ github.event.pull_request.html_url }} | |
| run: | | |
| FOLDERS="${{ needs.validate.outputs.folders }}" | |
| if [ -z "$FOLDERS" ]; then | |
| echo "No Playbook folders changed. Skipping publish." | |
| echo "count=0" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| rm -f published.jsonl | |
| PUBLISHED=0 | |
| while IFS= read -r FOLDER; do | |
| [ -z "$FOLDER" ] && continue | |
| if [ ! -f "${FOLDER}/APPHUB.yaml" ]; then | |
| echo "Skipping ${FOLDER}: no APPHUB.yaml" | |
| continue | |
| fi | |
| echo "Publishing ${FOLDER} to integration..." | |
| node scripts/publish-playbook.js "${FOLDER}" --env integration --output published.jsonl | |
| PUBLISHED=$((PUBLISHED + 1)) | |
| done <<< "$FOLDERS" | |
| echo "count=$PUBLISHED" >> "$GITHUB_OUTPUT" | |
| if [ "$PUBLISHED" -eq 0 ]; then | |
| echo "No playbooks with APPHUB.yaml were changed." | |
| else | |
| echo "Published ${PUBLISHED} playbook(s) to integration." | |
| fi | |
| - name: Comment on PR when friendly_id changed | |
| if: steps.friendly_id_check.outputs.has_changes == 'true' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const body = fs.readFileSync('friendly-id-changes.md', 'utf8'); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Contentstack: friendly_id changed') | |
| ); | |
| const commentBody = body + '\n\n---\n*This PR changes `friendly_id` in one or more playbooks. The old Contentstack entry will remain; a new entry was created.*'; | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: commentBody | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: commentBody | |
| }); | |
| } | |
| - name: Comment on PR when publish fails | |
| if: failure() | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; | |
| const body = [ | |
| '# Contentstack: Publish to integration failed', | |
| '', | |
| `The publish step failed. Check the [workflow run logs](${runUrl}) for details.`, | |
| '', | |
| 'Common causes:', | |
| '- Invalid or missing `APPHUB.yaml` (e.g. category/product_type slug not found in Contentstack)', | |
| '- Contentstack API error (auth, rate limit, or service issue)', | |
| '- Network or connectivity issue', | |
| '', | |
| '---', | |
| '*Fix the issue and push again to retry.*' | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body | |
| }); | |
| - name: Comment on PR with publication links | |
| if: steps.publish.outputs.count != '0' | |
| env: | |
| CMS_STACK_UID: ${{ secrets.CMS_STACK_UID }} | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const CMS_STACK_UID = process.env.CMS_STACK_UID || 'bltd14fd2a03236233f'; | |
| const APP_HUB_INTEGRATION = 'https://app-hub-intb.ciscospark.com/applications'; | |
| const CS_ENTRY_BASE = `https://app.contentstack.com/#!/stack/${CMS_STACK_UID}/content-type/webex_playbook_app/en-us/entry`; | |
| let body = '# Contentstack: Published to integration\n\n'; | |
| const lines = fs.readFileSync('published.jsonl', 'utf8').trim().split('\n').filter(Boolean); | |
| for (const line of lines) { | |
| const { friendly_id, entry_uid } = JSON.parse(line); | |
| body += `## \`${friendly_id}\`\n`; | |
| body += `- **Integration**: [View in App Hub](${APP_HUB_INTEGRATION}/${friendly_id})\n`; | |
| body += `- **Contentstack**: [Edit entry](${CS_ENTRY_BASE}/${entry_uid}/edit?branch=main)\n\n`; | |
| } | |
| body += '---\n*Published playbooks are available in the integration environment.*'; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Contentstack: Published to integration') | |
| ); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body | |
| }); | |
| } |