Repository navigation
samyak 1 #108
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 signature | ||
|
Check warning on line 1 in .github/workflows/validate-signature.yml
|
||
| # pull_request_target so every student's check runs the moment the PR opens, | ||
| # with no maintainer clicking "Approve and run" for first-time contributors. | ||
| # | ||
| # That trigger runs with this repo's permissions, so nothing here ever checks | ||
| # out, installs or executes anything from the PR. The workflow is always the | ||
| # copy on main; the PR's signature file is only ever read as data. | ||
| on: | ||
| pull_request_target: | ||
| branches: [main] | ||
| types: [opened, synchronize, reopened] | ||
| # One run per PR at a time; a newer push cancels the older check. | ||
| concurrency: | ||
| group: signature-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
| permissions: {} | ||
| jobs: | ||
| # The required status check. Read-only. | ||
| validate: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| outputs: | ||
| problems: ${{ steps.check.outputs.problems }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.event.pull_request.base.sha }} | ||
| fetch-depth: 0 | ||
| persist-credentials: false | ||
| - name: Check the PR adds exactly one valid signature | ||
| id: check | ||
| env: | ||
| AUTHOR: ${{ github.event.pull_request.user.login }} | ||
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| run: | | ||
| set -euo pipefail | ||
| problems=() | ||
| # Hand every problem to the bot job, however this step ends. | ||
| emit() { | ||
| { | ||
| echo "problems<<SIGNATURE_PROBLEMS_EOF" | ||
| for p in "${problems[@]+"${problems[@]}"}"; do echo "$p"; done | ||
| echo "SIGNATURE_PROBLEMS_EOF" | ||
| } >> "$GITHUB_OUTPUT" | ||
| } | ||
| trap emit EXIT | ||
| fail() { problems+=("$1"); echo "::error::$1"; exit 1; } | ||
| # Filenames come from the PR; strip anything that could be read as a workflow command. | ||
| safe() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._/@ -'; } | ||
| # The PR's commit, as data only. Nothing below checks it out or runs it. | ||
| git fetch --no-tags -q origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head" | ||
| [ "$(git rev-parse refs/remotes/pr/head)" = "$HEAD_SHA" ] || fail "The PR changed while this check was starting. Push again or re-run the check." | ||
| expected="signatures/${AUTHOR}.md" | ||
| # Three dots: compare against where the student's branch started, as the PR's | ||
| # "Files changed" tab does. Two dots would count every signature merged into | ||
| # main since they forked as a change of theirs. | ||
| changed="$(git diff --name-status "${BASE_SHA}...${HEAD_SHA}")" | ||
| echo "Files changed in this PR:" | ||
| printf '%s\n' "$changed" | while IFS= read -r line; do echo " $(safe "$line")"; done | ||
| count="$(printf '%s\n' "$changed" | grep -c . || true)" | ||
| if [ "$count" -ne 1 ]; then | ||
| others="$(printf '%s\n' "$changed" | cut -f2 | while IFS= read -r f; do [ "$f" = "$expected" ] || printf '`%s` ' "$(safe "$f")"; done)" | ||
| fail "This PR changes $count files. It should change exactly one: \`$expected\`. Remove these from the PR: ${others}(if you see other people's files, you committed on main — see 'Stuck?' in the README)." | ||
| fi | ||
| status="$(printf '%s' "$changed" | cut -f1)" | ||
| path="$(printf '%s' "$changed" | cut -f2)" | ||
| [ "$path" = "$expected" ] || fail "Your file must be named exactly \`$expected\` — your GitHub username, capitals included. Yours is \`$(safe "$path")\`. Fix it with: \`git mv \"$(safe "$path")\" $expected\`" | ||
| case "$status" in A|M) ;; *) fail "Your signature file should be added, not deleted or renamed (git status: $(safe "$status"))." ;; esac | ||
| sig="$RUNNER_TEMP/signature.md" | ||
| git show "${HEAD_SHA}:${path}" > "$sig" | ||
| [ -s "$sig" ] || fail "\`$path\` is empty. Copy \`signatures/_template.md\` and fill it in." | ||
| # Content problems are collected, not fatal one at a time, so one run lists everything to fix. | ||
| first="$(head -n1 "$sig")" | ||
| if ! printf '%s' "$first" | grep -Eq '^# .+'; then | ||
| problems+=("The first line must be your name after \`# \`, for example \`# Asha Rao\`.") | ||
| elif printf '%s' "$first" | grep -Eiq '^#[[:space:]]*Your Name[[:space:]]*$'; then | ||
| problems+=("The first line still says \`# Your Name\`. Replace *Your Name* with your real name and keep the \`# \`, for example \`# Asha Rao\`.") | ||
| fi | ||
| grep -Eqi -- "^- \*\*GitHub:\*\* @${AUTHOR}[[:space:]]*$" "$sig" \ | ||
| || problems+=("The GitHub line must be exactly \`- **GitHub:** @${AUTHOR}\` — with the \`@\` and a space after the colon.") | ||
| for field in "Batch" "I'm here to" "One thing I've built"; do | ||
| grep -Fq -- "- **${field}:**" "$sig" || problems+=("The \`- **${field}:**\` line from the template is missing.") | ||
| done | ||
| grep -Fq "one honest line about what you want" "$sig" && problems+=("\`I'm here to\` still has the template text. Write your own line.") | ||
| if [ "${#problems[@]}" -gt 0 ]; then | ||
| for p in "${problems[@]}"; do echo "::error::$p"; done | ||
| exit 1 | ||
| fi | ||
| echo "✅ Signature looks good." | ||
| # Comments on the PR and, when the check passed, merges it. | ||
| # It never touches PR content except to read the one signature file through the API. | ||
| bot: | ||
| needs: validate | ||
| if: always() && needs.validate.result != 'cancelled' && needs.validate.result != 'skipped' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| steps: | ||
| - uses: actions/github-script@v7 | ||
| env: | ||
| RESULT: ${{ needs.validate.result }} | ||
| PROBLEMS: ${{ needs.validate.outputs.problems }} | ||
| with: | ||
| script: | | ||
| const MARKER = "<!-- workbook-bot -->"; | ||
| const pr = context.payload.pull_request; | ||
| const { owner, repo } = context.repo; | ||
| const user = pr.user.login; | ||
| const path = `signatures/${user}.md`; | ||
| const passed = process.env.RESULT === "success"; | ||
| async function upsert(body) { | ||
| const comments = await github.paginate(github.rest.issues.listComments, { | ||
| owner, repo, issue_number: pr.number, per_page: 100, | ||
| }); | ||
| const mine = comments.find((c) => c.user.type === "Bot" && c.body.includes(MARKER)); | ||
| const text = `${MARKER}\n${body}`; | ||
| if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body: text }); | ||
| else await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: text }); | ||
| } | ||
| if (!passed) { | ||
| const problems = (process.env.PROBLEMS || "").split("\n").map((s) => s.trim()).filter(Boolean); | ||
| const list = problems.length | ||
| ? problems.map((p) => `- [ ] ${p}`).join("\n") | ||
| : "- [ ] The check failed before it could read your file. Open the **validate** check's log for details."; | ||
| await upsert( | ||
| `Hi @${user} 👋 thanks for signing the workbook! The check found ${problems.length > 1 ? "these problems" : "a problem"}:\n\n${list}\n\n` + | ||
| "**How to fix it** (in your clone, on the same branch as this PR):\n\n" + | ||
| "```bash\n# fix the file as described above, then:\ngit add -A\ngit commit -m \"Fix signature\"\ngit push\n```\n\n" + | ||
| `Your file should look like:\n\n\`\`\`markdown\n# Your Real Name\n\n- **GitHub:** @${user}\n- **Batch:** 2026\n- **I'm here to:** your own line\n- **One thing I've built:** a link, or nothing yet\n\`\`\`\n\n` + | ||
| "Push to **this same PR**. Don't open a new one. This comment updates itself each time the check re-runs.", | ||
| ); | ||
| return; | ||
| } | ||
| // Passed: read the signature (as data) for a few friendly polish tips. | ||
| const { data: file } = await github.rest.repos.getContent({ owner, repo, path, ref: pr.head.sha }); | ||
| const text = Buffer.from(file.content, "base64").toString("utf8"); | ||
| const field = (name) => (text.match(new RegExp(`^- \\*\\*${name}:\\*\\*\\s*(.*)$`, "m")) || [])[1]?.trim() ?? ""; | ||
| const goal = field("I'm here to"); | ||
| const built = field("One thing I've built"); | ||
| const name = (text.split("\n")[0] || "").replace(/^#\s*/, "").trim(); | ||
| const tips = []; | ||
| if (/^["“].*["”]$/.test(built)) tips.push("You don't need quotes around *nothing yet*. Just write it as plain text."); | ||
| if (/^to\b/i.test(goal)) tips.push("Your *I'm here to* line starts with \"to\", so it reads \"I'm here to: to…\". Drop the extra \"to\"."); | ||
| if (goal && goal.split(/\s+/).length < 4) tips.push("Your goal is quite short. Next time, say *what* you want to learn or build; it helps us match you with the right issues."); | ||
| if (/\bi\b/.test(goal)) tips.push("Capitalise \"I\" in your goal line."); | ||
| if (name && name === name.toLowerCase()) tips.push("Capitalise your name in the heading."); | ||
| if (/(^|\s)(?!https?:\/\/)[\w-]+\.(netlify\.app|vercel\.app|onrender\.com|github\.io|com|dev|app)\b/i.test(built)) tips.push("Start links with `https://` so they're clickable."); | ||
| if (/^(learning|to learn|learn)/i.test(built)) tips.push("*One thing I've built* is for something you've made (or *nothing yet*), not a goal."); | ||
| if (text.split("\n").some((line) => /\S {2,}/.test(line))) tips.push("There are some double spaces. Reading your own diff before opening a PR catches these."); | ||
| const feedback = tips.length | ||
| ? "**A few polish tips for next time** (no need to change this PR):\n" + tips.map((t) => `- ${t}`).join("\n") | ||
| : "Clean signature, nothing to polish. 👌"; | ||
| let merged = false; | ||
| try { | ||
| await github.rest.pulls.merge({ | ||
| owner, repo, pull_number: pr.number, | ||
| sha: pr.head.sha, // only the exact commit that passed the check | ||
| // Squash: one new commit on top of main with only the signature. A merge | ||
| // commit from a fork made before a workflow change would carry that change | ||
| // in, and GitHub refuses workflow edits from this token. | ||
| merge_method: "squash", | ||
| commit_title: `Sign the workbook: ${name || user} (#${pr.number})`, | ||
| }); | ||
| merged = true; | ||
| } catch (e) { | ||
| core.warning(`Merge failed: ${e.message}`); | ||
| } | ||
| await upsert( | ||
| merged | ||
| ? `🎉 Welcome to DevForge, @${user}! The check passed and your signature is **merged**.\n\n${feedback}\n\n` + | ||
| "**Next:** open [the workbook](https://www.devforge.club/workbook), submit **milestone 1** with this PR's link, and write your reflection within 48 hours. Then on to milestone 2!" | ||
| : `✅ @${user}, your signature passed the check. The automatic merge didn't go through, so a council member will merge it shortly.\n\n${feedback}`, | ||
| ); | ||