Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a34f8ef

Browse files
committed
feat(ci): enforce validation before preview for showcase PRs
- Showcase PRs go through a single pipeline: validate → preview. Preview cannot deploy without passing validation first. - Extract preview build+deploy into reusable workflow - preview-website.yml no longer triggers on community/** changes, preventing preview deployment with tampered website code - deploy-website.yml keeps community/** trigger (merged = safe)
1 parent 7409b02 commit a34f8ef

3 files changed

Lines changed: 171 additions & 27 deletions

File tree

.github/workflows/community-validation.yml

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,42 @@
1-
name: Community Profile Validation
1+
name: Showcase Validation & Preview
22

33
on:
4-
pull_request:
5-
types: [labeled, synchronize]
4+
pull_request_target:
5+
types: [labeled, synchronize, opened, reopened, closed]
66
paths:
77
- "community/**"
88

9-
# Read-only permissions — no secrets access
9+
# Top-level: read-only. Individual jobs escalate as needed.
1010
permissions:
1111
contents: read
1212
pull-requests: read
1313

14+
concurrency:
15+
group: showcase-${{ github.event.pull_request.number }}
16+
cancel-in-progress: true
17+
1418
jobs:
19+
# ── Step 1: Validate the community submission ──
1520
validate:
16-
if: contains(github.event.pull_request.labels.*.name, 'showcase')
21+
if: >-
22+
github.event.action != 'closed' &&
23+
contains(github.event.pull_request.labels.*.name, 'showcase')
1724
runs-on: ubuntu-latest
1825
# Fork PRs require manual approval via the 'showcase' environment
1926
environment: ${{ github.event.pull_request.head.repo.full_name != github.repository && 'showcase' || '' }}
27+
outputs:
28+
folder: ${{ steps.validate.outputs.folder }}
2029
steps:
2130
- uses: actions/checkout@v6
2231
with:
32+
ref: ${{ github.event.pull_request.head.sha }}
2333
fetch-depth: 0
2434

2535
- name: Get changed files
2636
id: changed
2737
run: |
28-
BASE=${{ github.event.pull_request.base.sha }}
29-
HEAD=${{ github.event.pull_request.head.sha }}
30-
FILES=$(git diff --name-only "$BASE" "$HEAD")
38+
git fetch origin ${{ github.event.pull_request.base.sha }} --depth=1
39+
FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }})
3140
echo "files<<EOF" >> "$GITHUB_OUTPUT"
3241
echo "$FILES" >> "$GITHUB_OUTPUT"
3342
echo "EOF" >> "$GITHUB_OUTPUT"
@@ -92,38 +101,31 @@ jobs:
92101
if [ -f "community/$FOLDER/README.md" ]; then
93102
README="community/$FOLDER/README.md"
94103
95-
# Check frontmatter exists
96104
if ! head -1 "$README" | grep -q '^---$'; then
97105
ERRORS+=("README.md missing frontmatter (must start with ---)")
98106
else
99-
# Extract frontmatter
100107
FRONTMATTER=$(sed -n '/^---$/,/^---$/p' "$README" | sed '1d;$d')
101108
102-
# Required fields
103109
for FIELD in author description category tags profiles; do
104110
if ! echo "$FRONTMATTER" | grep -q "^${FIELD}:"; then
105111
ERRORS+=("README.md frontmatter missing required field: $FIELD")
106112
fi
107113
done
108114
109-
# Author in frontmatter must match folder prefix
110115
AUTHOR=$(echo "$FRONTMATTER" | grep '^author:' | sed 's/^author: *//')
111116
FOLDER_PREFIX=$(echo "$FOLDER" | cut -d'-' -f1)
112117
if [ -n "$AUTHOR" ] && [ "$AUTHOR" != "$FOLDER_PREFIX" ]; then
113118
ERRORS+=("Author '$AUTHOR' in frontmatter does not match folder prefix '$FOLDER_PREFIX'")
114119
fi
115120
116-
# Author in frontmatter must match PR author (GitHub username)
117121
if [ -n "$AUTHOR" ] && [ -n "$PR_AUTHOR" ]; then
118-
# Case-insensitive comparison (GitHub handles are case-insensitive)
119122
AUTHOR_LOWER=$(echo "$AUTHOR" | tr '[:upper:]' '[:lower:]')
120123
PR_AUTHOR_LOWER=$(echo "$PR_AUTHOR" | tr '[:upper:]' '[:lower:]')
121124
if [ "$AUTHOR_LOWER" != "$PR_AUTHOR_LOWER" ]; then
122125
ERRORS+=("Author '$AUTHOR' in frontmatter does not match PR author '$PR_AUTHOR'. You can only submit profiles under your own GitHub handle.")
123126
fi
124127
fi
125128
126-
# Category must be one of the allowed values
127129
CATEGORY=$(echo "$FRONTMATTER" | grep '^category:' | sed 's/^category: *//')
128130
ALLOWED="git docker rust k8s python node misc"
129131
if [ -n "$CATEGORY" ] && ! echo "$ALLOWED" | grep -qw "$CATEGORY"; then
@@ -132,29 +134,27 @@ jobs:
132134
fi
133135
fi
134136
135-
# ── Rule 8: profiles.toml must be valid TOML structure ──
137+
# ── Rule 8: profiles.toml structure ──
136138
if [ -f "community/$FOLDER/profiles.toml" ]; then
137139
if ! grep -q '\[\[profiles\]\]\|^\[aliases\]' "community/$FOLDER/profiles.toml"; then
138140
ERRORS+=("profiles.toml does not look like a valid am export (missing [[profiles]] or [aliases] section)")
139141
fi
140142
fi
141143
142-
# ── Rule 9: Scan for suspicious content in profiles.toml ──
144+
# ── Rule 9: Scan for suspicious content ──
143145
if [ -f "community/$FOLDER/profiles.toml" ]; then
144-
# Check for TOML unicode escapes that produce control characters
145146
if grep -qP '\\u\{?00[0-1][0-9a-fA-F]\}?|\\u\{?007[fF]\}?|\\u\{?00[89a-fA-F][0-9a-fA-F]\}?' "community/$FOLDER/profiles.toml" 2>/dev/null; then
146147
WARNINGS+=("profiles.toml contains unicode escape sequences that may produce control characters. Maintainer: please inspect carefully.")
147148
fi
148-
# Check for raw control characters (bytes 0x00-0x1F except \t \n \r, and 0x7F)
149149
if grep -qP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' "community/$FOLDER/profiles.toml" 2>/dev/null; then
150-
ERRORS+=("SECURITY: profiles.toml contains raw control characters. This may be an attempt to inject malicious terminal commands.")
150+
ERRORS+=("SECURITY: profiles.toml contains raw control characters.")
151151
fi
152152
fi
153153
154154
# ── Rule 10: Must not modify other people's folders ──
155155
OTHER_FOLDERS=$(echo "$FILES" | grep '^community/' | cut -d'/' -f2 | sort -u | grep -v "^${FOLDER}$" || true)
156156
if [ -n "$OTHER_FOLDERS" ]; then
157-
ERRORS+=("SECURITY: Changes detected in other contributor folders: $OTHER_FOLDERS. You may only modify your own folder.")
157+
ERRORS+=("SECURITY: Changes detected in other contributor folders: $OTHER_FOLDERS.")
158158
fi
159159
160160
# ── Report ──
@@ -163,14 +163,12 @@ jobs:
163163
for WARN in "${WARNINGS[@]}"; do
164164
echo " ⚠ $WARN"
165165
done
166-
echo ""
167166
fi
168167
169168
if [ ${#ERRORS[@]} -gt 0 ]; then
170169
echo "::error::Community profile validation failed"
171170
echo ""
172171
echo "❌ Validation failed with ${#ERRORS[@]} error(s):"
173-
echo ""
174172
for ERR in "${ERRORS[@]}"; do
175173
echo " • $ERR"
176174
done
@@ -191,7 +189,45 @@ jobs:
191189
- name: Verify import
192190
run: |
193191
FOLDER="${{ steps.validate.outputs.folder }}"
194-
TOML="community/$FOLDER/profiles.toml"
195-
echo "Testing import of $TOML..."
196-
./target/release/am import "$TOML" --yes
192+
echo "Testing import of community/$FOLDER/profiles.toml..."
193+
./target/release/am import "community/$FOLDER/profiles.toml" --yes
197194
echo "✅ Import verification passed"
195+
196+
# ── Step 2: Build & deploy preview (only after validation passes) ──
197+
preview:
198+
needs: validate
199+
uses: ./.github/workflows/reusable-preview-website.yml
200+
with:
201+
pr_number: ${{ github.event.pull_request.number }}
202+
head_sha: ${{ github.event.pull_request.head.sha }}
203+
is_fork: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
204+
permissions:
205+
contents: write
206+
pull-requests: write
207+
208+
# ── Cleanup on PR close ──
209+
cleanup:
210+
if: github.event.action == 'closed'
211+
runs-on: ubuntu-latest
212+
permissions:
213+
contents: write
214+
steps:
215+
- name: Checkout gh-pages branch
216+
uses: actions/checkout@v6
217+
with:
218+
ref: gh-pages
219+
220+
- name: Remove preview
221+
run: |
222+
PR_NUMBER=${{ github.event.pull_request.number }}
223+
224+
if [ -d "_preview/${PR_NUMBER}" ]; then
225+
rm -rf "_preview/${PR_NUMBER}"
226+
git add -A
227+
git config user.name "github-actions[bot]"
228+
git config user.email "github-actions[bot]@users.noreply.github.com"
229+
git commit -m "cleanup: remove preview for PR #${PR_NUMBER}"
230+
git push
231+
else
232+
echo "No preview to clean up"
233+
fi

.github/workflows/preview-website.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ name: Preview Website
33
on:
44
# pull_request_target runs the workflow from the BASE branch (main),
55
# not from the PR branch — so contributors cannot modify this workflow.
6+
# Only for website-only PRs. Community PRs get their preview
7+
# from community-validation.yml after validation passes.
68
pull_request_target:
79
paths:
810
- "website/**"
9-
- "community/**"
1011
types: [opened, synchronize, reopened, closed]
1112

1213
permissions:
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: Reusable Preview Website
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
pr_number:
7+
required: true
8+
type: number
9+
head_sha:
10+
required: true
11+
type: string
12+
is_fork:
13+
required: true
14+
type: boolean
15+
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
20+
jobs:
21+
preview:
22+
runs-on: ubuntu-latest
23+
environment: ${{ inputs.is_fork && 'preview-website' || '' }}
24+
steps:
25+
- name: Checkout PR branch
26+
uses: actions/checkout@v6
27+
with:
28+
ref: ${{ inputs.head_sha }}
29+
fetch-depth: 0
30+
path: pr
31+
32+
- name: Setup Node
33+
uses: actions/setup-node@v6
34+
with:
35+
node-version: 20
36+
cache: npm
37+
cache-dependency-path: pr/website/package-lock.json
38+
39+
- name: Install dependencies
40+
run: npm ci
41+
working-directory: pr/website
42+
43+
- name: Build with preview base path
44+
run: npm run build
45+
working-directory: pr/website
46+
env:
47+
VITEPRESS_BASE: /_preview/${{ inputs.pr_number }}/
48+
49+
- name: Checkout gh-pages branch
50+
uses: actions/checkout@v6
51+
with:
52+
ref: gh-pages
53+
path: gh-pages
54+
55+
- name: Deploy preview
56+
run: |
57+
cd gh-pages
58+
PR_NUMBER=${{ inputs.pr_number }}
59+
60+
rm -rf "_preview/${PR_NUMBER}"
61+
mkdir -p "_preview/${PR_NUMBER}"
62+
63+
cp -r ../pr/website/.vitepress/dist/* "_preview/${PR_NUMBER}/"
64+
65+
git add -A
66+
if git diff --staged --quiet; then
67+
echo "No changes to deploy"
68+
else
69+
git config user.name "github-actions[bot]"
70+
git config user.email "github-actions[bot]@users.noreply.github.com"
71+
git commit -m "preview: PR #${PR_NUMBER} from ${{ inputs.head_sha }}"
72+
git push
73+
fi
74+
75+
- name: Comment preview URL
76+
uses: actions/github-script@v7
77+
with:
78+
script: |
79+
const prNumber = ${{ inputs.pr_number }};
80+
const previewUrl = `https://amoxide.rs/_preview/${prNumber}/`;
81+
const body = `🔍 **Website Preview:** ${previewUrl}\n\n_Preview will be removed when this PR is merged or closed._`;
82+
83+
const comments = await github.rest.issues.listComments({
84+
owner: context.repo.owner,
85+
repo: context.repo.repo,
86+
issue_number: prNumber,
87+
});
88+
89+
const existing = comments.data.find(c =>
90+
c.user.login === 'github-actions[bot]' && c.body.includes('Website Preview')
91+
);
92+
93+
if (existing) {
94+
await github.rest.issues.updateComment({
95+
owner: context.repo.owner,
96+
repo: context.repo.repo,
97+
comment_id: existing.id,
98+
body,
99+
});
100+
} else {
101+
await github.rest.issues.createComment({
102+
owner: context.repo.owner,
103+
repo: context.repo.repo,
104+
issue_number: prNumber,
105+
body,
106+
});
107+
}

0 commit comments

Comments
 (0)