Skip to content

fix: add members read permission for team membership checks #1

fix: add members read permission for team membership checks

fix: add members read permission for team membership checks #1

---

Check failure on line 1 in .github/workflows/deployment-guard.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/deployment-guard.yml

Invalid workflow file

(Line: 60, Col: 3): Unexpected value 'members'
name: Deployment Guard
on:
workflow_call:
inputs:
# Team-based bypass configuration
trusted_github_teams:
description: 'Comma-separated list of GitHub team slugs that can bypass all validations (e.g., platform-engineers,dotSysadmins)'
required: false
type: string
default: ''
# Validation toggles - enable/disable specific checks
enable_file_allowlist:
description: 'Enable file allowlist validation'
required: false
type: boolean
default: true
enable_image_only_check:
description: 'Enable image-only change validation (blocks if other fields changed)'
required: false
type: boolean
default: true
enable_image_validation:
description: 'Enable image repository, format, and version validation'
required: false
type: boolean
default: true
# File allowlist configuration
allowed_files_pattern:
description: 'Glob pattern for allowed files (e.g., kubernetes/dotcms/**/statefulset.yaml)'
required: false
type: string
default: '**/*'
# Image validation configuration
allowed_image_repositories:
description: 'Comma-separated list of allowed image repositories (e.g., dotcms/dotcms)'
required: false
type: string
default: ''
allowed_version_pattern:
description: 'Regex pattern for allowed version tags (default: any)'
required: false
type: string
default: '.*'
verify_image_existence:
description: 'Whether to verify image exists in registry'
required: false
type: boolean
default: false
permissions:
contents: read
pull-requests: write
# Required for checking team membership via GitHub API
# Note: This permission allows reading organization membership data
members: read
jobs:
check-team-membership:
name: Check Team Membership
runs-on: ubuntu-latest
outputs:
is-team-member: ${{ steps.check.outputs.is_member }}
pr-author: ${{ steps.check.outputs.author }}
should-bypass: ${{ steps.check.outputs.should_bypass }}
matched-team: ${{ steps.check.outputs.matched_team }}
steps:
- name: Check if PR author is in trusted teams
id: check
uses: actions/github-script@v7
with:
script: |
const trustedTeamsInput = '${{ inputs.trusted_github_teams }}';
// If no teams configured, don't bypass
if (!trustedTeamsInput) {
console.log('ℹ️ No trusted teams configured, running full validation');
core.setOutput('is_member', false);
core.setOutput('should_bypass', false);
core.setOutput('author', context.payload.pull_request.user.login);
core.setOutput('matched_team', '');
return;
}
const actor = context.payload.pull_request.user.login;
const org = context.repo.owner;
// Parse comma-separated list of teams
const trustedTeams = trustedTeamsInput.split(',').map(t => t.trim()).filter(t => t);
core.setOutput('author', actor);
console.log(`Checking if ${actor} is a member of any trusted team...`);
console.log(`Trusted teams: ${trustedTeams.join(', ')}`);
// Check membership in each team
let isMemberOfAny = false;
let matchedTeam = '';
for (const team of trustedTeams) {
try {
console.log(` Checking team: ${team}`);
const response = await github.rest.teams.getMembershipForUserInOrg({
org: org,
team_slug: team,
username: actor
});
if (response.data.state === 'active') {
isMemberOfAny = true;
matchedTeam = team;
console.log(` ✅ User ${actor} is a member of ${team}`);
break; // Found membership, no need to check other teams
} else {
console.log(` ℹ️ User ${actor} is not an active member of ${team}`);
}
} catch (error) {
if (error.status === 404) {
console.log(` ℹ️ User ${actor} is NOT a member of ${team} (or team does not exist)`);
} else {
console.log(` ⚠️ Error checking ${team}: ${error.message}`);
// Don't throw - continue checking other teams
}
}
}
// Set outputs
core.setOutput('is_member', isMemberOfAny);
core.setOutput('should_bypass', isMemberOfAny);
core.setOutput('matched_team', matchedTeam);
// Final result
if (isMemberOfAny) {
console.log('');
console.log(`✅ RESULT: User ${actor} is a member of ${matchedTeam}`);
console.log('🚀 ALL VALIDATIONS WILL BE BYPASSED - Team member is fully trusted');
} else {
console.log('');
console.log(`ℹ️ RESULT: User ${actor} is NOT a member of any trusted team`);
console.log('🔒 Full validation will be enforced');
}
validate-changed-files:
name: Validate Changed Files
runs-on: ubuntu-latest
needs: check-team-membership
if: |
inputs.enable_file_allowlist &&
needs.check-team-membership.outputs.should-bypass == 'false'
outputs:
allowed-files-check: ${{ steps.check-files.outputs.result }}
changed-files: ${{ steps.get-files.outputs.files }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed files
id: get-files
run: |
# Get list of changed files in the PR
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_SHA="origin/${{ github.event.repository.default_branch }}"
HEAD_SHA="HEAD"
fi
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -E '\.ya?ml$' || true)
if [ -z "$CHANGED_FILES" ]; then
echo "No YAML files changed"
echo "files=" >> "$GITHUB_OUTPUT"
else
echo "Changed files:"
echo "$CHANGED_FILES"
# Convert to JSON array for output
FILES_JSON=$(echo "$CHANGED_FILES" | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "files=$FILES_JSON" >> "$GITHUB_OUTPUT"
fi
- name: Check files against allowlist
id: check-files
run: |
CHANGED_FILES='${{ steps.get-files.outputs.files }}'
ALLOWED_PATTERN='${{ inputs.allowed_files_pattern }}'
if [ "$CHANGED_FILES" = "" ] || [ "$CHANGED_FILES" = "[]" ]; then
echo "No files to validate"
echo "result=pass" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Validating files against pattern: $ALLOWED_PATTERN"
# Convert glob pattern to regex
# First handle special case: .ya?ml → \.(yaml|yml)
# Then replace **/ with placeholder (as a unit, including the slash)
# Then replace remaining * with [^/]* (match any character except /)
# Then replace placeholder with ([^/]+/)* (match zero or more path segments)
# Note: Using # as delimiter to avoid conflicts with |
# Note: In grep -E, | should NOT be escaped (it's a regex alternation operator)
PATTERN_REGEX=$(echo "$ALLOWED_PATTERN" | sed 's#\.ya?ml#\\.(yaml|yml)#g' | sed 's#\*\*/#__DOUBLESTAR__#g' | sed 's#\*#[^/]*#g' | sed 's#__DOUBLESTAR__#([^/]+/)*#g')
echo "$CHANGED_FILES" | jq -r '.[]' | while IFS= read -r file; do
if ! echo "$file" | grep -qE "^${PATTERN_REGEX}$"; then
echo "❌ File not allowed: $file"
echo "$file" >> /tmp/disallowed_files.txt
else
echo "✅ File allowed: $file"
fi
done
if [ -f /tmp/disallowed_files.txt ]; then
echo "result=fail" >> "$GITHUB_OUTPUT"
echo "❌ Some files are not in the allowlist"
exit 1
else
echo "result=pass" >> "$GITHUB_OUTPUT"
echo "✅ All files are in the allowlist"
fi
validate-image-only-changed:
name: Validate Only Image Changed
runs-on: ubuntu-latest
needs: [check-team-membership, validate-changed-files]
if: |
always() &&
inputs.enable_image_only_check &&
needs.check-team-membership.outputs.should-bypass == 'false' &&
(inputs.enable_file_allowlist == false || needs.validate-changed-files.outputs.allowed-files-check == 'pass')
outputs:
image-only-check: ${{ steps.check-changes.outputs.result }}
new-images: ${{ steps.check-changes.outputs.images }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install yq
run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Check only image field changed
id: check-changes
run: |
CHANGED_FILES='${{ needs.validate-changed-files.outputs.changed-files }}'
if [ "$CHANGED_FILES" = "" ] || [ "$CHANGED_FILES" = "[]" ]; then
echo "No files to validate"
echo "result=pass" >> "$GITHUB_OUTPUT"
echo "images=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
BASE_SHA="${{ github.event.pull_request.base.sha }}"
echo "$CHANGED_FILES" | jq -r '.[]' | while IFS= read -r file; do
echo "=================================================="
echo "Validating: $file"
echo "=================================================="
# Get old and new YAML content
OLD_YAML=$(git show "$BASE_SHA:$file" 2>/dev/null || echo "")
NEW_YAML=$(cat "$file")
if [ -z "$OLD_YAML" ]; then
echo "⚠️ File is new, skipping comparison"
continue
fi
# Extract the main dotCMS container image
OLD_IMAGE=$(echo "$OLD_YAML" | yq '.spec.template.spec.containers[] | select(.name == "dotcms") | .image' 2>/dev/null || echo "")
NEW_IMAGE=$(echo "$NEW_YAML" | yq '.spec.template.spec.containers[] | select(.name == "dotcms") | .image' 2>/dev/null || echo "")
if [ -z "$OLD_IMAGE" ] || [ -z "$NEW_IMAGE" ]; then
echo "❌ Could not extract dotCMS container image"
echo "false" > /tmp/validation_failed.txt
continue
fi
echo "Old image: $OLD_IMAGE"
echo "New image: $NEW_IMAGE"
# Normalize both YAMLs by replacing the dotCMS image with placeholder
OLD_NORMALIZED=$(echo "$OLD_YAML" | yq '(.spec.template.spec.containers[] | select(.name == "dotcms") | .image) = "PLACEHOLDER"')
NEW_NORMALIZED=$(echo "$NEW_YAML" | yq '(.spec.template.spec.containers[] | select(.name == "dotcms") | .image) = "PLACEHOLDER"')
# Compare normalized YAMLs
if [ "$OLD_NORMALIZED" != "$NEW_NORMALIZED" ]; then
echo "❌ Changes detected beyond image field in: $file"
echo ""
echo "Diff of normalized files:"
diff <(echo "$OLD_NORMALIZED") <(echo "$NEW_NORMALIZED") || true
echo "false" > /tmp/validation_failed.txt
else
echo "✅ Only image changed: $OLD_IMAGE → $NEW_IMAGE"
echo "$NEW_IMAGE" >> /tmp/new_images.txt
fi
echo ""
done
if [ -f /tmp/validation_failed.txt ]; then
echo "result=fail" >> "$GITHUB_OUTPUT"
echo "images=[]" >> "$GITHUB_OUTPUT"
exit 1
else
if [ -f /tmp/new_images.txt ]; then
IMAGES_JSON=$(jq -R -s -c 'split("\n") | map(select(length > 0)) | unique' < /tmp/new_images.txt)
echo "images=$IMAGES_JSON" >> "$GITHUB_OUTPUT"
else
echo "images=[]" >> "$GITHUB_OUTPUT"
fi
echo "result=pass" >> "$GITHUB_OUTPUT"
echo "✅ All files have only image field changes"
fi
validate-images:
name: Validate Images
runs-on: ubuntu-latest
needs: [check-team-membership, validate-image-only-changed]
if: |
always() &&
inputs.enable_image_validation &&
needs.check-team-membership.outputs.should-bypass == 'false' &&
(inputs.enable_image_only_check == false || needs.validate-image-only-changed.outputs.image-only-check == 'pass')
outputs:
image-validation: ${{ steps.validate.outputs.result }}
steps:
- name: Validate image format, repository, tag, and existence
id: validate
run: |
NEW_IMAGES='${{ needs.validate-image-only-changed.outputs.new-images }}'
ALLOWED_REPOS='${{ inputs.allowed_image_repositories }}'
VERSION_PATTERN='${{ inputs.allowed_version_pattern }}'
VERIFY_EXISTENCE='${{ inputs.verify_image_existence }}'
if [ "$NEW_IMAGES" = "" ] || [ "$NEW_IMAGES" = "[]" ]; then
echo "No new images to validate"
echo "result=pass" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$NEW_IMAGES" | jq -r '.[]' | while IFS= read -r image; do
echo "=================================================="
echo "Validating image: $image"
echo "=================================================="
# 1. Validate format (repo/name:tag)
if ! [[ "$image" =~ ^[a-zA-Z0-9_/-]+:[a-zA-Z0-9._-]+$ ]]; then
echo "❌ Invalid image format: $image"
echo "false" > /tmp/validation_failed.txt
continue
fi
echo "✅ Image format is valid"
# 2. Extract repository and tag
REPO="${image%:*}"
TAG="${image##*:}"
echo " Repository: $REPO"
echo " Tag: $TAG"
# 3. Check repository is in allowlist (if configured)
if [ -n "$ALLOWED_REPOS" ]; then
REPO_ALLOWED=false
IFS=',' read -ra ALLOWED <<< "$ALLOWED_REPOS"
for allowed_repo in "${ALLOWED[@]}"; do
# Trim whitespace
allowed_repo=$(echo "$allowed_repo" | xargs)
if [[ "$REPO" == "$allowed_repo" ]]; then
REPO_ALLOWED=true
break
fi
done
if [ "$REPO_ALLOWED" = false ]; then
echo "❌ Repository not allowed: $REPO"
echo " Allowed repositories: $ALLOWED_REPOS"
echo "false" > /tmp/validation_failed.txt
continue
fi
echo "✅ Repository is allowed"
else
echo "ℹ️ Repository validation skipped (no allowlist configured)"
fi
# 4. Validate tag matches version pattern
if ! [[ "$TAG" =~ $VERSION_PATTERN ]]; then
echo "❌ Tag does not match version pattern: $TAG"
echo " Expected pattern: $VERSION_PATTERN"
echo " This typically means: date-based versions YY.MM.DD where YY >= 25"
echo "false" > /tmp/validation_failed.txt
continue
fi
echo "✅ Tag matches version pattern"
# 5. Verify image exists in registry (optional)
if [ "$VERIFY_EXISTENCE" = "true" ]; then
echo "Verifying image exists in registry..."
if docker manifest inspect "$image" >/dev/null 2>&1; then
echo "✅ Image exists in registry"
else
echo "❌ Image does not exist in registry: $image"
echo "false" > /tmp/validation_failed.txt
continue
fi
fi
echo "✅ Image validated successfully: $image"
echo ""
done
if [ -f /tmp/validation_failed.txt ]; then
echo "result=fail" >> "$GITHUB_OUTPUT"
exit 1
else
echo "result=pass" >> "$GITHUB_OUTPUT"
echo "✅ All images validated successfully"
fi
finalize:
name: Final Status
runs-on: ubuntu-latest
needs: [check-team-membership, validate-changed-files, validate-image-only-changed, validate-images]
if: always()
steps:
- name: Check all validations
run: |
SHOULD_BYPASS="${{ needs.check-team-membership.outputs.should-bypass }}"
PR_AUTHOR="${{ needs.check-team-membership.outputs.pr-author }}"
MATCHED_TEAM="${{ needs.check-team-membership.outputs.matched-team }}"
echo "=================================================="
echo "Deployment Guard - Validation Summary"
echo "=================================================="
# Check if team member bypass is active
if [ "$SHOULD_BYPASS" = "true" ]; then
echo "✅ VALIDATION BYPASSED"
echo ""
echo "PR Author: $PR_AUTHOR"
echo "Team Member: YES"
echo "Matched Team: $MATCHED_TEAM"
echo ""
echo "🚀 This PR author is a member of a trusted team."
echo " All validations have been bypassed."
echo " Changes are approved based on team trust."
echo ""
echo "=================================================="
exit 0
fi
# If not bypassed, check validation results
echo "PR Author: $PR_AUTHOR"
echo "Team Member: NO"
echo "Status: Running full validation"
echo ""
FILES_CHECK="${{ needs.validate-changed-files.outputs.allowed-files-check }}"
IMAGE_ONLY_CHECK="${{ needs.validate-image-only-changed.outputs.image-only-check }}"
IMAGE_VALIDATION="${{ needs.validate-images.outputs.image-validation }}"
ENABLE_FILE_ALLOWLIST="${{ inputs.enable_file_allowlist }}"
ENABLE_IMAGE_ONLY="${{ inputs.enable_image_only_check }}"
ENABLE_IMAGE_VALIDATION="${{ inputs.enable_image_validation }}"
echo "Enabled checks:"
echo " - File allowlist: $ENABLE_FILE_ALLOWLIST"
echo " - Image-only changes: $ENABLE_IMAGE_ONLY"
echo " - Image validation: $ENABLE_IMAGE_VALIDATION"
echo ""
echo "Results:"
if [ "$ENABLE_FILE_ALLOWLIST" = "true" ]; then
echo " - Files allowlist check: ${FILES_CHECK:-skipped}"
fi
if [ "$ENABLE_IMAGE_ONLY" = "true" ]; then
echo " - Image-only change check: ${IMAGE_ONLY_CHECK:-skipped}"
fi
if [ "$ENABLE_IMAGE_VALIDATION" = "true" ]; then
echo " - Image validation: ${IMAGE_VALIDATION:-skipped}"
fi
echo "=================================================="
# Check file allowlist (if enabled)
if [ "$ENABLE_FILE_ALLOWLIST" = "true" ] && [ "$FILES_CHECK" != "pass" ]; then
echo "❌ BLOCKED: Modified files are not in the allowlist"
echo ""
echo "Only the following files can be modified:"
echo " - ${{ inputs.allowed_files_pattern }}"
echo ""
echo "Please ensure you're only modifying allowed files."
exit 1
fi
# Check image-only changes (if enabled)
if [ "$ENABLE_IMAGE_ONLY" = "true" ] && [ "$IMAGE_ONLY_CHECK" != "pass" ]; then
echo "❌ BLOCKED: Changes detected beyond the image field"
echo ""
echo "Only the container image field can be modified."
echo "No other changes are allowed (resources, env vars, volumes, etc.)"
echo ""
echo "Please revert any non-image changes and try again."
exit 1
fi
# Check image validation (if enabled)
if [ "$ENABLE_IMAGE_VALIDATION" = "true" ] && [ "$IMAGE_VALIDATION" != "pass" ]; then
echo "❌ BLOCKED: Image validation failed"
echo ""
echo "Image validation requirements:"
if [ -n "${{ inputs.allowed_image_repositories }}" ]; then
echo " - Repository must be: ${{ inputs.allowed_image_repositories }}"
fi
echo " - Tag must match pattern: ${{ inputs.allowed_version_pattern }}"
if [ "${{ inputs.verify_image_existence }}" = "true" ]; then
echo " - Image must exist in the registry"
fi
echo ""
echo "Please use a valid image and try again."
exit 1
fi
echo "✅ All enabled validations passed!"
# Show validated images if available
NEW_IMAGES='${{ needs.validate-image-only-changed.outputs.new-images }}'
if [ -n "$NEW_IMAGES" ] && [ "$NEW_IMAGES" != "[]" ]; then
echo ""
echo "The following images were validated and approved:"
echo "$NEW_IMAGES" | jq -r '.[]' 2>/dev/null | while IFS= read -r img; do
echo " ✅ $img"
done
fi
- name: Create PR comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const shouldBypass = '${{ needs.check-team-membership.outputs.should-bypass }}' === 'true';
const isTeamMember = '${{ needs.check-team-membership.outputs.is-team-member }}' === 'true';
const prAuthor = '${{ needs.check-team-membership.outputs.pr-author }}';
const matchedTeam = '${{ needs.check-team-membership.outputs.matched-team }}';
const trustedTeams = '${{ inputs.trusted_github_teams }}';
let commentBody = '## 🛡️ Deployment Guard Validation\n\n';
// Team membership status
commentBody += '### 👤 Author Verification\n';
if (shouldBypass && isTeamMember) {
commentBody += `✅ **TRUSTED TEAM MEMBER** - @${prAuthor}\n\n`;
commentBody += `- Member of: \`${matchedTeam}\`\n`;
if (trustedTeams) {
commentBody += `- Trusted teams: \`${trustedTeams}\`\n`;
}
commentBody += `- Status: 🚀 **All validations bypassed**\n`;
commentBody += `- Reason: Team members are fully trusted\n\n`;
commentBody += '---\n\n';
commentBody += '### ✅ Validation Complete\n\n';
commentBody += 'This PR was automatically approved based on team membership.\n\n';
commentBody += 'No additional validation was required.\n';
} else {
commentBody += `ℹ️ **NON-TEAM MEMBER** - @${prAuthor}\n\n`;
commentBody += `- Status: 🔒 **Full validation enforced**\n`;
commentBody += `- All deployment rules must be satisfied\n\n`;
const filesCheck = '${{ needs.validate-changed-files.outputs.allowed-files-check }}';
const imageOnlyCheck = '${{ needs.validate-image-only-changed.outputs.image-only-check }}';
const imageValidation = '${{ needs.validate-images.outputs.image-validation }}';
const newImages = JSON.parse('${{ needs.validate-image-only-changed.outputs.new-images }}' || '[]');
// Files check
commentBody += '### 📁 File Allowlist Check\n';
if (filesCheck === 'pass') {
commentBody += '✅ **PASSED** - All modified files are in the allowlist\n\n';
} else if (filesCheck === 'fail') {
commentBody += '❌ **FAILED** - Some files are not in the allowlist\n\n';
commentBody += `Only files matching \`${{ inputs.allowed_files_pattern }}\` can be modified.\n\n`;
} else {
commentBody += 'ℹ️ **SKIPPED** - File allowlist check not enabled\n\n';
}
// Image-only check
commentBody += '### 🔄 Image-Only Change Check\n';
if (imageOnlyCheck === 'pass') {
commentBody += '✅ **PASSED** - Only image field was modified\n\n';
} else if (imageOnlyCheck === 'fail') {
commentBody += '❌ **FAILED** - Changes detected beyond image field\n\n';
commentBody += 'Only the dotCMS container image can be modified. No other fields are allowed.\n\n';
} else {
commentBody += 'ℹ️ **SKIPPED** - Image-only check not enabled\n\n';
}
// Image validation
commentBody += '### 🐳 Image Validation\n';
if (imageValidation === 'pass') {
commentBody += '✅ **PASSED** - All images validated successfully\n\n';
if (newImages.length > 0) {
commentBody += '**Validated images:**\n';
newImages.forEach(img => {
commentBody += `- \`${img}\`\n`;
});
}
} else if (imageValidation === 'fail') {
commentBody += '❌ **FAILED** - Image validation failed\n\n';
commentBody += `**Requirements:**\n`;
if ('${{ inputs.allowed_image_repositories }}') {
commentBody += `- Repository must be: \`${{ inputs.allowed_image_repositories }}\`\n`;
}
commentBody += `- Tag must match pattern: \`${{ inputs.allowed_version_pattern }}\`\n`;
if ('${{ inputs.verify_image_existence }}' === 'true') {
commentBody += `- Image must exist in the registry\n`;
}
commentBody += '\n';
} else {
commentBody += 'ℹ️ **SKIPPED** - Image validation not enabled\n\n';
}
// Final status
commentBody += '\n---\n\n';
if (filesCheck === 'pass' && imageOnlyCheck === 'pass' && imageValidation === 'pass') {
commentBody += '### ✅ All validations passed!\n\n';
commentBody += 'This PR meets all deployment requirements.\n';
} else {
commentBody += '### ❌ Validation failed\n\n';
commentBody += 'Please address the issues above and push new commits to re-trigger validation.\n';
}
}
// Post comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody
});