chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Auto Extract
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: auto-extract
cancel-in-progress: false
jobs:
extract:
name: Extract
if: github.actor != 'emdashbot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Commit the extracted locale catalogs back to main. Nothing else.
permission-contents: write
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.app-token.outputs.token }}
# Intentional: the "Commit and push" step below pushes the
# extracted catalogs back to main using this credential.
persist-credentials: true
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm locale:extract
- name: Check for changes
id: diff
run: |
git add -A
invalid_changes=$(git diff --staged --name-status --no-renames | awk '$1 !~ /^(A|M)$/ || $2 !~ /^packages\/admin\/src\/locales\/[^/]+\/messages\.po$/ { print }' || true)
if [ -n "$invalid_changes" ]; then
echo "::error::Extraction produced unexpected staged changes. Only added or modified packages/admin/src/locales/*/messages.po files are allowed:"
echo "$invalid_changes"
exit 1
fi
if git diff --staged --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push
if: steps.diff.outputs.changed == 'true'
run: |
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "chore: extract locale catalogs [skip ci]"
git pull --rebase origin main
git push
+234
View File
@@ -0,0 +1,234 @@
name: Auto Format — Apply
# Stage 2 of 2. Runs after "Auto Format" completes on a PR. This job has
# elevated permissions to push the formatted patch back to the PR branch,
# but it never executes code from the PR — it only downloads the inert
# patch artifact, checks out the measured head SHA, and applies the diff.
on:
workflow_run:
workflows: ["Auto Format"]
types: [completed]
permissions:
# actions:read is needed for listWorkflowRunArtifacts / downloadArtifact.
# pull-requests:read is needed for pulls.get in the Resolve PR step.
# contents:read is a safety default; the push-back steps use the app token.
actions: read
contents: read
pull-requests: read
jobs:
apply:
name: Apply
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download patch artifact
id: download
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const artifact = data.artifacts.find((a) => a.name === 'auto-format-patch');
if (!artifact) {
core.setOutput('found', 'false');
core.info('No patch artifact — nothing to apply.');
return;
}
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
// Stage the artifact under RUNNER_TEMP, not GITHUB_WORKSPACE.
// GITHUB_WORKSPACE is wiped by the later actions/checkout step
// (clean: true is the default), which would delete the payload
// before the Apply step can reach it. RUNNER_TEMP sits outside
// the workspace and survives checkout.
const outDir = path.join(process.env.RUNNER_TEMP, 'af-artifact');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'artifact.zip'), Buffer.from(download.data));
core.setOutput('found', 'true');
- name: Unpack artifact
if: steps.download.outputs.found == 'true'
run: |
cd "$RUNNER_TEMP/af-artifact"
unzip -o artifact.zip
ls -la
- name: Resolve PR
if: steps.download.outputs.found == 'true'
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const prNumber = Number(
fs.readFileSync(
path.join(process.env.RUNNER_TEMP, 'af-artifact', 'pr-number'),
'utf8',
).trim(),
);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
core.setFailed(`Invalid PR number in artifact: ${prNumber}`);
return;
}
// Cross-check: fetch the PR and verify its head SHA matches the
// workflow_run's head SHA, so a forged artifact can't redirect
// the push at an unrelated branch.
const headSha = context.payload.workflow_run.head_sha;
let pr;
try {
const { data } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
pr = data;
} catch (err) {
core.setFailed(`Failed to fetch PR #${prNumber}: ${err.message}`);
return;
}
if (pr.head.sha !== headSha) {
core.setFailed(
`PR #${prNumber} head SHA (${pr.head.sha}) does not match workflow_run head SHA (${headSha}). The branch has moved on; the next PR event will re-run format+apply.`,
);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('ref', pr.head.ref);
// Push onto the exact commit that was formatted. If the branch
// has advanced since, the push fails non-fast-forward rather than
// applying a stale patch to a newer tree.
core.setOutput('sha', headSha);
core.setOutput('full_name', pr.head.repo.full_name);
const isFork = pr.head.repo.fork
|| pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
core.setOutput('is_fork', isFork.toString());
- name: Generate app token
if: steps.download.outputs.found == 'true'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Push the formatted commit (contents) and comment on push failure
# (pull-requests). Nothing else.
permission-contents: write
permission-pull-requests: write
# --- Same-repo PRs: checkout the measured SHA and push directly ---
- name: Checkout (same-repo)
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'false'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.pr.outputs.sha }}
token: ${{ steps.app-token.outputs.token }}
# The same-repo push step below pushes the formatted commit back to
# the PR branch using this credential.
persist-credentials: true
# --- Fork PRs: checkout the fork at the measured SHA so we can push back ---
- name: Checkout (fork)
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ steps.pr.outputs.full_name }}
ref: ${{ steps.pr.outputs.sha }}
persist-credentials: false
# Reviewed opt-in to checkout v7's fork guard: no code from the
# fork tree is ever executed here. The steps below only `git apply`
# the patch from the trusted format artifact and run git
# add/commit/push — no scripts, hooks, or tooling from the
# checked-out tree. Credentials are not persisted; the push uses
# a scoped app token via GIT_ASKPASS.
allow-unsafe-pr-checkout: true
- name: Apply patch
if: steps.download.outputs.found == 'true'
id: apply
run: |
git apply "$RUNNER_TEMP/af-artifact/format.patch"
git add -A
if git diff --staged --quiet; then
echo "no_diff=true" >> "$GITHUB_OUTPUT"
echo "Patch is a no-op against the checked-out head — nothing to push."
else
echo "no_diff=false" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push (same-repo)
if: |
steps.download.outputs.found == 'true'
&& steps.pr.outputs.is_fork == 'false'
&& steps.apply.outputs.no_diff == 'false'
env:
HEAD_REF: ${{ steps.pr.outputs.ref }}
HEAD_SHA: ${{ steps.pr.outputs.sha }}
run: |
set -e
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "style: format"
# Detached-HEAD push onto the PR branch. If the branch has advanced
# past the measured SHA, this fails non-fast-forward — the next PR
# event re-runs format+apply against the new head.
if ! git push origin "HEAD:$HEAD_REF"; then
echo "::error::Non-fast-forward push — the PR branch moved past the measured SHA $HEAD_SHA. The next PR event will re-run format+apply." >&2
exit 1
fi
- name: Commit and push (fork)
if: |
steps.download.outputs.found == 'true'
&& steps.pr.outputs.is_fork == 'true'
&& steps.apply.outputs.no_diff == 'false'
id: push-fork
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_REF: ${{ steps.pr.outputs.ref }}
FULL_NAME: ${{ steps.pr.outputs.full_name }}
run: |
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "style: format"
# Push the formatted commit to the fork via the app token, supplied
# through GIT_ASKPASS so it never lands in process args or git config.
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
chmod +x "$GIT_ASKPASS"
git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
if git push fork "HEAD:$HEAD_REF"; then
echo "push_failed=false" >> "$GITHUB_OUTPUT"
else
echo "push_failed=true" >> "$GITHUB_OUTPUT"
fi
rm -f "$GIT_ASKPASS"
- name: Comment on push failure
if: steps.push-fork.outputs.push_failed == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(process.env.PR_NUMBER),
body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\npnpm format\n\`\`\``,
});
+58
View File
@@ -0,0 +1,58 @@
name: Auto Format
# Stage 1 of 2 (producer). Runs with the default read-only token and never
# pushes or comments, so it is safe to format PR-authored code — including
# from forks. The companion "Auto Format — Apply" workflow (triggered by
# workflow_run) commits the result back with elevated permissions, isolated
# from PR-authored code.
on:
pull_request:
branches: [main]
types: [opened, synchronize]
permissions:
contents: read
jobs:
format:
name: Format
# Skip the bot's own format commits to avoid a re-format loop.
if: github.actor != 'emdashbot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Run formatter
run: npx oxfmt@0.58.0 --ignore-path .gitignore
- name: Compute patch
id: patch
run: |
git add -A
if git diff --staged --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Already formatted — nothing to apply."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
mkdir -p auto-format-out
git diff --staged --binary > auto-format-out/format.patch
echo "${{ github.event.pull_request.number }}" > auto-format-out/pr-number
echo "Formatting changes detected — uploading patch for the Apply workflow."
fi
- name: Upload patch artifact
if: steps.patch.outputs.changed == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: auto-format-patch
path: auto-format-out/
retention-days: 7
if-no-files-found: error
+159
View File
@@ -0,0 +1,159 @@
name: Bonk
# /bonk and @ask-bonk both trigger this workflow. The first word after the
# trigger picks a model alias from .github/bonk-models.json. Currently
# registered aliases (see that file for the source of truth):
#
# /bonk <task> -> default model (currently opus)
# /bonk opus <task> -> Claude Opus 4.7 (default)
# /bonk kimi <task> -> Kimi K2.7 Code (cheap pass for tiny tasks)
# @ask-bonk opus how would you do X? -> Claude Opus 4.7
#
# Add new aliases by editing .github/bonk-models.json -- the resolver script
# only registers the selected alias in OPENCODE_CONFIG_CONTENT at runtime.
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
# Concurrency is at job level (below) rather than workflow level so it's only
# evaluated when the `if:` filter passes. Otherwise every issue_comment event
# in the repo would enter the group and could evict in-flight runs from
# unrelated, non-matching comments.
jobs:
bonk:
if: >-
github.event.sender.type != 'Bot'
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association)
&& (
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
&& (contains(github.event.comment.body, '/bonk') || contains(github.event.comment.body, '@ask-bonk')))
|| (github.event_name == 'pull_request_review'
&& (contains(github.event.review.body, '/bonk') || contains(github.event.review.body, '@ask-bonk')))
)
# Per-workflow group key so /bonk and /review have independent queues per
# target. Spamming /bonk on the same issue/PR serializes; different actions
# on the same target run in parallel.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
id-token: write
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: Resolve model from comment
id: model
env:
BODY: ${{ github.event.comment.body || github.event.review.body }}
run: node .github/scripts/resolve-bonk-model.mjs
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve trigger context
id: trigger
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
# issue_comment fires on both issues and PRs. github.event.issue.pull_request
# is non-null when the issue is actually a PR.
IS_PR_COMMENT: ${{ github.event.issue.pull_request != null }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
# Decide trigger_kind ("issue" or "pr") and target_number.
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
if [[ "$IS_PR_COMMENT" == "true" ]]; then
KIND=pr
NUM="$ISSUE_NUMBER" # issue.number is the PR number for PR comments
else
KIND=issue
NUM="$ISSUE_NUMBER"
fi
else
KIND=pr
NUM="$PR_NUMBER"
fi
# Fetch title/body (heredoc-quoted to prevent newlines in titles or
# bodies from corrupting $GITHUB_OUTPUT or smuggling step outputs).
if [[ "$KIND" == "pr" ]]; then
gh api "/repos/${GITHUB_REPOSITORY}/pulls/${NUM}" > /tmp/target.json
SHA="$(jq -r .head.sha /tmp/target.json)"
BASE="$(jq -r .base.sha /tmp/target.json)"
else
gh api "/repos/${GITHUB_REPOSITORY}/issues/${NUM}" > /tmp/target.json
SHA=""
BASE=""
fi
{
echo "kind=${KIND}"
echo "number=${NUM}"
echo "head_sha=${SHA}"
echo "base_sha=${BASE}"
echo 'title<<TARGET_TITLE_EOF'
jq -r '.title // ""' /tmp/target.json
echo TARGET_TITLE_EOF
echo 'body<<TARGET_BODY_EOF'
jq -r '.body // ""' /tmp/target.json
echo TARGET_BODY_EOF
} >> "$GITHUB_OUTPUT"
- name: Run Bonk (${{ steps.model.outputs.alias }})
uses: ask-bonk/ask-bonk/github@bfd92f4d0abb62d98558b35432534f7b9992f3eb # main as of 2026-04-24
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
OPENCODE_CONFIG_CONTENT: ${{ steps.model.outputs.opencode_config }}
with:
model: ${{ steps.model.outputs.model }}
mentions: "/bonk,@ask-bonk"
opencode_version: "1.4.11"
permissions: write
# Custom agent defined in .opencode/agents/auto-implementer.md.
# Holds the investigation/reproduction protocol, scope discipline,
# and PR body conventions so this workflow stays small.
agent: auto-implementer
prompt: |
A maintainer has invoked /bonk on the emdash-cms/emdash repository. Follow your agent instructions for mode classification, investigation, reproduction, and posting.
<trigger_kind>${{ steps.trigger.outputs.kind }}</trigger_kind>
<target_number>${{ steps.trigger.outputs.number }}</target_number>
<target_title>${{ steps.trigger.outputs.title }}</target_title>
<target_body>
${{ steps.trigger.outputs.body }}
</target_body>
<head_sha>${{ steps.trigger.outputs.head_sha }}</head_sha>
<base_sha>${{ steps.trigger.outputs.base_sha }}</base_sha>
The maintainer's request:
${{ github.event.comment.body || github.event.review.body }}
+139
View File
@@ -0,0 +1,139 @@
name: Bot Cleanup
# Two ingress paths:
# - issues.closed: drop the bot branches for that issue immediately.
# - daily cron: sweep `bot/artifacts-*` branches older than 90 days.
#
# Both jobs use the app token so deletions land as the bot identity (not
# whoever closed the issue).
on:
issues:
types: [closed]
schedule:
# 04:00 UTC daily. Off-peak for most contributors.
- cron: "0 4 * * *"
permissions:
contents: read
concurrency:
group: bot-cleanup
cancel-in-progress: false
jobs:
cleanup-on-close:
name: Delete bot branches when an issue closes
if: github.event_name == 'issues' && github.event.issue.pull_request == null
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: emdash-cms
repositories: emdash
permission-contents: write
# Read-only PR scope so we can `gh pr list --head bot/fix-N`
# before deleting the branch -- protects an open bot-opened
# PR from being orphaned when an issue is closed without
# merging.
permission-pull-requests: read
- name: Delete bot branches for closed issue
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
FIX_BRANCH="bot/fix-${ISSUE_NUMBER}"
ART_BRANCH="bot/artifacts-${ISSUE_NUMBER}"
# bot/fix-N MAY have an open PR pointing at it (the bot
# opens one after `triage/verified`). Deleting the ref would
# invalidate that PR. Two close paths to worry about:
#
# - PR merged -> issue auto-closes via "Closes #N" -> we
# fire here. The PR is closed; deleting the now-unused
# ref is fine. GitHub may have already deleted it.
# - Maintainer manually closes the issue ("won't fix",
# "stale", etc.) while the bot PR is still open. We
# must NOT delete the ref -- it would close the PR
# silently and lose the bot's work with no recovery.
OPEN_PR_COUNT="$(gh pr list \
--repo emdash-cms/emdash \
--head "$FIX_BRANCH" \
--state open \
--json number \
--jq 'length' 2>/dev/null || echo 0)"
if [[ "$OPEN_PR_COUNT" != "0" ]]; then
echo "::notice::skipping ${FIX_BRANCH} deletion: ${OPEN_PR_COUNT} open PR(s) reference it"
else
ENCODED="${FIX_BRANCH//\//%2F}"
STATUS="$(gh api -X DELETE "repos/emdash-cms/emdash/git/refs/heads/${ENCODED}" \
--silent -i 2>/dev/null | head -n 1 || true)"
echo "DELETE ${FIX_BRANCH}: ${STATUS:-no response}"
fi
# bot/artifacts-N is never the head of any PR (it's an
# orphan branch with screenshots only). Always safe to delete.
ENCODED="${ART_BRANCH//\//%2F}"
STATUS="$(gh api -X DELETE "repos/emdash-cms/emdash/git/refs/heads/${ENCODED}" \
--silent -i 2>/dev/null | head -n 1 || true)"
echo "DELETE ${ART_BRANCH}: ${STATUS:-no response}"
cleanup-daily:
name: Prune stale artifact branches
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: emdash-cms
repositories: emdash
permission-contents: write
- name: Sweep stale artifacts branches
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
CUTOFF="$(date -u -d '90 days ago' +%s 2>/dev/null || date -u -v-90d +%s)"
# Paginated branch list; filter to bot/artifacts-* in jq.
BRANCHES="$(gh api "repos/emdash-cms/emdash/branches" --paginate \
--jq '.[] | select(.name | startswith("bot/artifacts-")) | .name')"
if [[ -z "$BRANCHES" ]]; then
echo "No bot/artifacts-* branches found."
exit 0
fi
while IFS= read -r NAME; do
[[ -z "$NAME" ]] && continue
ENCODED="${NAME//\//%2F}"
DATE="$(gh api "repos/emdash-cms/emdash/branches/${ENCODED}" \
--jq '.commit.commit.committer.date' 2>/dev/null || true)"
if [[ -z "$DATE" ]]; then
echo "Skipping ${NAME}: could not read commit date."
continue
fi
TS="$(date -u -d "$DATE" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$DATE" +%s)"
if (( TS < CUTOFF )); then
echo "Deleting ${NAME} (committed ${DATE})"
gh api -X DELETE "repos/emdash-cms/emdash/git/refs/heads/${ENCODED}" --silent || \
echo " delete failed for ${NAME} (already gone?)"
else
echo "Keeping ${NAME} (committed ${DATE})"
fi
done <<<"$BRANCHES"
+295
View File
@@ -0,0 +1,295 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck
- run: pnpm run --filter emdash-demo --filter @emdash-cms/demo-cloudflare typecheck
- run: pnpm typecheck:templates
- run: node scripts/typecheck-public-source.mjs
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm lint
version-check:
name: Version Check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- run: node .github/scripts/check-no-major.mjs
changeset-validate:
name: Changeset Validation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# changeset status --since diffs against origin/main; needs full history.
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Validate changesets
run: |
shopt -s extglob
if compgen -G ".changeset/!(README).md" > /dev/null; then
pnpm changeset status --since=origin/main
else
echo "No changesets present; skipping validation."
fi
test:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: emdash_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Build emdash + its deps AND the plugin-cli + registry packages.
# They aren't deps of `emdash`, so the `emdash...` filter would
# leave them unbuilt and their tests would fail to resolve workspace
# links to dist/.
- run: pnpm run --filter emdash... --filter "@emdash-cms/plugin-cli" --filter "@emdash-cms/registry-*" --filter "@emdash-cms/plugin-types" build
- run: pnpm test:unit
env:
EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test
# Render tests use the Astro Vite plugin (vitest.repro.config.ts);
# they can't run under the plain-node config in test:unit.
- run: pnpm --filter emdash exec vitest run --config vitest.repro.config.ts
test-smoke:
name: Smoke Tests
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: emdash_smoke
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm --filter emdash exec vitest run --config vitest.smoke.config.ts
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/emdash_smoke
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm --filter emdash exec vitest run --config vitest.integration.config.ts
test-browser:
name: Browser Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter @emdash-cms/admin... build
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: pnpm run --filter @emdash-cms/admin test
test-e2e-rollup:
name: E2E Tests
if: always()
needs: [test-e2e]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check E2E shard results
run: |
if [ "${{ needs.test-e2e.result }}" != "success" ]; then
echo "E2E tests failed or were cancelled"
exit 1
fi
test-e2e:
name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter emdash... build
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: |
playwright-report/
test-results/
retention-days: 7
test-e2e-cloudflare:
name: E2E Cloudflare (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter "emdash-e2e-fixture-cloudflare..." build
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
# Runs the full e2e suite against the workerd runtime. Sharded like the Node
# lane: per-shard setup (dev-server boot + seed) is ~30s, so wall-clock is
# dominated by test execution and shards parallelize it near-linearly.
- run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
EMDASH_E2E_TARGET: cloudflare
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: playwright-report-cloudflare-${{ matrix.shardIndex }}
path: |
playwright-report/
test-results/
retention-days: 7
+79
View File
@@ -0,0 +1,79 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize]
merge_group:
permissions:
actions: write
checks: read
contents: write
issues: write
pull-requests: write
statuses: write
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.issue.pull_request && (github.event.comment.body == 'recheck' || startsWith(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA'))) || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
path-to-signatures: "signatures/version1/cla.json"
path-to-document: "https://www.cloudflare.com/cla/"
branch: "cla-signatures"
allowlist: dependabot[bot],emdashbot[bot],copilot-swe-agent[bot],ask-bonk[bot],opencode
lock-pullrequest-aftermerge: false
label:
needs: CLAssistant
if: always() && (github.event_name == 'pull_request_target' || github.event.issue.pull_request)
runs-on: ubuntu-latest
steps:
- name: Label CLA status
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;
if (!prNumber) return;
const owner = context.repo.owner;
const repo = context.repo.repo;
// Get the PR to read head SHA
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
// Read the CLA check run (the CLA Assistant uses check runs, not commit statuses)
const { data: checkRuns } = await github.rest.checks.listForRef({
owner, repo, ref: pr.head.sha,
});
const claCheck = checkRuns.check_runs.find(cr => cr.name === 'CLAssistant');
if (!claCheck || claCheck.status !== 'completed') return;
const signed = claCheck.conclusion === 'success';
const addLabel = signed ? 'cla: signed' : 'cla: needed';
const removeLabel = signed ? 'cla: needed' : 'cla: signed';
// Ensure labels exist
const labelColors = { 'cla: signed': '0e8a16', 'cla: needed': 'b60205' };
try {
await github.rest.issues.getLabel({ owner, repo, name: addLabel });
} catch {
await github.rest.issues.createLabel({ owner, repo, name: addLabel, color: labelColors[addLabel] });
}
// Add the correct label
const currentLabels = pr.labels.map(l => l.name);
if (!currentLabels.includes(addLabel)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [addLabel] });
}
// Remove the stale label
if (currentLabels.includes(removeLabel)) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: removeLabel });
}
+132
View File
@@ -0,0 +1,132 @@
name: CodeQL
# Advanced setup so we can control when analysis runs. The "Require code
# scanning results" ruleset rule waits for a SARIF upload; if a PR doesn't
# touch code (locale-only, workflow-only) and the workflow never runs, the
# check sits pending forever. We always run a job per language and either do
# the real analysis (when relevant paths changed) or upload an empty SARIF so
# the check reports cleanly.
#
# Parity with the previous default setup:
# - languages: actions, javascript-typescript
# (javascript and typescript are subsumed by javascript-typescript)
# - query suite: default
# - threat model: remote
# - schedule: weekly
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly run, matches previous default setup cadence.
- cron: "0 6 * * 1"
permissions: {}
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
pull-requests: read # dorny/paths-filter calls the PRs API on pull_request events
strategy:
fail-fast: false
matrix:
include:
- language: actions
paths-filter: |
code:
- '.github/workflows/**'
- '.github/actions/**'
- language: javascript-typescript
paths-filter: |
code:
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
- '**/*.jsx'
- '**/*.mjs'
- '**/*.cjs'
- '**/package.json'
- '**/pnpm-lock.yaml'
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# On push and schedule, always analyze. On pull_request, only analyze
# when paths relevant to this language changed.
- name: Detect relevant changes
id: changes
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
filters: ${{ matrix.paths-filter }}
- name: Decide whether to analyze
id: decide
env:
EVENT_NAME: ${{ github.event_name }}
CHANGED: ${{ steps.changes.outputs.code }}
run: |
if [ "$EVENT_NAME" != "pull_request" ] || [ "$CHANGED" = "true" ]; then
echo "analyze=true" >> "$GITHUB_OUTPUT"
else
echo "analyze=false" >> "$GITHUB_OUTPUT"
fi
- name: Initialize CodeQL
if: steps.decide.outputs.analyze == 'true'
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL analysis
if: steps.decide.outputs.analyze == 'true'
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{ matrix.language }}"
# When skipped, upload an empty SARIF so the "Require code scanning
# results" rule on the branch ruleset sees a result and passes the PR.
- name: Write empty SARIF
if: steps.decide.outputs.analyze != 'true'
env:
LANGUAGE: ${{ matrix.language }}
run: |
cat > empty.sarif <<EOF
{
"version": "2.1.0",
"\$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "CodeQL",
"semanticVersion": "0.0.0",
"rules": []
}
},
"results": [],
"automationDetails": {
"id": "/language:${LANGUAGE}/"
}
}
]
}
EOF
- name: Upload empty SARIF
if: steps.decide.outputs.analyze != 'true'
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: empty.sarif
category: "/language:${{ matrix.language }}"
+53
View File
@@ -0,0 +1,53 @@
name: Dependabot Auto-Approve
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
approve:
name: Auto-Approve
# Use github.event.pull_request.user.login, not github.actor.
# github.actor reflects the last actor on the trigger and is spoofable
# via a PR whose HEAD commit author is 'dependabot[bot]'. The
# pull_request.user.login is the PR opener and cannot be spoofed.
# https://docs.zizmor.sh/audits/#bot-conditions
if: github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Read PR metadata and submit the approving review. Nothing else.
permission-pull-requests: write
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98
with:
github-token: ${{ steps.app-token.outputs.token }}
- name: Auto-approve patch and minor updates
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
env:
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
event: 'APPROVE',
body: `Auto-approved: ${process.env.UPDATE_TYPE} update for ${process.env.DEPENDENCY_NAMES}.`,
});
+158
View File
@@ -0,0 +1,158 @@
name: Format Command
on:
issue_comment:
types: [created]
jobs:
format:
name: Format
if: >-
github.event.issue.pull_request &&
github.event.comment.body == '/format' &&
(
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR'
)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Push the formatted commit (contents), comment on the PR
# (pull-requests), and react to the triggering comment via the
# issues/comments reactions API (issues). Nothing else.
permission-contents: write
permission-pull-requests: write
permission-issues: write
- name: Get PR details
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const isFork = pr.data.head.repo.fork || pr.data.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
core.setOutput('ref', pr.data.head.ref);
core.setOutput('sha', pr.data.head.sha);
core.setOutput('is_fork', isFork.toString());
core.setOutput('full_name', pr.data.head.repo.full_name);
- name: React to comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes',
});
- name: Checkout (same-repo)
if: steps.pr.outputs.is_fork == 'false'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.pr.outputs.ref }}
token: ${{ steps.app-token.outputs.token }}
# Intentional: the same-repo push step below pushes the
# formatted changes back to the PR branch using this credential.
persist-credentials: true
- name: Checkout (fork)
if: steps.pr.outputs.is_fork == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ steps.pr.outputs.full_name }}
ref: ${{ steps.pr.outputs.sha }}
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Run formatter
run: npx oxfmt@0.58.0 --ignore-path .gitignore
- name: Check for changes
id: diff
run: |
git add -A
if git diff --staged --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push (same-repo)
if: steps.pr.outputs.is_fork == 'false' && steps.diff.outputs.changed == 'true'
run: |
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "style: format"
git push
- name: Commit and push (fork)
if: steps.pr.outputs.is_fork == 'true' && steps.diff.outputs.changed == 'true'
id: push-fork
run: |
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "style: format"
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
chmod +x "$GIT_ASKPASS"
git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
if git push fork "HEAD:$HEAD_REF"; then
echo "push_failed=false" >> "$GITHUB_OUTPUT"
else
echo "push_failed=true" >> "$GITHUB_OUTPUT"
fi
rm -f "$GIT_ASKPASS"
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
FULL_NAME: ${{ steps.pr.outputs.full_name }}
HEAD_REF: ${{ steps.pr.outputs.ref }}
- name: Comment on push failure
if: steps.push-fork.outputs.push_failed == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\npnpm format\n\`\`\``,
});
- name: React to comment (result)
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
CHANGED: ${{ steps.diff.outputs.changed }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const changed = process.env.CHANGED === 'true';
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: changed ? 'rocket' : '+1',
});
+27
View File
@@ -0,0 +1,27 @@
name: Format
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
+672
View File
@@ -0,0 +1,672 @@
name: Investigate
# Runs the Flue-based investigation agent when a maintainer applies the
# `bot:repro` label to an issue. The agent reproduces the bug (and may push a
# fix branch). The orchestrator (this workflow) performs all GitHub writes
# based on the agent's structured JSON output.
#
# Also accepts re-triggers from the reporter-reply workflow when the reporter
# (or a maintainer) says the first attempt missed something:
# * workflow_dispatch -- for manual re-runs from the Actions UI.
# * repository_dispatch (type `reporter-retry`) -- used by reporter-reply.yml,
# because firing it needs only the contents:write the emdashbot App already
# has, whereas workflow_dispatch from the App would need actions:write.
# * repository_dispatch (type `maintainer-directive`) -- used by
# maintainer-reply.yml when a maintainer directs an implementation on a
# reproduced issue. Carries `directive`, an authoritative instruction that
# overrides the fix gate (see InvestigatePayload.maintainerDirective). The
# produced fix routes through the normal awaiting-reporter loop.
# reporter-retry carries { issueNumber, retryContext }; maintainer-directive
# carries { issueNumber, directive }; both via client_payload (workflow_dispatch
# carries the equivalents in inputs).
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issueNumber:
description: "Issue number to investigate"
required: true
type: string
retryContext:
description: "Reporter feedback from previous attempt"
required: false
type: string
directive:
description: "Maintainer implementation directive (overrides the fix gate)"
required: false
type: string
repository_dispatch:
types: [reporter-retry, maintainer-directive]
# Default-deny at workflow level. The job below opens up only what it needs.
permissions:
contents: read
jobs:
investigate:
name: Investigate issue
# Gate on label name (only `bot:repro`) for the labeled path, or always
# run for workflow_dispatch. Also skip if the labeled "issue" is actually
# a PR (issues.labeled fires for PRs too).
if: >-
github.event_name == 'workflow_dispatch'
|| github.event_name == 'repository_dispatch'
|| (github.event.label.name == 'bot:repro' && github.event.issue.pull_request == null)
runs-on: ubuntu-latest
timeout-minutes: 60
# Serialize per-issue. Don't cancel in-flight runs -- partial state is
# worse than a queue, since the agent may have already pushed branches.
concurrency:
group: investigate-${{ github.event.issue.number || inputs.issueNumber || github.event.client_payload.issueNumber }}
cancel-in-progress: false
# Sandbox token (GITHUB_TOKEN) is intentionally read-only. All writes use
# the minted app token from the step below.
permissions:
# Sandbox bash gets this via AGENT_GH_TOKEN; just enough to clone and
# read issues, never enough to comment, label, or push.
contents: read
issues: read
# Hoist commonly used workflow context into env so shell steps can
# reference $RUN_URL etc. without raw `${{ ... }}` expansions, which
# zizmor flags as template injection. The values themselves are
# trustworthy here (`github.run_id`, `github.repository`, etc.) but
# the pattern is the recommended fix.
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: emdash-cms
repositories: emdash
permission-issues: write
permission-contents: write
permission-pull-requests: write
- name: Resolve issue context
id: ctx
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
EVENT_NAME: ${{ github.event_name }}
LABEL_ISSUE_NUMBER: ${{ github.event.issue.number }}
# Re-trigger issue number / feedback come from inputs (workflow_dispatch)
# or client_payload (repository_dispatch); only one is ever set.
DISPATCH_ISSUE_NUMBER: ${{ inputs.issueNumber || github.event.client_payload.issueNumber }}
LABEL_ISSUE_TITLE: ${{ github.event.issue.title }}
LABEL_ISSUE_BODY: ${{ github.event.issue.body }}
LABEL_ISSUE_REPORTER: ${{ github.event.issue.user.login }}
RETRY_CONTEXT: ${{ inputs.retryContext || github.event.client_payload.retryContext }}
# A maintainer's implementation directive (maintainer-directive
# dispatch or a manual workflow_dispatch). Empty on the labeled and
# reporter-retry paths.
DIRECTIVE: ${{ inputs.directive || github.event.client_payload.directive }}
run: |
set -euo pipefail
# The issue body and retry context are attacker-controllable
# multiline strings. Writing them to $GITHUB_OUTPUT with a
# fixed heredoc delimiter is a step-output injection vector:
# a body containing the delimiter would terminate the heredoc
# and let the attacker forge subsequent outputs. Avoid
# putting them in step outputs at all -- write to /tmp files
# that later steps read directly.
if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then
NUM="$DISPATCH_ISSUE_NUMBER"
# The dispatched number is attacker-influenceable (a forged
# repository_dispatch could carry a path-traversal value) and is
# about to be interpolated into an API path -- validate BEFORE the
# call, ahead of the shared check below. Issue numbers are positive
# integers with no leading zero (also keeps --argjson happy later).
if ! [[ "$NUM" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::invalid issue number: $NUM"
exit 1
fi
gh api "/repos/emdash-cms/emdash/issues/${NUM}" > /tmp/issue.json
# The issues API returns PRs too; only the labeled path was
# PR-guarded. Reject a PR number dispatched by mistake or forgery.
if jq -e '.pull_request' /tmp/issue.json >/dev/null 2>&1; then
echo "::error::#${NUM} is a pull request, not an issue"
exit 1
fi
TITLE="$(jq -r '.title // ""' /tmp/issue.json | tr -d '\r\n')"
REPORTER="$(jq -r '.user.login // ""' /tmp/issue.json | tr -d '\r\n')"
jq -r '.body // ""' /tmp/issue.json > /tmp/ctx-body.txt
printf '%s' "$RETRY_CONTEXT" > /tmp/ctx-retry.txt
else
NUM="$LABEL_ISSUE_NUMBER"
TITLE="$(printf '%s' "$LABEL_ISSUE_TITLE" | tr -d '\r\n')"
REPORTER="$(printf '%s' "$LABEL_ISSUE_REPORTER" | tr -d '\r\n')"
printf '%s' "$LABEL_ISSUE_BODY" > /tmp/ctx-body.txt
: > /tmp/ctx-retry.txt
fi
# The directive is attacker-shaped multiline text like the body and
# retry context; same treatment -- write to /tmp, never to a step
# output. Empty unless a maintainer-directive dispatch set it. A
# whitespace-only value (possible via a manual workflow_dispatch)
# normalizes to empty so `directed` and the payload reflect only a
# meaningful instruction.
if printf '%s' "$DIRECTIVE" | grep -q '[^[:space:]]'; then
printf '%s' "$DIRECTIVE" > /tmp/ctx-directive.txt
else
: > /tmp/ctx-directive.txt
fi
# Validate scalar fields are simple before they hit step
# outputs. Issue numbers are integers; logins match a tight
# regex. Anything weird produces a hard fail rather than a
# silent injection.
if ! [[ "$NUM" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::invalid issue number: $NUM"
exit 1
fi
if ! [[ "$REPORTER" =~ ^[a-zA-Z0-9-]{0,39}$ ]]; then
echo "::error::invalid reporter login: $REPORTER"
exit 1
fi
# Titles can include arbitrary unicode; cap length and strip
# control characters. They are never executed, but they do
# get echoed into markdown comments.
TITLE_CLEAN="$(printf '%s' "$TITLE" | LC_ALL=C tr -d '\000-\037\177' | cut -c1-256)"
# `directed` is a clean boolean derived from directive presence --
# safe for a step output (the directive text itself never is).
# Outcome branches use it to word maintainer-facing comments.
if [[ -s /tmp/ctx-directive.txt ]]; then DIRECTED=true; else DIRECTED=false; fi
{
echo "number=${NUM}"
echo "title=${TITLE_CLEAN}"
echo "reporter=${REPORTER}"
echo "directed=${DIRECTED}"
} >> "$GITHUB_OUTPUT"
- name: Transition label to triage/reproducing
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
run: |
set -euo pipefail
# Remove any existing bot:* label; swallow 404s (label may not be present).
for L in bot:repro triage/reproducing triage/reproduced triage/by-design triage/awaiting-reporter triage/verified triage/not-reproduced triage/skipped triage/failed; do
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "$L" >/dev/null 2>&1 || true
done
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --add-label "triage/reproducing"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
cache: "pnpm"
# The repro-admin and repro-public skills drive a real browser via
# `bgproc` (boots `pnpm dev`) and `agent-browser`. They
# are not project dependencies, so install them globally here rather
# than letting the agent burn tokens discovering and self-installing
# them mid-run. PATH is inherited by the agent's local() sandbox, so
# these land on the agent's bash PATH. `agent-browser install`
# fetches the browser binary.
- name: Install browser automation tools
run: |
npm install -g bgproc agent-browser
agent-browser install
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install Flue agent dependencies
run: pnpm install --frozen-lockfile
working-directory: .flue
- name: Build packages
run: pnpm build
- name: Build agent payload
id: payload
env:
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
ISSUE_TITLE: ${{ steps.ctx.outputs.title }}
run: |
set -euo pipefail
# Body and retry context come from /tmp files written by the
# ctx step, not $GITHUB_OUTPUT -- $GITHUB_OUTPUT with a fixed
# heredoc delimiter is a step-output injection vector when
# the content is attacker-controlled (issue body, retry text).
# jq --rawfile reads the file directly, so we never have to
# quote or escape the content in shell.
PAYLOAD="$(jq -nc \
--argjson n "$ISSUE_NUMBER" \
--arg t "$ISSUE_TITLE" \
--rawfile b /tmp/ctx-body.txt \
--rawfile r /tmp/ctx-retry.txt \
--rawfile d /tmp/ctx-directive.txt \
'{issueNumber: $n, issueTitle: $t, issueBody: $b, owner: "emdash-cms", repo: "emdash"} + (if $r == "" then {} else {retryContext: $r} end) + (if $d == "" then {} else {maintainerDirective: $d} end)')"
# Write payload to file rather than $GITHUB_OUTPUT to avoid the
# 1MB output cap on large issue bodies and to keep raw JSON out
# of step logs.
printf '%s' "$PAYLOAD" > /tmp/agent-payload.json
echo "path=/tmp/agent-payload.json" >> "$GITHUB_OUTPUT"
- name: Run Flue investigate agent
id: agent
timeout-minutes: 50
# Sandbox token is the workflow-scoped GITHUB_TOKEN (read-only here).
# Orchestrator token is the app token. The agent's local() sandbox
# picks up AGENT_GH_TOKEN as GH_TOKEN; the orchestrator token is
# intentionally NOT exposed to the sandbox.
env:
AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
# The workflow writes its assembled result here on clean
# completion; the parse step reads it directly instead of
# scraping the result back out of stdout.
INVESTIGATE_RESULT_PATH: /tmp/agent-result.json
run: |
set -o pipefail
PAYLOAD="$(cat /tmp/agent-payload.json)"
# Sanity-check what the agent's session will see. Flue reads
# AGENTS.md from the sandbox cwd (repo root) at session init;
# if it is missing here the agent starts with no repo context.
echo "agent cwd: $(pwd)"
echo "AGENTS.md at cwd: $([ -f AGENTS.md ] && echo present || echo MISSING)"
set +e
# `flue run` writes structured log events and the workflow
# result to stdout, and human-readable progress to stderr. Tee
# stderr to both the workflow log (so progress is visible live,
# not just dumped at end-of-step) and a file, while keeping
# stdout clean for the JSON parse step.
# Run `flue run` from the repo root (not from .flue/). Two
# reasons:
# 1. Flue resolves `--root .flue` relative to the caller's
# cwd. `pnpm --dir .flue` would compose to `.flue/.flue`
# and the build fails with "No agent or workflow files
# found." (Observed on the first live run.)
# 2. The agent's `local()` sandbox inherits process.cwd()
# as its working directory. We want that to be the
# EmDash repo root so the agent's bash tool can `pnpm
# test`, `git`, `gh issue view`, etc. against the
# EmDash checkout.
#
# Invoke the flue binary directly from .flue/'s installed
# node_modules; pnpm's `--dir` semantics are exactly what
# broke us originally.
.flue/node_modules/.bin/flue run investigate \
--target node \
--root .flue \
--payload "$PAYLOAD" \
> /tmp/agent-stdout.json 2> >(tee /tmp/agent-stderr.log >&2)
EXIT=$?
set -e
echo "exit=$EXIT" >> "$GITHUB_OUTPUT"
echo "--- agent stdout (first 200 lines) ---"
head -n 200 /tmp/agent-stdout.json || true
echo "--- end preview ---"
- name: Parse agent result
if: always()
id: parse
env:
AGENT_EXIT: ${{ steps.agent.outputs.exit }}
DIRECTED: ${{ steps.ctx.outputs.directed }}
run: |
set -euo pipefail
# The workflow writes its assembled result to
# /tmp/agent-result.json (INVESTIGATE_RESULT_PATH) on clean
# completion. A non-zero exit or a missing/empty file means the
# run did not finish -- treat it as failed.
if [[ "${AGENT_EXIT:-1}" != "0" ]] || [[ ! -s /tmp/agent-result.json ]]; then
echo "outcome=failed" >> "$GITHUB_OUTPUT"
exit 0
fi
# Defensive: confirm the file is a single JSON object before the
# downstream `jq` reads. The workflow controls this file, so a
# malformed one indicates a bug, not adversarial input.
if ! jq -e 'type == "object"' /tmp/agent-result.json >/dev/null 2>&1; then
echo "::warning::result file is not a JSON object"
echo "outcome=failed" >> "$GITHUB_OUTPUT"
exit 0
fi
SKIPPED="$(jq -r '.skipped // false' /tmp/agent-result.json)"
REPRODUCED="$(jq -r '.reproduced // false' /tmp/agent-result.json)"
FIXED="$(jq -r '.fixed // false' /tmp/agent-result.json)"
VERDICT="$(jq -r '.verdict // ""' /tmp/agent-result.json)"
if [[ "$SKIPPED" == "true" ]]; then
OUTCOME=skipped
elif [[ "$REPRODUCED" != "true" ]]; then
OUTCOME=not-reproduced
elif [[ "$FIXED" == "true" ]]; then
OUTCOME=fixed
elif [[ "$VERDICT" == "intended-behavior" && "$DIRECTED" != "true" ]]; then
# A directed run overrides the intended-behavior judgment (the flue
# agent already skips its early return), so it should never land in
# by-design. If its fix was abandoned it falls through to the
# reproduced branch, which carries the directed-aware wording.
OUTCOME=intended-behavior
else
OUTCOME=reproduced
fi
echo "outcome=$OUTCOME" >> "$GITHUB_OUTPUT"
# ----- Outcome branches: skipped -----
- name: Handle skipped
if: steps.parse.outputs.outcome == 'skipped'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
DIRECTED: ${{ steps.ctx.outputs.directed }}
run: |
set -euo pipefail
REASON="$(jq -r '.reason // .notes // "No reason provided."' /tmp/agent-result.json)"
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/skipped"
{
if [[ "$DIRECTED" == "true" ]]; then
echo "I couldn't carry out the directive: the reproduction step was skipped, so there's no way to verify a fix."
else
echo "The investigation bot declined to reproduce this issue."
fi
echo
echo "**Reason:** ${REASON}"
echo
echo "<sub>Run: $RUN_URL</sub>"
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Outcome branches: not-reproduced -----
- name: Handle not-reproduced
if: steps.parse.outputs.outcome == 'not-reproduced'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
DIRECTED: ${{ steps.ctx.outputs.directed }}
run: |
set -euo pipefail
ATTEMPTS="$(jq -r '.attempts // "The bot tried the steps described in the issue but could not trigger the bug."' /tmp/agent-result.json)"
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/not-reproduced"
{
if [[ "$DIRECTED" == "true" ]]; then
echo "I tried to implement the directive but couldn't reproduce the issue to verify a fix against."
else
echo "The investigation bot could not reproduce this issue."
fi
echo
echo "**What was tried:**"
echo
echo "${ATTEMPTS}"
echo
echo "If you can share a minimal reproduction (failing test, repo, or video), please add it and a maintainer can re-trigger the bot."
echo
echo "<sub>Run: $RUN_URL</sub>"
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Outcome branches: reproduced but verdict is intended-behavior -----
- name: Handle reproduced (intended-behavior)
if: steps.parse.outputs.outcome == 'intended-behavior'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
run: |
set -euo pipefail
NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)"
# `triage/by-design` (not `triage/reproduced`): the bot
# reproduced the described behavior but believes it is
# intentional. This is a "likely close / convert to discussion"
# signal, the opposite follow-up from a confirmed bug, so it
# gets its own label rather than sharing triage/reproduced.
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/by-design"
{
echo "The investigation bot reproduced the described behavior, but it appears to be intended."
echo
echo "**Analysis:**"
echo
echo "${NOTES}"
echo
echo "A maintainer will follow up to confirm whether this is a bug or a documentation/UX gap."
echo
echo "<sub>Run: $RUN_URL</sub>"
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Outcome branches: reproduced but no fix yet -----
- name: Handle reproduced (no fix)
if: steps.parse.outputs.outcome == 'reproduced'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
DIRECTED: ${{ steps.ctx.outputs.directed }}
run: |
set -euo pipefail
NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)"
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/reproduced"
{
if [[ "$DIRECTED" == "true" ]]; then
# A maintainer directed an implementation but the fix stage still
# came back empty (the fix agent read the code and abandoned, or
# the directive couldn't be carried out). Say so plainly rather
# than the default "a maintainer will pick up" line.
echo "I tried to implement the directive but couldn't produce a verified fix."
echo
echo "${NOTES}"
echo
echo "The issue stays in \`triage/reproduced\`. Refine the directive and reply again, or pick it up by hand."
else
echo "The investigation bot reproduced this issue."
echo
echo "${NOTES}"
echo
echo "A maintainer will pick up the fix from here."
fi
echo
echo "<sub>Run: $RUN_URL</sub>"
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Outcome branches: reproduced AND fixed -----
- name: Handle reproduced + fixed
if: steps.parse.outputs.outcome == 'fixed'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
ISSUE_TITLE: ${{ steps.ctx.outputs.title }}
REPORTER: ${{ steps.ctx.outputs.reporter }}
run: |
set -euo pipefail
# Re-check issue state before any GitHub writes. The agent
# ran for up to 50 minutes; in that window the issue may
# have been closed (manually, or by bot-cleanup.yml on a
# different trigger). Pushing branches and commenting on a
# closed issue would be noise; bot-cleanup.yml would then
# leave a dangling branch the close trigger already missed.
ISSUE_STATE="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '.state')"
if [[ "$ISSUE_STATE" != "open" ]]; then
echo "::warning::issue #${ISSUE_NUMBER} is ${ISSUE_STATE}; skipping branch push and comment"
exit 0
fi
NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)"
COMMIT_MSG="$(jq -r '.commitMessage // ("fix: address #" + (.classification.summary // ""))' /tmp/agent-result.json)"
FIX_BRANCH="bot/fix-${ISSUE_NUMBER}"
ART_BRANCH="bot/artifacts-${ISSUE_NUMBER}"
# Build the screenshot markdown block. URLs point at the
# orphan artifact branch on the emdash repo, not this PR's
# branch.
#
# Defense in depth on the agent's structured output:
# - filename: regex-validated against [a-zA-Z0-9._-]+, max
# 80 chars. Anything that fails is dropped from the
# comment (the screenshot is still on the artifact
# branch; just not rendered). Prevents URL injection
# and path traversal.
# - description: any `]`, `[`, `(`, `)`, `\` MD-escaped
# with a `\` prefix so the alt-text span can't be
# broken out of.
SHOTS_MD="$(jq -r --arg branch "$ART_BRANCH" '
def md_escape: gsub("([\\\\\\[\\]()])"; "\\\\\\1");
(.screenshots // [])
| map(select((.filename // "") | test("^[a-zA-Z0-9._-]{1,80}$")))
| map(
"![" + ((.description // .filename) | md_escape) + "](https://raw.githubusercontent.com/emdash-cms/emdash/" + $branch + "/.bot-artifacts/" + .filename + ")"
)
| join("\n\n")
' /tmp/agent-result.json)"
# Configure git identity and a GIT_ASKPASS shim so the app
# token is never visible on a process command line.
git config --global user.name "emdashbot[bot]"
git config --global user.email "emdashbot[bot]@users.noreply.github.com"
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
chmod +x "$GIT_ASKPASS"
ORIGIN_URL="https://x-access-token@github.com/emdash-cms/emdash.git"
# Commit the staged fix onto bot/fix-<n>. The agent did
# `git add -A` for the fix files (per skills/fix/SKILL.md);
# we move .bot-artifacts off the index before committing so
# screenshots never land on the fix branch.
git reset HEAD .bot-artifacts 2>/dev/null || true
git checkout -B "$FIX_BRANCH"
git commit -m "$COMMIT_MSG" || {
echo "::warning::no staged changes to commit on $FIX_BRANCH"
}
git remote remove emdash-fix-origin 2>/dev/null || true
git remote add emdash-fix-origin "$ORIGIN_URL"
# Plain --force is intentional: every bot run regenerates the
# fix from scratch on top of current main. Prior bot commits
# on this branch are discarded. --force-with-lease without a
# tracked remote ref would not protect anything here (we
# never fetched bot/fix-N), and using it would falsely
# signal we're protecting against concurrent edits.
git push --force emdash-fix-origin "HEAD:refs/heads/${FIX_BRANCH}"
# Push the artifact branch as an orphan with only the
# screenshots. Uses a separate working tree so we don't
# disturb the fix branch state.
if [[ -d .bot-artifacts ]] && [[ -n "$(ls -A .bot-artifacts 2>/dev/null)" ]]; then
ART_TMP="$RUNNER_TEMP/artifacts-${ISSUE_NUMBER}"
rm -rf "$ART_TMP"
mkdir -p "$ART_TMP/.bot-artifacts"
cp -r .bot-artifacts/. "$ART_TMP/.bot-artifacts/"
(
cd "$ART_TMP"
git init -q -b "$ART_BRANCH"
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git add .bot-artifacts
git commit -q -m "screenshots for #${ISSUE_NUMBER}"
git remote add origin "$ORIGIN_URL"
git push --force origin "HEAD:refs/heads/${ART_BRANCH}"
)
fi
rm -f "$GIT_ASKPASS"
# Build the install command from the branch name. The
# preview-releases.yml workflow publishes a pkg.pr.new release on
# every push to bot/fix-*. pkg.pr.new keys branch resolution by the
# *full* branch name, so use "$FIX_BRANCH" verbatim -- stripping the
# "bot/" prefix (e.g. "fix-123") produces a URL that 404s.
INSTALL_CMD="npm i https://pkg.pr.new/emdash@${FIX_BRANCH}"
# ISO-8601 timestamp embedded in the comment as a hidden
# HTML marker. reporter-reply.yml uses this to verify that a
# negative or positive reply was posted AFTER this most-
# recent ask. Replies posted to an earlier ask (about a
# previous fix candidate) are ignored as stale.
ASK_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
echo "<!-- bot-ask: ${ASK_AT} -->"
echo "The investigation bot reproduced this issue and pushed a candidate fix."
echo
echo "${NOTES}"
echo
echo "**Try the fix** _(the preview release may take ~60s to publish after the bot pushes its branch -- if `npm i` 404s, wait a moment and retry)_:"
echo
echo '```bash'
echo "${INSTALL_CMD}"
echo '```'
echo
if [[ -n "$SHOTS_MD" ]]; then
echo "**Screenshots:**"
echo
echo "${SHOTS_MD}"
echo
fi
if [[ -n "$REPORTER" ]]; then
echo "@${REPORTER} could you try this and reply here with whether it resolves the issue? A simple \"yes, fixed\" or \"no, still broken\" is enough."
else
echo "Could the reporter please try this and reply with whether it resolves the issue?"
fi
echo
# Maintainer directives. reporter-reply.yml only acts on a
# non-reporter comment when it carries one of these at the
# START of a line, so the keywords go in `code` spans (which
# don't form @-mentions and so won't trip the directive parser
# on this very comment -- belt-and-suspenders alongside the
# bot-author exclusion there).
echo "<sub>**Maintainers** can act on the reporter's behalf: start a line with <code>@emdashbot confirm</code> to accept the fix and open a PR, or <code>@emdashbot reject</code> (optionally with details) to re-run the investigation.</sub>"
echo
echo "Fix branch: \`${FIX_BRANCH}\` · Artifacts branch: \`${ART_BRANCH}\`"
echo
echo "<sub>Run: $RUN_URL</sub>"
} > /tmp/comment.md
# Order matters: post the ask comment FIRST, transition the
# label only after the comment succeeds. If we flipped to
# `triage/awaiting-reporter` before posting and the comment
# then failed, reporter-reply.yml would see an issue in the
# awaiting state with no current `bot-ask` marker, treat
# every future reply as stale, and the issue would be stuck
# until a maintainer noticed.
if ! gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md; then
echo "::warning::ask-comment post failed; transitioning to triage/failed instead of triage/awaiting-reporter"
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/reproducing" --add-label "triage/failed" || true
exit 1
fi
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/reproducing" --add-label "triage/awaiting-reporter"
# ----- Outcome branches: agent failed / no parseable result -----
- name: Handle agent failure
if: failure() || steps.parse.outputs.outcome == 'failed'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
run: |
set -euo pipefail
if [[ -z "${ISSUE_NUMBER:-}" ]]; then
echo "No issue number resolved; nothing to comment on."
exit 0
fi
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/failed" || true
{
echo "The investigation bot ran into a problem and could not complete."
echo
echo "A maintainer can re-trigger by removing and re-applying the \`bot:repro\` label."
echo
echo "<sub>Run: $RUN_URL</sub>"
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
+30
View File
@@ -0,0 +1,30 @@
name: Lunaria
on:
pull_request_target:
types: [opened, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
lunaria-overview:
name: Translation Overview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: lunariajs/action@a0594f1c5b8d55fb367d8df607d5e2541c99e77d # v1-prerelease
+408
View File
@@ -0,0 +1,408 @@
name: Maintainer Reply
# When an issue is in a pre-fix triage state (`triage/reproduced` or
# `triage/by-design`) and an authorized maintainer addresses `@emdashbot` with
# a freeform directive, classify the intent via a small Flue classifier and
# act: dispatch a directed investigate run to implement the chosen approach,
# flag it as by-design, disengage, or ask for clarification.
#
# This covers the gap reporter-reply.yml does not: there, a fix already exists
# on `bot/fix-<n>` and the question is "does it work?" (confirm / reject). Here
# the bot reproduced the issue but deferred the fix (e.g. diagnose returned
# `needs-design-decision` with options), and the maintainer is making that
# call. The two workflows gate on disjoint label states, so they never both
# fire on one comment.
#
# A produced fix routes through the normal awaiting-reporter loop -- this
# workflow only gets the issue from `reproduced` to a fix attempt; reporter-
# reply.yml owns everything after.
on:
issue_comment:
types: [created]
# Default-deny at workflow level.
permissions:
contents: read
jobs:
classify-and-act:
name: Classify directive and act
# Coarse `if:` -- cheap, reliable payload-only filters, matching
# reporter-reply.yml's philosophy:
# - the comment is on an issue (not a PR -- issue_comment fires for both)
# - the commenter is not a bot (excludes emdashbot's own comments, which
# would otherwise re-trigger the classifier in a loop)
# - the issue is in a pre-fix state this workflow acts on
#
# Authorization (a real write/triage role) and the `@emdashbot` wake word
# are checked in live-check. `author_association` from the payload is
# unreliable for the role check -- a maintainer with private org membership
# reports `NONE` -- so it is not gated on here.
if: >-
github.event.issue.pull_request == null
&& github.event.comment.user.type != 'Bot'
&& (contains(github.event.issue.labels.*.name, 'triage/reproduced')
|| contains(github.event.issue.labels.*.name, 'triage/by-design'))
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: maintainer-reply-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
# All writes (labels, comment, repository_dispatch) use the app token
# below. No PRs are opened here, so no pull-requests scope is needed.
contents: read
issues: read
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: emdash-cms
repositories: emdash
permission-issues: write
permission-contents: write
# Re-verify live state before any expensive work. Three checks:
#
# 1. The issue is still in a pre-fix state this workflow acts on
# (`triage/reproduced` or `triage/by-design`). The job `if:` uses
# the dispatch-time label snapshot; a label may have moved since.
# Concurrency only serialises replies, it does not re-read state.
#
# 2. The commenter is authorized: a real admin/write/triage role on the
# repo, checked against the permission API rather than the
# spoof-prone-by-omission `author_association` in the payload.
#
# 3. The comment opts in with an `@emdashbot` directive at the START of
# a line (leading whitespace only) so a directive quoted from
# another comment (`> @emdashbot ...`) does not count. Without the
# wake word, ordinary maintainer chatter on a triage thread would
# kick off an expensive classify+investigate on every comment.
- name: Re-verify live state
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENTER: ${{ github.event.comment.user.login }}
REPLY_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
# Capture which pre-fix state the issue is in -- handlers word their
# comments and label flips differently for reproduced vs by-design.
LABELS="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '[.labels[].name] | join(",")')"
if grep -q 'triage/reproduced' <<<"$LABELS"; then
STATE="reproduced"
elif grep -q 'triage/by-design' <<<"$LABELS"; then
STATE="by-design"
else
echo "::notice::issue #${ISSUE_NUMBER} is no longer in a pre-fix state (live labels: ${LABELS}); skipping stale reply event"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ---- Authorization: real write-or-triage role on the repo ----
#
# Gate on BOTH fields the endpoint returns:
# * `permission` -- the legacy BASE role (admin/write/read/none),
# with maintain mapped to write and triage mapped to read. Custom
# org roles collapse to their base here, so a write-equivalent
# custom role is caught by `write`.
# * `role_name` -- needed only to recognise `triage` specifically
# (it maps down to `read` in `permission`).
# A 404 (no access) leaves both empty. The read is authorized by the
# token's contents:write (push-equivalent) scope.
PERM_JSON="$(gh api "/repos/emdash-cms/emdash/collaborators/${COMMENTER}/permission" 2>/dev/null || true)"
PERM="$(jq -r '.permission // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
ROLE="$(jq -r '.role_name // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
if [[ "$PERM" != "admin" && "$PERM" != "write" && "$ROLE" != "triage" ]]; then
echo "::notice::commenter ${COMMENTER} has permission '${PERM:-none}' / role '${ROLE:-none}' on emdash (need write or triage); ignoring"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ---- Wake word: an `@emdashbot` directive starting a line ----
if ! grep -iqE '^[[:space:]]*@emdashbot\b' <<<"$REPLY_BODY"; then
echo "::notice::maintainer ${COMMENTER} commented without an '@emdashbot' directive; taking no action"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "state=${STATE}" >> "$GITHUB_OUTPUT"
echo "stale=false" >> "$GITHUB_OUTPUT"
id: live-check
- name: Checkout
if: steps.live-check.outputs.stale != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: Setup pnpm
if: steps.live-check.outputs.stale != 'true'
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
if: steps.live-check.outputs.stale != 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
cache: "pnpm"
- name: Install root dependencies
if: steps.live-check.outputs.stale != 'true'
run: pnpm install --frozen-lockfile
- name: Install Flue agent dependencies
if: steps.live-check.outputs.stale != 'true'
run: pnpm install --frozen-lockfile
working-directory: .flue
- name: Build packages
if: steps.live-check.outputs.stale != 'true'
run: pnpm build
- name: Build classifier payload
if: steps.live-check.outputs.stale != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPLY_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
# The latest emdashbot[bot] comment is the bot's investigation, so the
# classifier can resolve references like "option A" or "the second
# one". Bot-authored and only ever fed to the model, so semi-trusted;
# write to a file and pass via --rawfile rather than an env var.
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
| jq -r '[ .[] | .[] | select(.user.login == "emdashbot[bot]") | .body ] | last // ""' \
> /tmp/bot-context.txt
jq -nc \
--argjson n "$ISSUE_NUMBER" \
--arg b "$REPLY_BODY" \
--rawfile c /tmp/bot-context.txt \
'{replyBody: $b, issueNumber: $n, botContext: $c, owner: "emdash-cms", repo: "emdash"}' \
> /tmp/classify-payload.json
- name: Run classifier
if: steps.live-check.outputs.stale != 'true'
id: classify
timeout-minutes: 10
env:
AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
# The workflow writes its result here; we read it directly instead of
# scraping `flue run`'s stdout, which interleaves build-log lines and
# pretty-prints the result -- both defeat parsing and silently default
# to `unclear`. Same handoff as investigate.yml's INVESTIGATE_RESULT_PATH.
CLASSIFY_RESULT_PATH: /tmp/classify-result.json
run: |
set -o pipefail
RESULT_PATH="${CLASSIFY_RESULT_PATH:?CLASSIFY_RESULT_PATH not set}"
PAYLOAD="$(cat /tmp/classify-payload.json)"
rm -f "$RESULT_PATH"
set +e
# See investigate.yml's "Run Flue investigate agent" step for why we
# invoke the binary directly rather than via `pnpm --dir`.
.flue/node_modules/.bin/flue run classify-maintainer-reply \
--target node \
--root .flue \
--payload "$PAYLOAD" \
> /tmp/classify-stdout.json 2> /tmp/classify-stderr.log
EXIT=$?
set -e
: > /tmp/directive.txt
: > /tmp/classify-reasoning.txt
# A clean run writes a single JSON object to the result file. A
# non-zero exit, a missing file, or a non-object means the run did
# not finish -- default to unclear (which re-asks, never acts).
if [[ $EXIT -ne 0 ]] || [[ ! -s "$RESULT_PATH" ]] || ! jq -e 'type == "object"' "$RESULT_PATH" >/dev/null 2>&1; then
echo "::warning::classifier exit=${EXIT} or no result file; defaulting to unclear"
tail -n 50 /tmp/classify-stderr.log || true
echo "intent=unclear" >> "$GITHUB_OUTPUT"
exit 0
fi
# Whitelist the intent -- the handler gate must be a known enum or we
# treat it as unclear. Defends against an unexpected model value.
INTENT_RAW="$(jq -r '.intent // "unclear"' "$RESULT_PATH" | tr -d '\r\n')"
case "$INTENT_RAW" in
implement|close|takeover|unclear) INTENT="$INTENT_RAW" ;;
*) INTENT="unclear" ;;
esac
# Directive and reasoning are model output shaped by the maintainer's
# comment. Persist to files, never $GITHUB_OUTPUT -- a heredoc with a
# fixed delimiter would be a step-output injection vector if either
# contained the delimiter on its own line. The directive is later
# JSON-escaped into the dispatch payload; it is never interpolated
# into a command or used to build an identifier.
jq -r '.directive // ""' "$RESULT_PATH" > /tmp/directive.txt
jq -r '.reasoning // ""' "$RESULT_PATH" > /tmp/classify-reasoning.txt
echo "intent=${INTENT}" >> "$GITHUB_OUTPUT"
# Combine intent + directive presence into the `action` the handlers
# gate on. `implement` only acts if the classifier actually extracted a
# directive. An empty directive (the maintainer said "go ahead" without
# naming an approach) cannot override the fix gate, so it would just
# reproduce again; route it to `unclear` to ask for specifics instead.
- name: Resolve action
if: steps.live-check.outputs.stale != 'true'
id: resolve
env:
INTENT: ${{ steps.classify.outputs.intent }}
run: |
set -euo pipefail
case "$INTENT" in
implement)
if [[ -s /tmp/directive.txt ]] && grep -q '[^[:space:]]' /tmp/directive.txt; then
ACTION="implement"
else
ACTION="unclear"
fi
;;
close) ACTION="close" ;;
takeover) ACTION="takeover" ;;
*) ACTION="unclear" ;;
esac
echo "action=${ACTION}" >> "$GITHUB_OUTPUT"
# ----- Implement: dispatch a directed investigate run -----
- name: Handle implement
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'implement'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
STATE: ${{ steps.live-check.outputs.state }}
COMMENTER: ${{ github.event.comment.user.login }}
REPO_FULL: ${{ github.repository }}
run: |
set -euo pipefail
# Fire a `maintainer-directive` repository_dispatch (not
# `gh workflow run`): firing repository_dispatch needs only
# contents:write, which the app token has, whereas workflow_dispatch
# needs actions:write, which the emdashbot App is not granted.
# investigate.yml reads issueNumber / directive from client_payload.
# The directive is read from a file via --rawfile so it is
# JSON-escaped, never interpolated into the command.
#
# Dispatch first, then flip the label. Order matters for recovery: if
# dispatch fails, the label stays put so the maintainer can simply
# reply again, rather than the issue getting stuck in a reproducing
# state with nothing running.
set +e
jq -nc \
--arg n "$ISSUE_NUMBER" \
--rawfile d /tmp/directive.txt \
'{event_type: "maintainer-directive", client_payload: {issueNumber: $n, directive: $d}}' \
| gh api --method POST "/repos/${REPO_FULL}/dispatches" --input -
DISPATCH_EXIT=$?
set -e
if [[ $DISPATCH_EXIT -ne 0 ]]; then
echo "::warning::repository_dispatch failed (exit ${DISPATCH_EXIT}); leaving label on triage/${STATE}"
{
echo "@${COMMENTER} I tried to start the implementation but the dispatch failed. Reply again to retry, or pick it up by hand."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
exit 0
fi
# Dispatch succeeded. Flip the current pre-fix state label to
# triage/reproducing so the in-flight investigation claims the issue
# and a second directive during that window passes the live-state
# check to a no-op. The dispatched investigate.yml re-asserts
# reproducing idempotently at its transition step. Retry the flip a
# few times; if it never lands, investigate.yml will flip it itself.
FLIP_OK=false
for ATTEMPT in 1 2 3; do
if gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/${STATE}" --add-label "triage/reproducing"; then
FLIP_OK=true
break
fi
echo "::warning::label flip attempt ${ATTEMPT} failed, retrying"
sleep $((ATTEMPT * 2))
done
if [[ "$FLIP_OK" != "true" ]]; then
echo "::warning::label flip failed 3 times; relying on investigate.yml's transition step"
fi
{
echo "On it, @${COMMENTER} — implementing your directive and re-running the investigation. I'll push a candidate fix and ask for confirmation when it's ready."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
# ----- Close: flag as by-design; the bot never closes the issue itself -----
- name: Handle close
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'close'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
STATE: ${{ steps.live-check.outputs.state }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
if [[ "$STATE" != "by-design" ]]; then
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/${STATE}" --add-label "triage/by-design"
fi
{
echo "Flagged as by-design per @${COMMENTER}."
# Reasoning is multi-line model output; read from the file and
# block-quote every line (a bare echo would quote only the first).
if grep -q '[^[:space:]]' /tmp/classify-reasoning.txt; then
echo
sed 's/^/> /' /tmp/classify-reasoning.txt
fi
echo
echo "I don't close issues automatically — close it whenever you're ready."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Takeover: disengage so the bot stops acting on this issue -----
- name: Handle takeover
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'takeover'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
STATE: ${{ steps.live-check.outputs.state }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
# Drop the pre-fix state label so this workflow no longer fires on
# the issue (the job `if:` requires reproduced/by-design).
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/${STATE}"
{
echo "Disengaging — over to you, @${COMMENTER}. Re-apply \`bot:repro\` if you want the bot back on it."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Unclear: ask for a concrete directive, no state change -----
- name: Handle unclear
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'unclear'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
{
echo "@${COMMENTER} I couldn't tell what you'd like me to do. You can:"
echo
echo "- **Implement a fix** — \`@emdashbot implement <which approach>\` (name the option or the change you want)."
echo "- **Flag as by-design** — \`@emdashbot this is by design\`."
echo "- **Take it over** — \`@emdashbot I'll handle this\`."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
@@ -0,0 +1,173 @@
name: Playground Preview
# Workers Builds runs `wrangler preview` for the playground demo (it can't use
# the standard preview-URL flow because the playground has a Durable Object).
# These are a private beta feature, and don't currently support automatic PR
# comments with the preview URL.
# The cloudflare-workers-and-pages bot posts a "build successful" comment when
# the build finishes, but it doesn't include the preview URL (preview URLs are
# a `wrangler versions upload` concept, not `wrangler preview`).
# This workflow is a workaround to post the preview URL in the PR description.
#
# The playground is the primary "try this PR" surface for emdash: each visit
# gets its own session-scoped Durable Object, so reviewers can poke at a full
# working admin without signup, login, or shared state. That makes the preview
# link the single most useful thing in the PR -- but a sticky comment posted
# after the build would land below the fold (CF bot, pkg-pr-new, changeset-bot,
# etc.). So instead we edit the PR description to insert a managed block.
#
# Trigger: the CF bot's "Deployment successful" edit, scoped to emdash-playground.
# This means we comment when the deploy is genuinely live, not just when the
# commit was pushed.
#
# The branch preview URL is fully deterministic from the branch name and the
# worker/account names, so no Cloudflare API token is required -- only the
# default GITHUB_TOKEN.
#
# Caveats:
# - Branch slugs longer than the (private-beta, undocumented) max length get
# truncated server-side; collisions get a random 6-char suffix appended.
# In practice this is fine for emdash branch names. If the URL 404s, check
# the dash.
# - issue_comment workflows run from `main`, not the PR branch. Changes to
# this file only take effect once merged.
# - This won't fire for PRs from forks where the fork doesn't have the
# workflow file. That's fine -- the playground builds only run for the
# internal repo, not forks.
on:
issue_comment:
types: [created, edited]
permissions: {}
# The Cloudflare bot edits its comment 4-5 times during a build (queued ->
# initializing -> running -> ... -> successful). The "successful" edit is
# usually the terminal state, but a subsequent edit could race a still-running
# workflow that's mid-fetch. Serialize per PR; cancel in-flight runs so only
# the latest comment state is processed.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
update-body:
name: Update PR body
runs-on: ubuntu-latest
# Only react to:
# - PR comments (not issue comments)
# - the CF bot's comment
# - that mentions the playground worker
# - and contains the deployment-success marker
if: >-
github.event.issue.pull_request != null &&
github.event.comment.user.login == 'cloudflare-workers-and-pages[bot]' &&
contains(github.event.comment.body, 'emdash-playground') &&
contains(github.event.comment.body, 'Deployment successful!')
permissions:
pull-requests: write # read PR body and update it with the playground block; no PR code is checked out
steps:
- name: Update PR description with playground link
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BOT_COMMENT_BODY: ${{ github.event.comment.body }}
PR_NUMBER: ${{ github.event.issue.number }}
with:
script: |
const { BOT_COMMENT_BODY, PR_NUMBER } = process.env;
const prNumber = Number(PR_NUMBER);
// Confirm the playground row itself is in the successful state.
// Each row in the bot's comment looks like:
// | ✅ Deployment successful! ... | emdash-playground | <sha> | ... |
const playgroundRow = BOT_COMMENT_BODY.split("\n").find((line) =>
line.includes("| emdash-playground |"),
);
if (!playgroundRow || !playgroundRow.includes("✅ Deployment successful!")) {
core.info("Playground row not in successful state; skipping.");
return;
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const branch = pr.head.ref;
// Slug rules (from SPEC: Worker Previews): lowercase, with /, .,
// +, =, _ replaced by -. We widen this to any non-DNS-safe char
// (handles Renovate-style `@`, unusual community branches, etc.):
// anything outside [a-z0-9-] becomes -, repeated -- collapsed,
// leading/trailing - trimmed. If we end up with an empty slug
// (e.g. branch was all special chars), bail rather than emit a
// broken URL.
const slug = branch
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
if (!slug) {
core.warning(`Branch "${branch}" produced an empty slug; skipping.`);
return;
}
const url = `https://${slug}-emdash-playground.emdash-cms.workers.dev`;
const START = "<!-- emdash:playground-preview:start -->";
const END = "<!-- emdash:playground-preview:end -->";
// The managed block. Kept short and inviting -- the link is the
// point. Each visit to the playground gets its own session-scoped
// Durable Object, so reviewers can play freely.
const block = [
START,
"",
"---",
"",
`### Try this PR`,
"",
`**[Open a fresh playground →](${url})**`,
"",
`A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.`,
"",
`<sub>Tracks \`${branch}\`. Updated automatically when the playground redeploys.</sub>`,
"",
END,
].join("\n");
const existingBody = pr.body ?? "";
// If a block already exists, replace it in place (preserves
// whatever position the author or a previous run put it in).
// Otherwise append it to the end of the description.
const blockRegex = new RegExp(
`\\n*${escapeRegex(START)}[\\s\\S]*?${escapeRegex(END)}\\n*`,
);
let newBody;
if (blockRegex.test(existingBody)) {
newBody = existingBody.replace(blockRegex, `\n\n${block}\n`);
core.info("Replaced existing playground block in PR body.");
} else {
// Append, with a blank line separator.
const trimmed = existingBody.replace(/\s+$/, "");
newBody = trimmed.length > 0 ? `${trimmed}\n\n${block}\n` : `${block}\n`;
core.info("Appended playground block to PR body.");
}
if (newBody === existingBody) {
core.info("PR body unchanged; skipping update.");
return;
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
body: newBody,
});
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+153
View File
@@ -0,0 +1,153 @@
name: PR Compliance
on:
pull_request_target:
branches: [main]
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
check-pr:
name: Validate PR
# Filter on the PR author, not github.actor: actor becomes a maintainer on
# synchronize/edited events triggered by pushes or merges into a bot branch,
# which lets bot PRs slip past an actor-based check.
if: >-
github.event.pull_request.user.login != 'dependabot[bot]'
&& github.event.pull_request.user.login != 'renovate[bot]'
&& github.event.pull_request.user.login != 'emdashbot[bot]'
&& github.event.pull_request.user.login != 'ask-bonk[bot]'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check PR template
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const body = context.payload.pull_request.body || '';
const pr = context.payload.pull_request;
// Maintainers (repo owners, org members, and collaborators) are trusted
// to land features without a pre-approved Discussion link.
const association = pr.author_association || '';
const isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association);
const errors = [];
// Check if the PR body looks like it used the template at all
const hasTemplate = body.includes('## What does this PR do?') || body.includes('## Type of change');
if (!hasTemplate) {
errors.push('This PR does not use the required PR template. Please edit the description to use the [PR template](https://github.com/emdash-cms/emdash/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Copy it into your PR description and fill out all sections.');
} else {
// Must have a description (not just the raw template placeholder)
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
const description = descriptionSection?.[1]?.replace(/<!--[\s\S]*?-->/g, '').trim() || '';
if (!description || description === 'Closes #') {
errors.push('Fill out the "What does this PR do?" section with a description of your change.');
}
// Must check at least one type
const typeChecks = [
/- \[x\] Bug fix/i,
/- \[x\] Feature/i,
/- \[x\] Refactor/i,
/- \[x\] Translation/i,
/- \[x\] Documentation/i,
/- \[x\] Performance/i,
/- \[x\] Tests/i,
/- \[x\] Chore/i,
];
const hasType = typeChecks.some(re => re.test(body));
if (!hasType) {
errors.push('Check at least one "Type of change" checkbox.');
}
// If Feature is checked, require a discussion link.
// Maintainers are exempt: they can approve their own features.
const isFeature = /- \[x\] Feature/i.test(body);
if (isFeature && !isMaintainer) {
const hasDiscussionLink = /github\.com\/emdash-cms\/emdash\/discussions\/\d+/.test(body);
if (!hasDiscussionLink) {
errors.push('Feature PRs require a link to an approved Discussion (https://github.com/emdash-cms/emdash/discussions/categories/ideas). Open a Discussion first, get approval, then link it in the PR.');
}
}
// Must check the "I have read CONTRIBUTING.md" box. Be lenient about
// formatting: accept the box whether or not CONTRIBUTING.md is still a
// markdown link (authors often strip the link or tweak the wording).
const hasReadContributing = /- \[x\][^\n]*\bI have read\b[^\n]*CONTRIBUTING/i.test(body);
if (!hasReadContributing) {
errors.push('Check the "I have read CONTRIBUTING.md" checkbox.');
}
}
if (errors.length > 0) {
const message = [
'## PR template validation failed',
'',
'Please fix the following issues by editing your PR description:',
'',
...errors.map(e => `- ${e}`),
'',
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for the full contribution policy.',
].join('\n');
// Leave a comment so the author sees the errors directly
// Find and update existing bot comment, or create a new one
const marker = '<!-- pr-compliance-check -->';
const commentBody = `${marker}\n${message}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
const existing = comments.find(c =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: commentBody,
});
}
core.setFailed(message);
} else {
// If passing now, remove any previous failure comment
const marker = '<!-- pr-compliance-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
const existing = comments.find(c =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes(marker)
);
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
}
+288
View File
@@ -0,0 +1,288 @@
name: PR Sweep
on:
schedule:
# Every 6 hours
- cron: "0 */6 * * *"
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
sweep:
name: Sweep Open PRs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Sweep PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const now = new Date();
const DAY = 24 * 60 * 60 * 1000;
// Label color map for auto-creation
const labelColors = {
'needs-approval': 'fbca04',
'needs-rebase': 'e11d48',
'stale': 'ededed',
'overlap': 'c5def5',
};
// Ensure labels exist
const existingLabels = new Set();
for await (const response of github.paginate.iterator(
github.rest.issues.listLabelsForRepo,
{ owner, repo, per_page: 100 }
)) {
for (const label of response.data) {
existingLabels.add(label.name);
}
}
for (const [name, color] of Object.entries(labelColors)) {
if (!existingLabels.has(name)) {
await github.rest.issues.createLabel({ owner, repo, name, color });
}
}
// Get all open PRs
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
core.info(`Sweeping ${prs.length} open PRs`);
// Build a file->PR map for overlap detection
const fileToPRs = new Map();
for (const pr of prs) {
const prLabels = new Set(pr.labels.map(l => l.name));
const toAdd = [];
const toRemove = [];
// --- needs-approval: PR has no CI check runs ---
// (CLA runs via pull_request_target so it always runs)
try {
const { data: checkRuns } = await github.rest.checks.listForRef({
owner,
repo,
ref: pr.head.sha,
per_page: 100,
});
// Filter to only CI-related checks (not CLA, not PR Triage)
const ciChecks = checkRuns.check_runs.filter(c =>
!c.name.includes('CLA') &&
!c.name.includes('Label') &&
!c.name.includes('Triage') &&
!c.name.includes('Validate PR')
);
const prAge = now - new Date(pr.created_at);
if (ciChecks.length === 0 && prAge > 5 * 60 * 1000) {
// No CI checks after 5 minutes -- likely needs approval
if (!prLabels.has('needs-approval')) toAdd.push('needs-approval');
} else {
if (prLabels.has('needs-approval')) toRemove.push('needs-approval');
}
} catch {
// Ignore check run lookup failures
}
// --- needs-rebase: PR has merge conflicts ---
// mergeable is not included in the list endpoint; fetch individually
try {
const { data: fullPr } = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (fullPr.mergeable === false) {
if (!prLabels.has('needs-rebase')) toAdd.push('needs-rebase');
} else if (fullPr.mergeable === true) {
if (prLabels.has('needs-rebase')) toRemove.push('needs-rebase');
}
// mergeable===null means GitHub is still computing; skip
} catch {
// Ignore merge status lookup failures
}
// --- Stale detection ---
const lastActivity = new Date(pr.updated_at);
const daysSinceActivity = (now - lastActivity) / DAY;
if (daysSinceActivity > 21) {
// 21 days with no activity -- close it
if (!prLabels.has('stale')) {
// Should already be labeled stale from 14-day mark, but just in case
toAdd.push('stale');
}
const marker = '<!-- stale-close -->';
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: `${marker}\nThis PR has been inactive for 21 days and is being closed automatically. If you'd like to continue working on it, feel free to reopen it.`,
});
await github.rest.pulls.update({
owner,
repo,
pull_number: pr.number,
state: 'closed',
});
core.info(`#${pr.number}: closed (stale, ${Math.floor(daysSinceActivity)} days inactive)`);
continue; // Skip further processing for closed PRs
} else if (daysSinceActivity > 14) {
// 14 days -- label as stale and warn
if (!prLabels.has('stale')) {
toAdd.push('stale');
const marker = '<!-- stale-warning -->';
// Check if we already left a stale warning
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: pr.number,
per_page: 10,
});
const hasWarning = comments.some(c =>
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
);
if (!hasWarning) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: `${marker}\nThis PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.\n\nIf you're still working on this, please push an update or leave a comment.`,
});
}
}
} else {
// Active -- remove stale label if present
if (prLabels.has('stale')) toRemove.push('stale');
}
// --- Collect files for overlap detection ---
try {
const { data: files } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
for (const file of files) {
if (!fileToPRs.has(file.filename)) {
fileToPRs.set(file.filename, []);
}
fileToPRs.get(file.filename).push(pr.number);
}
} catch {
// Ignore file listing failures
}
// --- Apply label changes ---
if (toAdd.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: toAdd,
});
}
for (const label of toRemove) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name: label,
});
} catch {
// Label might not exist on the PR
}
}
if (toAdd.length > 0 || toRemove.length > 0) {
core.info(`#${pr.number}: +[${toAdd.join(',')}] -[${toRemove.join(',')}]`);
}
}
// --- Overlap detection ---
// Find files changed by multiple PRs
const overlaps = new Map(); // pr number -> set of overlapping PR numbers
for (const [file, prNumbers] of fileToPRs) {
if (prNumbers.length > 1) {
for (const prNum of prNumbers) {
if (!overlaps.has(prNum)) overlaps.set(prNum, new Set());
for (const other of prNumbers) {
if (other !== prNum) overlaps.get(prNum).add(other);
}
}
}
}
// Label PRs with significant overlap (3+ shared files with another PR)
for (const [prNum, otherPRs] of overlaps) {
// Count shared files per overlapping PR
const sharedFileCounts = new Map();
for (const [file, prNumbers] of fileToPRs) {
if (prNumbers.includes(prNum)) {
for (const other of prNumbers) {
if (other !== prNum) {
sharedFileCounts.set(other, (sharedFileCounts.get(other) || 0) + 1);
}
}
}
}
const significantOverlaps = [...sharedFileCounts.entries()]
.filter(([_, count]) => count >= 3)
.map(([otherPR, count]) => `#${otherPR} (${count} shared files)`);
if (significantOverlaps.length > 0) {
const pr = prs.find(p => p.number === prNum);
const prLabels = new Set(pr?.labels.map(l => l.name) || []);
if (!prLabels.has('overlap')) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNum,
labels: ['overlap'],
});
// Leave a comment about the overlap (only once)
const marker = '<!-- overlap-notice -->';
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNum,
per_page: 20,
});
const hasNotice = comments.some(c =>
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
);
if (!hasNotice) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNum,
body: `${marker}\n## Overlapping PRs\n\nThis PR modifies files that are also changed by other open PRs:\n\n${significantOverlaps.map(s => `- ${s}`).join('\n')}\n\nThis may cause merge conflicts or duplicated work. A maintainer will coordinate.`,
});
}
core.info(`#${prNum}: overlap with ${significantOverlaps.join(', ')}`);
}
}
}
+207
View File
@@ -0,0 +1,207 @@
name: PR Triage
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Label PR
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Triage PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const labels = new Set();
// --- Bot detection ---
const botLogins = ['dependabot[bot]', 'renovate[bot]', 'emdashbot[bot]'];
if (botLogins.includes(pr.user.login)) {
labels.add('bot');
}
// --- Size labels ---
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
});
const linesChanged = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
let sizeLabel;
if (linesChanged < 10) sizeLabel = 'size/XS';
else if (linesChanged < 50) sizeLabel = 'size/S';
else if (linesChanged < 200) sizeLabel = 'size/M';
else if (linesChanged < 500) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
labels.add(sizeLabel);
// --- Area labels ---
const areaMap = {
'area/core': (f) => f.startsWith('packages/core/'),
'area/admin': (f) => f.startsWith('packages/admin/'),
'area/plugins': (f) => f.startsWith('packages/plugins/'),
'area/docs': (f) => f.startsWith('docs/'),
'area/templates': (f) => f.startsWith('templates/'),
'area/ci': (f) => f.startsWith('.github/'),
'area/auth': (f) => f.startsWith('packages/auth/'),
'area/cloudflare': (f) => f.startsWith('packages/cloudflare/'),
};
for (const file of files) {
for (const [label, matcher] of Object.entries(areaMap)) {
if (matcher(file.filename)) {
labels.add(label);
}
}
}
// --- Merge conflict detection ---
// mergeable is available on the full PR object (may need a separate fetch
// since the webhook payload sometimes has mergeable=null while computing)
try {
const { data: fullPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (fullPr.mergeable === false) {
labels.add('needs-rebase');
}
} catch {
// Ignore -- mergeable state may not be computed yet
}
// CLA labels are managed by the CLA workflow (cla.yml), not here.
// --- Ensure all labels exist, then apply ---
const labelColors = {
'bot': 'ededed',
'size/XS': '3cbf00',
'size/S': '5dba3f',
'size/M': 'fbca04',
'size/L': 'ee9b00',
'size/XL': 'd93f0b',
'area/core': '0052cc',
'area/admin': '7057ff',
'area/plugins': '008672',
'area/docs': '0075ca',
'area/templates': 'bfdadc',
'area/ci': '000000',
'area/auth': 'd4c5f9',
'area/cloudflare': 'f9a825',
'needs-rebase': 'e11d48',
};
// Get existing labels on the repo
const existingLabels = new Set();
for await (const response of github.paginate.iterator(
github.rest.issues.listLabelsForRepo,
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
)) {
for (const label of response.data) {
existingLabels.add(label.name);
}
}
// Create any missing labels
for (const label of labels) {
if (!existingLabels.has(label) && labelColors[label]) {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: labelColors[label],
});
}
}
// Get current labels on the PR to remove stale ones
const currentLabels = new Set(pr.labels.map(l => l.name));
// Helper: remove a label, ignoring 404 if it's already gone
async function safeRemoveLabel(name) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
// Remove stale size labels (only one size label at a time)
for (const sl of sizeLabels) {
if (sl !== sizeLabel && currentLabels.has(sl)) {
await safeRemoveLabel(sl);
}
}
// Remove needs-rebase if PR is now mergeable
if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) {
await safeRemoveLabel('needs-rebase');
}
// Add new labels
const toAdd = [...labels].filter(l => !currentLabels.has(l));
if (toAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: toAdd,
});
}
core.info(`Applied labels: ${[...labels].join(', ')}`);
// --- Scope guard: comment on oversized PRs ---
// Only on open (not synchronize) to avoid spamming on every push
if (context.payload.action === 'opened') {
const warnings = [];
if (linesChanged > 500) {
warnings.push(`This PR changes **${linesChanged.toLocaleString()} lines** across **${files.length} files**. Large PRs are harder to review and more likely to be closed without review.`);
} else if (files.length > 20) {
warnings.push(`This PR touches **${files.length} files**. PRs with a broad scope are harder to review. Please confirm the scope hasn't drifted beyond the intended change.`);
}
// Check for cross-area changes (touching multiple packages)
const areas = Object.keys(areaMap).filter(a => labels.has(a));
if (areas.length > 3) {
warnings.push(`This PR spans ${areas.length} different areas (${areas.join(', ')}). Consider breaking it into smaller, focused PRs.`);
}
if (warnings.length > 0) {
const marker = '<!-- scope-guard -->';
const body = [
marker,
'## Scope check',
'',
...warnings,
'',
'If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.',
'',
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for contribution guidelines.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body,
});
}
}
+46
View File
@@ -0,0 +1,46 @@
name: Preview Releases
on:
push:
# `bot/fix-*` branches are pushed by .github/workflows/investigate.yml
# before the bot asks the reporter to verify a candidate fix. The
# ask comment includes an `npm i https://pkg.pr.new/emdash@bot/fix-<n>`
# install URL (the full branch name -- pkg.pr.new resolves branches by
# their full ref) that only resolves once pkg.pr.new has actually
# published a preview release on the branch -- hence the explicit
# branch pattern here.
branches: [main, "bot/fix-*"]
pull_request:
branches: [main]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
publish:
name: Publish Preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
# Publish a preview for every public package via globs rather than a
# hardcoded list. This keeps preview installs self-consistent: when
# `emdash`'s preview references a sibling like @emdash-cms/registry-client
# via `workspace:*`, pkg.pr.new can only rewrite that to a matching
# preview URL if the sibling is published in the same run. Omitting one
# makes the dep fall back to npm's released version, which breaks when the
# source has drifted (e.g. a new exports subpath added without a release).
# pkg.pr.new skips `private: true` packages automatically, so the test
# fixtures under packages/plugins/* are excluded without enumerating them.
- run: pnpm exec pkg-pr-new publish --pnpm './packages/*' './packages/plugins/*'
+234
View File
@@ -0,0 +1,234 @@
name: Query Counts — Apply
# Runs after the "Query Counts" workflow completes on a PR. This job has
# elevated permissions to push back to the PR branch and comment, but it
# never executes code from the PR — it only downloads the inert JSON +
# diff artifact produced by the measure job.
on:
workflow_run:
workflows: ["Query Counts"]
types: [completed]
permissions:
# actions:read is needed for listWorkflowRunArtifacts /
# downloadArtifact. pull-requests:read is needed for pulls.get in the
# Resolve PR step. contents:read is a safety default; the commit-back
# step uses the app token, not GITHUB_TOKEN.
actions: read
contents: read
pull-requests: read
jobs:
apply:
name: Apply
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download snapshot artifact
id: download
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const artifact = data.artifacts.find((a) => a.name === 'query-counts-snapshots');
if (!artifact) {
core.setOutput('found', 'false');
core.info('No snapshot artifact — nothing to apply.');
return;
}
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
// Stage the artifact under RUNNER_TEMP, not GITHUB_WORKSPACE.
// GITHUB_WORKSPACE is wiped by the later actions/checkout step
// (clean: true is the default), which would delete our payload
// before the Apply step can copy it into scripts/. RUNNER_TEMP
// sits outside the workspace and survives checkout.
const outDir = path.join(process.env.RUNNER_TEMP, 'qc-artifact');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'artifact.zip'), Buffer.from(download.data));
core.setOutput('found', 'true');
core.setOutput('dir', outDir);
- name: Unpack artifact
if: steps.download.outputs.found == 'true'
run: |
cd "$RUNNER_TEMP/qc-artifact"
unzip -o artifact.zip
ls -la
- name: Resolve PR
if: steps.download.outputs.found == 'true'
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const prNumber = Number(
fs.readFileSync(
path.join(process.env.RUNNER_TEMP, 'qc-artifact', 'pr-number'),
'utf8',
).trim(),
);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
core.setFailed(`Invalid PR number in artifact: ${prNumber}`);
return;
}
// Cross-check: fetch the PR directly and verify its head SHA
// matches the workflow_run's head SHA. This is more robust
// than listPullRequestsAssociatedWithCommit, which doesn't
// reliably surface fork PRs from the base repo's API view
// and was rejecting valid fork artifacts as "not associated".
const headSha = context.payload.workflow_run.head_sha;
let pr;
try {
const { data } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
pr = data;
} catch (err) {
core.setFailed(`Failed to fetch PR #${prNumber}: ${err.message}`);
return;
}
if (pr.head.sha !== headSha) {
core.setFailed(
`PR #${prNumber} head SHA (${pr.head.sha}) does not match workflow_run head SHA (${headSha}). The branch has likely moved on; the next PR event will trigger a fresh measure+apply.`,
);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('ref', pr.head.ref);
// Use the workflow_run's head_sha — the commit that was
// actually measured — rather than the PR's current head.
// If the branch has moved on since, we want to push onto
// the measured commit (and let the push fail non-fast-
// forward) rather than apply stale snapshots to a newer
// tree.
core.setOutput('sha', headSha);
core.setOutput('full_name', pr.head.repo.full_name);
const isFork = pr.head.repo.fork
|| pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
core.setOutput('is_fork', isFork.toString());
- name: Generate app token
if: steps.download.outputs.found == 'true'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Push the updated snapshots back to the PR branch. Nothing else.
permission-contents: write
# --- Same-repo PRs: checkout the pinned SHA and push directly ---
- name: Checkout (same-repo)
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'false'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.pr.outputs.sha }}
token: ${{ steps.app-token.outputs.token }}
# Intentional: the same-repo push step below pushes the
# updated snapshots back to the PR branch using this credential.
persist-credentials: true
# --- Fork PRs: checkout the fork at the pinned SHA so we can push back ---
- name: Checkout (fork)
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ steps.pr.outputs.full_name }}
ref: ${{ steps.pr.outputs.sha }}
persist-credentials: false
# Reviewed opt-in to checkout v7's fork guard: no code from the
# fork tree is ever executed here. The steps below only copy the
# inert snapshot JSONs from the trusted measure artifact and run
# git add/commit/push — no scripts, hooks, or tooling from the
# checked-out tree. Credentials are not persisted; the push uses
# a scoped app token via GIT_ASKPASS.
allow-unsafe-pr-checkout: true
- name: Apply snapshots
if: steps.download.outputs.found == 'true'
id: apply
run: |
cp "$RUNNER_TEMP/qc-artifact/query-counts.snapshot.sqlite.json" scripts/
cp "$RUNNER_TEMP/qc-artifact/query-counts.snapshot.d1.json" scripts/
cp "$RUNNER_TEMP/qc-artifact/query-counts.queries.sqlite.json" scripts/
cp "$RUNNER_TEMP/qc-artifact/query-counts.queries.d1.json" scripts/
git add \
scripts/query-counts.snapshot.sqlite.json \
scripts/query-counts.snapshot.d1.json \
scripts/query-counts.queries.sqlite.json \
scripts/query-counts.queries.d1.json
if git diff --staged --quiet; then
echo "no_diff=true" >> "$GITHUB_OUTPUT"
echo "Artifact matches the PR head — nothing to push (probably a race with a newer commit)."
else
echo "no_diff=false" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push (same-repo)
if: |
steps.download.outputs.found == 'true'
&& steps.pr.outputs.is_fork == 'false'
&& steps.apply.outputs.no_diff == 'false'
env:
HEAD_REF: ${{ steps.pr.outputs.ref }}
HEAD_SHA: ${{ steps.pr.outputs.sha }}
run: |
set -e
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "ci: update query-count snapshots"
# Detached-HEAD push onto the PR branch. If the branch has
# advanced past the measured SHA, this fails with a non-fast-
# forward — safe outcome, since applying stale counts to a
# newer tree would hide a regression. The next PR event will
# kick off a fresh measure+apply against the new head.
if ! git push origin "HEAD:$HEAD_REF"; then
echo "::error::Non-fast-forward push — the PR branch moved past the measured SHA $HEAD_SHA. Rerun the harness against the new head (the next PR event will do this automatically)." >&2
exit 1
fi
- name: Commit and push (fork)
if: |
steps.download.outputs.found == 'true'
&& steps.pr.outputs.is_fork == 'true'
&& steps.apply.outputs.no_diff == 'false'
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_REF: ${{ steps.pr.outputs.ref }}
FULL_NAME: ${{ steps.pr.outputs.full_name }}
run: |
set -e
git config user.name "emdashbot[bot]"
git config user.email "emdashbot[bot]@users.noreply.github.com"
git commit -m "ci: update query-count snapshots"
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
chmod +x "$GIT_ASKPASS"
git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
# Fail the workflow if we can't push — most likely cause is
# "Allow edits by maintainers" being disabled on the fork PR.
# The reviewer needs to know the snapshots couldn't be applied.
if ! git push fork "HEAD:$HEAD_REF"; then
rm -f "$GIT_ASKPASS"
echo "::error::Failed to push snapshots to fork. The contributor may have 'Allow edits by maintainers' disabled. They need to run 'pnpm query-counts --target sqlite --update && pnpm query-counts --target d1 --update' locally and commit, or re-enable maintainer edits." >&2
exit 1
fi
rm -f "$GIT_ASKPASS"
+203
View File
@@ -0,0 +1,203 @@
name: Query Counts — Label
# Manages the "query-count changed" label and a diff comment on PRs that
# alter per-route DB query counts. When a PR's snapshot files differ from
# base, the label and a comparison comment are added; when they match base
# again (e.g. the delta was reverted, or base caught up via a merge), both
# are removed.
#
# Deliberately NOT path-filtered. A `paths:` filter would create a catch-22:
# the workflow could never run to clear a stale label/comment once a
# previously-changed snapshot matched base again, because at that point the
# PR diff no longer touches the snapshot files. The script below is cheap
# (a couple of API reads) and decides what to do from the actual base/head
# diff on every run.
on:
pull_request_target:
branches: [main]
types: [opened, synchronize, reopened, edited]
permissions:
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Sync query-count label and diff comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const baseRef = pr.base.sha;
const headRef = pr.head.sha;
const label = 'query-count changed';
// Fetch a JSON file at a given ref. Returns {} if the file
// doesn't exist on that ref (e.g. snapshot added in this PR).
async function loadSnapshot(ref, path) {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
ref,
path,
});
if (Array.isArray(data) || data.type !== 'file' || !data.content) {
return {};
}
const raw = Buffer.from(data.content, 'base64').toString('utf8');
return JSON.parse(raw);
} catch (err) {
if (err.status === 404) return {};
throw err;
}
}
function diffRows(before, after) {
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
const rows = [];
for (const key of [...keys].sort()) {
const b = before[key];
const a = after[key];
if (b === a) continue;
const bCell = b === undefined ? '—' : String(b);
const aCell = a === undefined ? '—' : String(a);
const delta = (a ?? 0) - (b ?? 0);
const deltaCell =
a === undefined
? 'removed'
: b === undefined
? 'added'
: delta > 0
? `+${delta}`
: String(delta);
rows.push(`| \`${key}\` | ${bCell} | ${aCell} | ${deltaCell} |`);
}
return rows;
}
const dialects = [
['SQLite', 'scripts/query-counts.snapshot.sqlite.json'],
['D1', 'scripts/query-counts.snapshot.d1.json'],
];
const sections = [];
let totalDelta = 0;
let totalChanged = 0;
for (const [name, path] of dialects) {
const [before, after] = await Promise.all([
loadSnapshot(baseRef, path),
loadSnapshot(headRef, path),
]);
const rows = diffRows(before, after);
if (rows.length === 0) continue;
totalChanged += rows.length;
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
const b = before[key] ?? 0;
const a = after[key] ?? 0;
if (a !== b) totalDelta += a - b;
}
sections.push(
[
`### ${name}`,
'',
'| Route | Before | After | Δ |',
'| --- | ---: | ---: | --- |',
...rows,
].join('\n'),
);
}
const hasChange = sections.length > 0;
// Label: present iff the snapshots differ from base on this run.
if (hasChange) {
try {
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: 'fbca04',
description: 'PR diff modifies query-count snapshot files',
});
} catch (err) {
if (err.status !== 422) throw err; // 422 = already exists
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [label],
});
} else {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name: label,
});
} catch (err) {
if (err.status !== 404) throw err; // 404 = label not applied
}
}
// Comment: upsert when changed, delete when not.
const marker = '<!-- query-count-diff -->';
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
const prior = comments.find(
(c) => typeof c.body === 'string' && c.body.includes(marker),
);
if (!hasChange) {
// Snapshots match base on this run; remove any stale comment
// (e.g. an earlier push had a delta that base has since caught
// up to, or it was reverted).
if (prior) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: prior.id,
});
}
return;
}
const sign = totalDelta > 0 ? '+' : '';
const summary = `**${totalChanged}** route${totalChanged === 1 ? '' : 's'} changed, total Δ ${sign}${totalDelta} quer${Math.abs(totalDelta) === 1 ? 'y' : 'ies'}.`;
const body = [
marker,
'## Query-count snapshot changes',
'',
summary,
'',
...sections,
'',
'<sub>Comparing snapshot files between base and head. Updated automatically on each push.</sub>',
].join('\n');
if (prior) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: prior.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body,
});
}
+68
View File
@@ -0,0 +1,68 @@
name: Query Counts
on:
pull_request:
branches: [main]
# Runs with the default read-only token; this workflow never pushes or
# comments. The companion "Query Counts — Apply" workflow (triggered by
# workflow_run) handles that with elevated permissions, safely isolated
# from PR-authored code.
permissions:
contents: read
jobs:
measure:
name: Measure
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Regenerate snapshots
run: |
node scripts/query-counts.mjs --target sqlite --update
node scripts/query-counts.mjs --target d1 --update
- name: Detect snapshot drift
id: drift
run: |
mkdir -p query-counts-out
# Track both the count snapshots and the query-text snapshots. The
# query-text files (.queries.*.json) record the actual SQL per route;
# without them here a change that alters a query's text but not the
# count (e.g. adding a column to a folded subquery) would never be
# committed back, leaving the D1 text snapshot silently stale.
counts="scripts/query-counts.snapshot.sqlite.json scripts/query-counts.snapshot.d1.json"
queries="scripts/query-counts.queries.sqlite.json scripts/query-counts.queries.d1.json"
if git diff --quiet $counts $queries; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No drift — snapshots match the committed baseline."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
cp $counts $queries query-counts-out/
git diff $counts $queries > query-counts-out/snapshots.diff
echo "${{ github.event.pull_request.number }}" > query-counts-out/pr-number
echo "Drift detected — uploading artifact for the Apply workflow."
# Log only the count diff; the query-text diffs can be large and
# are carried in the artifact for the Apply workflow to commit.
git diff $counts
fi
- name: Upload snapshot artifact
if: steps.drift.outputs.changed == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: query-counts-snapshots
path: query-counts-out/
retention-days: 7
if-no-files-found: error
+117
View File
@@ -0,0 +1,117 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
inputs:
publish-only:
description: "Skip versioning, just publish current versions"
type: boolean
default: false
concurrency: ${{ github.workflow }}-${{ github.ref }}
# Default-deny at the workflow level. Each job scopes its own permissions:
# - `release` declares contents/id-token/pull-requests below.
# - `sync-templates` calls a reusable workflow whose own job-level
# permissions block (contents: read) takes precedence over the caller's
# inherited permissions.
permissions: {}
jobs:
release:
name: Release
runs-on: ubuntu-latest
outputs:
published: ${{ steps.changesets.outputs.published }}
permissions:
contents: write
id-token: write
pull-requests: write
steps:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# changesets/action pushes release commits/tags and opens the
# release PR (contents + pull-requests). npm publish auth is the
# separate NODE_AUTH_TOKEN; OIDC provenance is the job's id-token.
permission-contents: write
permission-pull-requests: write
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
# Intentional: changesets/action pushes the release PR / tags
# using this credential.
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm build
- name: Block 1.x releases (we are in 0.x)
run: node .github/scripts/check-no-major.mjs
- name: Create Release Pull Request or Publish
if: ${{ !inputs.publish-only }}
id: changesets
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
version: node .github/scripts/release.mjs version
publish: node .github/scripts/release.mjs publish
commit: "ci: release"
title: "ci: release"
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# Attach sandboxed-plugin tarballs to the releases changesets just
# created, so the decentralized registry has a stable public URL to
# point each release record at. See the script header for details.
# Only the changesets path is covered: the publish-only recovery path
# below neither runs changesets/action nor creates GitHub releases, so
# assets for that path must be backfilled manually.
- name: Attach plugin tarballs to releases
if: ${{ steps.changesets.outputs.published == 'true' }}
run: node .github/scripts/attach-plugin-tarballs.mjs
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
- name: Publish (manual)
if: ${{ inputs.publish-only }}
run: node .github/scripts/release.mjs publish
sync-templates:
name: Sync Templates
needs: release
if: >-
needs.release.outputs.published == 'true' ||
inputs.publish-only
permissions:
contents: read
uses: ./.github/workflows/sync-templates.yml
# Pass only the secrets sync-templates actually uses, not the full set
# available to this release workflow.
secrets:
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
+590
View File
@@ -0,0 +1,590 @@
name: Reporter Reply
# When an issue is in the `triage/awaiting-reporter` state and the original
# reporter comments, classify the reply (positive / negative / unclear) via
# a small Flue classifier workflow and act on the result.
on:
issue_comment:
types: [created]
# Default-deny at workflow level.
permissions:
contents: read
jobs:
classify-and-act:
name: Classify reply and act
# The job `if:` is intentionally COARSE -- it filters only on things
# that are cheap and reliable from the event payload:
# - the comment is on an issue (not a PR -- issue_comment fires for both)
# - the commenter is not a bot (see the loop note below)
# - the issue is currently in the triage/awaiting-reporter state
#
# Authorization (is this the reporter, or a maintainer with a real
# write/triage role?) is deliberately NOT done here. The payload's
# `author_association` is unreliable for this: a maintainer whose org
# membership is set to private reports `NONE`, so gating on it here
# would silently drop their replies before we could check. The
# `live-check` step does an authoritative permission-role lookup
# instead -- see check 4 there.
#
# The label `contains(...)` is on the event payload's label snapshot.
# Known small race: in `investigate.yml`'s reproduced+fixed path, the
# ask comment is posted before the label flip -- a reply created in
# that 1-2 second window has a snapshot without
# `triage/awaiting-reporter` and is dropped here. The live-check step
# also gates on labels, so even loosening this `if:` would not catch
# it. Accepted as known minor; a reporter cannot reply that fast in
# practice, and the next reply would be picked up correctly.
#
# The `user.type != 'Bot'` guard is load-bearing: it excludes
# emdashbot's own comments. Without it, a bot comment could
# re-trigger the classifier -- and the `unclear` path posts a comment
# with no label flip or dedup marker, which would loop.
if: >-
github.event.issue.pull_request == null
&& github.event.comment.user.type != 'Bot'
&& contains(github.event.issue.labels.*.name, 'triage/awaiting-reporter')
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: reporter-reply-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
# All writes (open PR, transition labels, comment, dispatch workflow)
# use the app token below. The job's default GITHUB_TOKEN stays read-only.
contents: read
issues: read
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: emdash-cms
repositories: emdash
permission-issues: write
permission-contents: write
permission-pull-requests: write
# Re-verify live state before any expensive work. Four checks:
#
# 1. The issue is currently `triage/awaiting-reporter`. The job's
# `if:` gate uses `github.event.issue.labels`, which is the
# label snapshot at event dispatch time. Two replies in
# quick succession would both pass the gate; concurrency
# only serialises them.
#
# 2. The reply was posted AFTER the most recent bot ask. Every
# verification ask from investigate.yml embeds a hidden
# `<!-- bot-ask: <iso8601> -->` marker. A reply with an
# older timestamp is feedback on a superseded fix candidate
# and should not drive state transitions on the current one.
#
# 3. Only markers authored by the bot itself count. Without an
# author filter, a reporter could forge `<!-- bot-ask:
# 9999-01-01T00:00:00Z -->` in any comment and permanently
# stale every future reply.
#
# 4. The commenter is authorized, and we record WHO they are:
# either the original reporter, or a maintainer with a live
# write/triage/maintain/admin role on the repo. This is checked
# against the permission API, not the spoof-prone-by-omission
# `author_association` from the payload. A maintainer must
# additionally issue an explicit `@emdashbot confirm` /
# `@emdashbot reject` directive -- this stops drive-by
# maintainer chatter from driving state while still letting a
# maintainer act on a quiet reporter's behalf. The reporter
# path is interpreted by the AI classifier; the maintainer
# directive maps deterministically and skips the classifier.
- name: Re-verify live state
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
COMMENTER: ${{ github.event.comment.user.login }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
REPLY_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
LABELS="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '[.labels[].name] | join(",")')"
if ! grep -q 'triage/awaiting-reporter' <<<"$LABELS"; then
echo "::notice::issue #${ISSUE_NUMBER} is no longer in triage/awaiting-reporter (live labels: ${LABELS}); skipping stale reply event"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Pull ALL comments across pagination as a single JSON array
# (`--slurp` flattens `--paginate` pages). Then filter to
# comments authored by emdashbot itself, the app slug used
# across this repo's workflows (see auto-format.yml etc.).
# Without an author filter, a reporter could forge a
# `<!-- bot-ask: ... -->` marker and stale every future reply.
# The presence of the marker identifies a comment as a bot
# ask; we then use the COMMENT'S id (monotonically increasing
# per repo) to order it relative to the reply. Comment ids
# avoid the second-precision tie that an embedded timestamp
# has -- a reply posted in the same second as the ask still
# has a strictly greater id.
LATEST_ASK_ID="$(
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
| jq '
[ .[]
| .[]
| select(.user.login == "emdashbot[bot]")
| select(.body | test("<!-- bot-ask: [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z -->"))
| .id
] | max // 0
'
)"
if [[ "$LATEST_ASK_ID" == "0" ]]; then
echo "::notice::no emdashbot[bot]-authored bot-ask comment found on issue #${ISSUE_NUMBER}; treating reply as stale"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Comment ids are monotonic integers; strictly-greater is
# safe regardless of clock drift, second-precision ties, or
# API caching. A reply that predates the latest ask cannot
# have a greater id.
if (( COMMENT_ID <= LATEST_ASK_ID )); then
echo "::notice::reply id ${COMMENT_ID} is not newer than latest bot ask id ${LATEST_ASK_ID}; treating as stale feedback on a superseded fix"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ---- Check 4: authorization + actor classification ----
#
# The original reporter is always trusted to speak to their own
# issue; their reply is handed to the AI classifier downstream.
if [[ "$COMMENTER" == "$ISSUE_AUTHOR" ]]; then
echo "actor=reporter" >> "$GITHUB_OUTPUT"
echo "stale=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Non-reporter: require a real write-or-triage role on the repo.
# We gate on BOTH fields the endpoint returns:
# * `permission` -- the legacy BASE role (admin/write/read/none),
# with maintain mapped to write and triage mapped to read.
# Custom org roles collapse to their base here, so a
# write-equivalent custom role is caught by `write` and we
# don't have to enumerate custom names.
# * `role_name` -- needed only to recognise `triage` specifically
# (it maps down to `read` in `permission`, so the base field
# alone can't tell triage from plain read access).
# Both are the highest effective role across repo/team/org/
# enterprise grants. A 404 (no access) leaves both empty.
#
# The read is authorized by the token's existing contents:write
# (push-equivalent) scope; this call does NOT work on metadata
# alone, so don't narrow the app-token scopes expecting it to.
PERM_JSON="$(gh api "/repos/emdash-cms/emdash/collaborators/${COMMENTER}/permission" 2>/dev/null || true)"
PERM="$(jq -r '.permission // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
ROLE="$(jq -r '.role_name // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
if [[ "$PERM" != "admin" && "$PERM" != "write" && "$ROLE" != "triage" ]]; then
echo "::notice::commenter ${COMMENTER} has permission '${PERM:-none}' / role '${ROLE:-none}' on emdash (need write or triage); ignoring non-reporter reply"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Authorized maintainer. They must opt in with an explicit
# directive; the mention+keyword has to START a line (leading
# whitespace only) so a directive quoted from another comment
# (`> @emdashbot confirm`) does not count. Case-insensitive.
# Maps deterministically to positive/negative -- the AI
# classifier is skipped entirely for this path.
DIRECTIVE=""
if grep -iqE '^[[:space:]]*@emdashbot[[:space:]]+(confirm|confirmed|verified|fixed)\b' <<<"$REPLY_BODY"; then
DIRECTIVE="positive"
elif grep -iqE '^[[:space:]]*@emdashbot[[:space:]]+(reject|rejected|retry|reopen)\b' <<<"$REPLY_BODY"; then
DIRECTIVE="negative"
fi
if [[ -z "$DIRECTIVE" ]]; then
echo "::notice::maintainer ${COMMENTER} commented without an '@emdashbot confirm/reject' directive; taking no action"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "actor=maintainer" >> "$GITHUB_OUTPUT"
echo "classification=${DIRECTIVE}" >> "$GITHUB_OUTPUT"
echo "stale=false" >> "$GITHUB_OUTPUT"
id: live-check
- name: Checkout
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: Setup pnpm
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
cache: "pnpm"
- name: Install root dependencies
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
run: pnpm install --frozen-lockfile
- name: Install Flue agent dependencies
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
run: pnpm install --frozen-lockfile
working-directory: .flue
- name: Build packages
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
run: pnpm build
- name: Build classifier payload
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPLY_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
jq -nc \
--argjson n "$ISSUE_NUMBER" \
--arg b "$REPLY_BODY" \
'{replyBody: $b, issueNumber: $n, owner: "emdash-cms", repo: "emdash"}' \
> /tmp/classify-payload.json
- name: Run classifier
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
id: classify
timeout-minutes: 10
env:
AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
# The workflow writes its result here; we read it directly instead of
# scraping `flue run`'s stdout, which interleaves build-log lines and
# pretty-prints the result -- both defeat line/slurp parsing and
# silently default every reply to `unclear`. Same handoff as
# investigate.yml's INVESTIGATE_RESULT_PATH.
CLASSIFY_RESULT_PATH: /tmp/classify-result.json
run: |
set -o pipefail
RESULT_PATH="${CLASSIFY_RESULT_PATH:?CLASSIFY_RESULT_PATH not set}"
PAYLOAD="$(cat /tmp/classify-payload.json)"
rm -f "$RESULT_PATH"
set +e
# See investigate.yml's "Run Flue investigate agent" step
# for why we invoke the binary directly rather than via
# `pnpm --dir`. Same --root resolution bug.
.flue/node_modules/.bin/flue run classify-reply \
--target node \
--root .flue \
--payload "$PAYLOAD" \
> /tmp/classify-stdout.json 2> /tmp/classify-stderr.log
EXIT=$?
set -e
: > /tmp/classify-reasoning.txt
# A clean run writes a single JSON object to the result file. A
# non-zero exit, a missing file, or a non-object means the run did
# not finish -- default to unclear (which re-asks, never acts).
if [[ $EXIT -ne 0 ]] || [[ ! -s "$RESULT_PATH" ]] || ! jq -e 'type == "object"' "$RESULT_PATH" >/dev/null 2>&1; then
echo "::warning::classifier exit=${EXIT} or no result file; defaulting to unclear"
tail -n 50 /tmp/classify-stderr.log || true
echo "classification=unclear" >> "$GITHUB_OUTPUT"
exit 0
fi
# Whitelist the classification value -- the gate has to be a
# known enum or we treat it as unclear. Defends against the
# model returning an unexpected value.
CLASS_RAW="$(jq -r '.classification // "unclear"' "$RESULT_PATH" | tr -d '\r\n')"
case "$CLASS_RAW" in
positive|negative|unclear) CLASS="$CLASS_RAW" ;;
*) CLASS="unclear" ;;
esac
# Reasoning is attacker-influenceable (the reporter's reply
# is in the model prompt). Persist it to a file rather than
# $GITHUB_OUTPUT -- a heredoc with a fixed delimiter would be
# a step-output injection vector if the reasoning contained
# the delimiter on its own line.
jq -r '.reasoning // ""' "$RESULT_PATH" > /tmp/classify-reasoning.txt
echo "classification=${CLASS}" >> "$GITHUB_OUTPUT"
# Collapse the two classification sources into one output the
# handlers gate on. For a reporter reply the value comes from the
# AI classifier above; for a maintainer it comes from the explicit
# directive parsed in live-check (the classifier never ran). Both
# are re-whitelisted here so the handler gate is always a known
# enum. A maintainer directive is only ever positive/negative, so
# the `unclear` handler is reporter-only in practice.
- name: Resolve classification
if: steps.live-check.outputs.stale != 'true'
id: resolve
env:
ACTOR: ${{ steps.live-check.outputs.actor }}
MAINTAINER_CLASS: ${{ steps.live-check.outputs.classification }}
REPORTER_CLASS: ${{ steps.classify.outputs.classification }}
run: |
set -euo pipefail
if [[ "$ACTOR" == "maintainer" ]]; then
CLASS="$MAINTAINER_CLASS"
else
CLASS="${REPORTER_CLASS:-unclear}"
fi
case "$CLASS" in
positive | negative | unclear) ;;
*) CLASS="unclear" ;;
esac
echo "classification=${CLASS}" >> "$GITHUB_OUTPUT"
# ----- Positive: open PR, transition to verified -----
- name: Handle positive (open PR)
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.classification == 'positive'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
REPLY_BODY: ${{ github.event.comment.body }}
# The confirming commenter -- either the original reporter or a
# maintainer (authorized in live-check, check 4). GitHub logins
# are a restricted charset (alphanumeric + single hyphens), so
# this is injection-safe, but we route it through env to match
# the file's defensive convention rather than inlining `${{ }}`.
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
FIX_BRANCH="bot/fix-${ISSUE_NUMBER}"
# Quote the confirmation into the PR body. `> ` prefix every line
# so multi-paragraph confirmations render as a block quote.
QUOTED="$(printf '%s\n' "$REPLY_BODY" | sed 's/^/> /')"
ISSUE_URL="https://github.com/emdash-cms/emdash/issues/${ISSUE_NUMBER}"
{
echo "Closes #${ISSUE_NUMBER}."
echo
echo "@${COMMENTER} confirmed this fix resolves the issue:"
echo
echo "${QUOTED}"
echo
echo "See ${ISSUE_URL} for the investigation trail."
echo
echo "<sub>Opened automatically by the investigation bot. A maintainer should review before merge.</sub>"
} > /tmp/pr-body.md
# `gh pr create` is idempotent-ish: if a PR already exists for
# this branch, it errors. Detect, fall back to listing the
# existing PR for the branch. If we can't find ANY PR URL,
# do not flip to triage/verified -- that state implies a real
# PR exists. Instead leave on triage/awaiting-reporter and ping
# the maintainer, since the fix branch may have been deleted
# by bot-cleanup.yml or by a manual purge.
set +e
PR_OUTPUT="$(gh pr create \
--repo emdash-cms/emdash \
--base main \
--head "${FIX_BRANCH}" \
--title "[bot] Fix #${ISSUE_NUMBER}: ${ISSUE_TITLE}" \
--body-file /tmp/pr-body.md 2>&1)"
CREATE_EXIT=$?
set -e
PR_URL=""
if [[ $CREATE_EXIT -eq 0 ]]; then
# gh pr create prints the new PR URL on stdout.
PR_URL="$(printf '%s' "$PR_OUTPUT" | grep -oE 'https://github.com/[^[:space:]]+/pull/[0-9]+' | head -n1 || true)"
else
echo "PR create failed or already exists. Output:"
echo "$PR_OUTPUT"
# Fall back to an existing open PR for the same branch.
PR_URL="$(gh pr list --repo emdash-cms/emdash --head "${FIX_BRANCH}" --state open --json url --jq '.[0].url // ""' || true)"
fi
if [[ -z "$PR_URL" ]]; then
# No PR exists. Do NOT mark verified -- that implies a PR.
# Surface the failure so a maintainer can recover.
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/awaiting-reporter" --add-label "triage/failed"
{
echo "@${COMMENTER} confirmed the fix, but the bot could not open a PR (branch \`${FIX_BRANCH}\` may have been deleted)."
echo
echo "A maintainer needs to take this from here."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
exit 0
fi
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/awaiting-reporter" --add-label "triage/verified"
{
echo "Thanks for confirming, @${COMMENTER}. A PR is open: ${PR_URL}"
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Negative: count retries, re-trigger or give up -----
- name: Handle negative (retry or fail)
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.classification == 'negative'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPLY_BODY: ${{ github.event.comment.body }}
# The replying commenter -- either the original reporter or a
# maintainer (authorized in live-check, check 4). Login charset
# is safe.
COMMENTER: ${{ github.event.comment.user.login }}
# Pull workflow context into env so the shell never sees raw
# `${{ ... }}` expansions -- zizmor flags these as template injection
# even though `github.repository` is trustworthy on a non-fork
# issue_comment trigger. Defensive.
REPO_FULL: ${{ github.repository }}
run: |
set -euo pipefail
# Retry counter is stored as a hidden HTML marker on the
# FIRST LINE of bot-authored retry comments. Three layers of
# hardening, in increasing tightness:
# 1. `user.login == emdashbot[bot]` -- only the App's own
# comments count. A reporter cannot impersonate.
# 2. The marker must be on the first line. The agent's
# output may be quoted into other bot comments (the
# ask comment includes `${NOTES}` which is shaped by
# the agent's free-form prose); pinning to line 0
# prevents an attacker who slips a marker into the
# issue body and gets it echoed back from defeating
# the retry budget.
# 3. The regex is exact: full anchor, no whitespace slop,
# bare integer.
COUNT="$(
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
| jq '
[ .[]
| .[]
| select(.user.login == "emdashbot[bot]")
| (.body | split("\n")[0])
| capture("^<!-- bot-retry-count: (?<n>[0-9]+) -->$"; "")
| .n | tonumber
] | max // 0
'
)"
NEXT=$((COUNT + 1))
MAX=3
if (( NEXT > MAX )); then
# Find the maintainer who applied bot:repro initially -- look up
# the labeled event on the issue's timeline.
LABELER="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/events" --paginate \
--jq '[.[] | select(.event == "labeled" and .label.name == "bot:repro") | .actor.login] | last // ""')"
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/awaiting-reporter" --add-label "triage/failed"
{
echo "<!-- bot-retry-count: ${NEXT} -->"
echo "The bot has tried ${MAX} times and the latest reply (from @${COMMENTER}) still indicates the fix does not work. A human maintainer needs to take this from here."
if [[ -n "$LABELER" ]]; then
echo
echo "@${LABELER} (you applied \`bot:repro\` originally) — over to you."
fi
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
exit 0
fi
# Re-trigger investigation via a repository_dispatch event (type
# `reporter-retry`) rather than `gh workflow run` (workflow_dispatch):
# firing repository_dispatch needs only contents:write, which the app
# token has, whereas workflow_dispatch needs actions:write, which the
# emdashbot App is not granted. investigate.yml reads issueNumber /
# retryContext from client_payload. The body is built with jq so the
# attacker-controlled REPLY_BODY is JSON-escaped, never interpolated
# into a command. repository_dispatch always runs on the default
# branch, so no ref is needed.
#
# Dispatch first, then transition the label. Order matters for
# recovery: if dispatch fails, the label stays put (so a maintainer
# can re-trigger by removing + re-adding `bot:repro` manually) rather
# than getting stuck in `triage/reproducing` with nothing running.
set +e
jq -nc \
--arg n "$ISSUE_NUMBER" \
--arg r "$REPLY_BODY" \
'{event_type: "reporter-retry", client_payload: {issueNumber: $n, retryContext: $r}}' \
| gh api --method POST "/repos/${REPO_FULL}/dispatches" --input -
DISPATCH_EXIT=$?
set -e
if [[ $DISPATCH_EXIT -ne 0 ]]; then
# Surface the failure on the issue so a maintainer can
# decide what to do. Leave the label on triage/awaiting-reporter
# so the maintainer's manual `bot:repro` re-application
# works as expected.
echo "::warning::repository_dispatch failed (exit ${DISPATCH_EXIT}); leaving label on triage/awaiting-reporter"
{
echo "<!-- bot-retry-count: ${NEXT} -->"
echo "I tried to re-run the investigation but the dispatch failed. A maintainer can re-trigger by removing the \`triage/awaiting-reporter\` label and re-adding \`bot:repro\`."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
exit 0
fi
# Dispatch succeeded. Now transition the label so the
# in-flight investigation can claim the issue state and a
# second reply during that window passes through the live-
# label check to a no-op. The dispatched investigate.yml
# will see `triage/reproducing` and leave it as-is at its
# transition step (which moves bot:repro -> triage/reproducing
# idempotently via --remove-label || true).
#
# Retry the flip up to 3 times: a transient API hiccup that
# leaves triage/awaiting-reporter visible opens a window for a
# duplicate retry. After 3 failures, the dispatched
# investigation will flip the label itself when it runs,
# which closes the window at the cost of a small race.
FLIP_OK=false
for ATTEMPT in 1 2 3; do
if gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/awaiting-reporter" --add-label "triage/reproducing"; then
FLIP_OK=true
break
fi
echo "::warning::label flip attempt ${ATTEMPT} failed, retrying"
sleep $((ATTEMPT * 2))
done
if [[ "$FLIP_OK" != "true" ]]; then
echo "::warning::label flip failed 3 times; relying on investigate.yml's transition step to close the window"
fi
{
echo "<!-- bot-retry-count: ${NEXT} -->"
echo "Thanks for the additional detail, @${COMMENTER}. Re-running the investigation (attempt ${NEXT} of ${MAX})."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
# ----- Unclear: ask for clarification, no state change -----
- name: Handle unclear
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.classification == 'unclear'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
# The replying commenter -- the original reporter (the unclear
# path is reporter-only; a maintainer directive is always
# positive/negative). Login charset is safe.
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
{
echo "@${COMMENTER} could you clarify whether the candidate fix resolves the issue?"
echo
echo "A short \"yes, fixed\" or \"no, still broken\" (with what you saw) is plenty. The bot is waiting on confirmation before opening a PR."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
+231
View File
@@ -0,0 +1,231 @@
name: Review State
# Event-driven interpreter that maintains four mutually-exclusive review/*
# labels on open PRs so maintainers can see review status at a glance.
# Pure actions/github-script: no checkout, no secrets, no agent.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
pull_request_review:
types: [submitted, dismissed]
schedule:
# Backstop sweep so state stays correct even if an event was missed.
- cron: "15 */6 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
review-state:
name: Update review state labels
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
concurrency:
group: review-state-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
steps:
- name: Apply review state labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// The four mutually-exclusive review states.
const stateLabels = {
'review/needs-review': {
color: 'fbca04',
description: 'No maintainer or bot review yet',
},
'review/awaiting-author': {
color: 'c5def5',
description: 'Reviewed; waiting on the author to respond',
},
'review/needs-rereview': {
color: 'd93f0b',
description: 'New commits since the last review',
},
'review/approved': {
color: '0e8a16',
description: 'Approved; no new commits since',
},
};
const stateNames = Object.keys(stateLabels);
// --- Ensure the four labels exist (mirrors pr-sweep.yml) ---
const existingLabels = new Set();
for await (const response of github.paginate.iterator(
github.rest.issues.listLabelsForRepo,
{ owner, repo, per_page: 100 }
)) {
for (const label of response.data) {
existingLabels.add(label.name);
}
}
for (const [name, meta] of Object.entries(stateLabels)) {
if (!existingLabels.has(name)) {
await github.rest.issues.createLabel({
owner,
repo,
name,
color: meta.color,
description: meta.description,
});
}
}
// Remove a label, ignoring 404 if it's already gone (mirrors pr-sweep.yml).
async function safeRemoveLabel(prNumber, name) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
// --- Determine the set of PRs to process ---
let prs;
if (
context.eventName === 'pull_request_target' ||
context.eventName === 'pull_request_review'
) {
prs = [context.payload.pull_request];
} else {
// schedule / workflow_dispatch: sweep every open PR.
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
}
core.info(`Processing ${prs.length} PR(s) for review state`);
for (const pr of prs) {
if (!pr) continue;
try {
const currentLabels = new Set((pr.labels || []).map(l => l.name));
// Drafts get no review state: strip any review/* labels and move on.
if (pr.draft === true) {
for (const name of stateNames) {
if (currentLabels.has(name)) {
await safeRemoveLabel(pr.number, name);
}
}
core.info(`#${pr.number}: draft, cleared review state`);
continue;
}
// Skip bot-authored PRs entirely (no labeling).
if (pr.user && pr.user.type === 'Bot') {
core.info(`#${pr.number}: bot author, skipping`);
continue;
}
// --- Qualifying reviews ---
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
const qualifying = reviews.filter(review =>
review.user &&
review.user.login !== pr.user.login &&
(
review.user.type === 'Bot' ||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(review.author_association)
) &&
['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'].includes(review.state)
);
let lastReview = null;
for (const review of qualifying) {
if (
!lastReview ||
new Date(review.submitted_at) > new Date(lastReview.submitted_at)
) {
lastReview = review;
}
}
// --- Last non-merge commit ---
// Only NON-merge commits count as new work. Merge commits (parents > 1)
// are "Update branch" / `git merge main`, so syncing a branch with main
// does not flip a reviewed PR back to needs-rereview.
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
let lastCommitAt;
for (const commit of commits) {
if (commit.parents && commit.parents.length > 1) continue;
const committed = commit.commit?.committer?.date;
if (!committed) continue;
if (!lastCommitAt || new Date(committed) > new Date(lastCommitAt)) {
lastCommitAt = committed;
}
}
// --- Decide the state ---
// lastReview may be a COMMENTED review; approvals should still win unless a later
// decision review (APPROVED/CHANGES_REQUESTED) supersedes them.
let lastDecisionReview = null;
for (const review of qualifying) {
if (review.state === 'COMMENTED') continue;
if (
!lastDecisionReview ||
new Date(review.submitted_at) > new Date(lastDecisionReview.submitted_at)
) {
lastDecisionReview = review;
}
}
let desired;
if (!lastReview) {
desired = 'review/needs-review';
} else if (
lastCommitAt &&
new Date(lastCommitAt) > new Date(lastReview.submitted_at)
) {
desired = 'review/needs-rereview';
} else if (lastDecisionReview?.state === 'APPROVED') {
desired = 'review/approved';
} else {
desired = 'review/awaiting-author';
}
// --- Apply: add the chosen label, remove the other three ---
if (!currentLabels.has(desired)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [desired],
});
}
for (const name of stateNames) {
if (name !== desired && currentLabels.has(name)) {
await safeRemoveLabel(pr.number, name);
}
}
core.info(`#${pr.number}: ${desired}`);
} catch (e) {
core.warning(`#${pr.number}: failed to update review state: ${e.message}`);
}
}
+150
View File
@@ -0,0 +1,150 @@
name: Review PR
# /review triggers this workflow. The first word after the trigger picks a
# model alias from .github/bonk-models.json. Currently registered aliases
# (see that file for the source of truth):
#
# /review -> default model (currently opus)
# /review opus -> Claude Opus 4.7 (default)
# /review kimi -> Kimi K2.7 Code (cheap pass for tiny PRs)
#
# Add new aliases by editing .github/bonk-models.json.
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
# Concurrency is at job level (below) rather than workflow level so it's only
# evaluated when the `if:` filter passes.
jobs:
review:
# mentions in this check must match the `mentions` input below.
# For issue_comment events, require the comment to be on a PR (not an issue).
if: >-
github.event.sender.type != 'Bot'
&& contains(github.event.comment.body, '/review')
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
&& (github.event_name != 'issue_comment' || github.event.issue.pull_request != null)
# Per-workflow group key so /review and /bonk have independent queues
# per target.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write
contents: read
issues: write
pull-requests: write
steps:
- name: Get PR number
id: pr-number
env:
# issue_comment on a PR exposes the number via github.event.issue.number;
# pull_request_review_comment exposes it via github.event.pull_request.number.
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
run: |
echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Verify PR exists
id: verify-pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr-number.outputs.number }}
run: |
if gh api "/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > /dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::warning::PR #${PR_NUMBER} not found, skipping review"
fi
- name: Checkout repository
if: steps.verify-pr.outputs.exists == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: Resolve model from comment
if: steps.verify-pr.outputs.exists == 'true'
id: model
env:
BODY: ${{ github.event.comment.body }}
run: node .github/scripts/resolve-bonk-model.mjs
- name: Setup pnpm
if: steps.verify-pr.outputs.exists == 'true'
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
if: steps.verify-pr.outputs.exists == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
cache: "pnpm"
- name: Install dependencies
if: steps.verify-pr.outputs.exists == 'true'
run: pnpm install --frozen-lockfile
- name: Get PR details
if: steps.verify-pr.outputs.exists == 'true'
id: pr-details
run: |
gh api "/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > /tmp/pr_data.json
# Use heredoc form for every field — PR titles and bodies can contain
# newlines that would otherwise corrupt $GITHUB_OUTPUT and let a PR
# author smuggle arbitrary step outputs via their title/body.
{
echo 'title<<PR_TITLE_EOF'
jq -r .title /tmp/pr_data.json
echo PR_TITLE_EOF
echo "sha=$(jq -r .head.sha /tmp/pr_data.json)"
echo 'body<<PR_BODY_EOF'
jq -r .body /tmp/pr_data.json
echo PR_BODY_EOF
} >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr-number.outputs.number }}
- name: Run Review (${{ steps.model.outputs.alias }})
if: steps.verify-pr.outputs.exists == 'true'
uses: ask-bonk/ask-bonk/github@bfd92f4d0abb62d98558b35432534f7b9992f3eb # main as of 2026-04-24
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
OPENCODE_CONFIG_CONTENT: ${{ steps.model.outputs.opencode_config }}
with:
model: ${{ steps.model.outputs.model }}
mentions: "/review"
opencode_version: "1.4.11"
permissions: write
# NO_PUSH scopes the installation token to contents:read so the
# reviewer cannot push, even if it tries to commit. Write tools
# remain enabled in the agent definition so it can scaffold fixes
# locally to verify reasoning.
token_permissions: NO_PUSH
# Custom agent defined in .opencode/agents/auto-reviewer.md.
# Holds the review philosophy, investigation protocol, and posting
# protocol so workflows stay small.
agent: auto-reviewer
prompt: |
Review pull request #${{ steps.pr-number.outputs.number }} ("${{ steps.pr-details.outputs.title }}").
Follow your agent instructions for investigation, severity calibration, and posting. Post a single review with line-anchored comments via the gh CLI; the summary will be posted as a separate workflow comment.
<pr_number>${{ steps.pr-number.outputs.number }}</pr_number>
<pr_description>
${{ steps.pr-details.outputs.body }}
</pr_description>
If the PR looks good, respond with only "LGTM!" and skip posting a review.
+50
View File
@@ -0,0 +1,50 @@
name: Sync Templates
on:
workflow_dispatch:
workflow_call:
secrets:
APP_ID:
required: true
description: GitHub App client ID for emdashbot.
APP_PRIVATE_KEY:
required: true
description: GitHub App private key for emdashbot.
jobs:
sync:
name: Sync templates to emdash-cms/templates
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: templates
owner: emdash-cms
# Push the sync branch (contents) and open/update the sync PR via
# `gh pr create` (pull-requests) on emdash-cms/templates. Nothing else.
permission-contents: write
permission-pull-requests: write
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Read-only: the sync step uses the app token via GH_TOKEN, not
# the persisted git credential.
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Sync templates
run: node scripts/sync-templates-repo.mjs
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
+244
View File
@@ -0,0 +1,244 @@
# Mirrors an issue's triage state onto the "Auto-Triage" Projects v2 board.
#
# Reactive and decoupled from the triage bot: it watches label changes and,
# whenever a `bot:repro` (trigger) or `triage/*` (state) label is added or
# removed, recomputes the issue's canonical triage state from its CURRENT
# labels and sets the board's "Triage State" single-select field. The triage
# workflows just move labels as they always have; this never writes back to
# them.
#
# It also keeps board membership in sync with the issue's open/closed state:
# closing an issue ARCHIVES its card (it leaves the active board views but
# stays recoverable in the project's archive, since triage is done once the
# issue is closed); reopening unarchives and re-syncs it if it still carries
# triage labels.
#
# Auth: mints an emdashbot App installation token (same APP_ID / APP_PRIVATE_KEY
# secrets the other bot workflows use). The App needs organization "Projects:
# Read and write" for the project mutations.
name: Triage project sync
on:
issues:
types: [labeled, unlabeled, closed, reopened]
workflow_dispatch: # one-shot backfill of issues already carrying triage labels
concurrency:
# Serialize per issue so rapid reproducing -> reproduced transitions don't race.
group: triage-project-sync-${{ github.event.issue.number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
# Skip PRs (issues.labeled fires for PRs too); always run the manual backfill.
if: github.event_name == 'workflow_dispatch' || github.event.issue.pull_request == null
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# owner + repositories: org-scoped for Projects, but limited to this
# one repo. permission-*: least privilege, just what the sync needs
# (org Projects write; issues read for the workflow_dispatch backfill).
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-organization-projects: write
permission-issues: read
- name: Sync triage state to project
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const PROJECT_NUMBER = 3;
const FIELD_NAME = "Triage State";
const owner = context.repo.owner;
const repo = context.repo.repo;
// Triage label -> board option name.
const STATE_BY_LABEL = {
"bot:repro": "Queued",
"triage/reproducing": "Reproducing",
"triage/reproduced": "Reproduced",
"triage/by-design": "By design",
"triage/awaiting-reporter": "Awaiting reporter",
"triage/verified": "Verified",
"triage/not-reproduced": "Not reproduced",
"triage/failed": "Failed",
"triage/skipped": "Skipped",
};
// When several are present, the most advanced/terminal one wins.
const PRECEDENCE = [
"triage/verified",
"triage/awaiting-reporter",
"triage/reproduced",
"triage/by-design",
"triage/not-reproduced",
"triage/failed",
"triage/skipped",
"triage/reproducing",
"bot:repro",
];
const pickState = (labels) => {
for (const l of PRECEDENCE) if (labels.includes(l)) return STATE_BY_LABEL[l];
return null;
};
const labelNames = (iss) =>
(iss.labels || []).map((l) => (typeof l === "string" ? l : l.name));
// Resolve the project, the single-select field, and its options.
const projData = await github.graphql(
`query($owner:String!, $num:Int!, $field:String!) {
organization(login:$owner) {
projectV2(number:$num) {
id
field(name:$field) {
... on ProjectV2SingleSelectField { id options { id name } }
}
}
}
}`,
{ owner, num: PROJECT_NUMBER, field: FIELD_NAME },
);
const project = projData.organization.projectV2;
if (!project || !project.field) {
core.setFailed(`Project #${PROJECT_NUMBER} or field "${FIELD_NAME}" not found`);
return;
}
const fieldId = project.field.id;
const optionId = (name) => project.field.options.find((o) => o.name === name)?.id;
// Archive an issue's card on THIS board, if present. An issue can
// belong to several projects; match on the project NODE ID, not the
// number -- Projects v2 numbers are per-owner, so a foreign project
// could also be #3 and a number match would target the wrong item.
// Archiving is reversible (unlike delete) and hides the card from
// active board views. Idempotent -- returns false when the issue
// has no card here, and re-archiving an archived item is a no-op.
const archiveCard = async (number) => {
const data = await github.graphql(
`query($owner:String!, $repo:String!, $number:Int!) {
repository(owner:$owner, name:$repo) {
issue(number:$number) {
projectItems(first:100) { nodes { id project { id } } }
}
}
}`,
{ owner, repo, number },
);
const nodes = data.repository?.issue?.projectItems?.nodes || [];
const item = nodes.find((n) => n.project?.id === project.id);
if (!item) {
// first:100 isn't paginated; warn if we hit the cap so a silent
// miss on an issue attached to 100+ projects is at least visible.
if (nodes.length === 100) {
core.warning(`#${number}: on 100+ projects; could not confirm board membership`);
}
return false;
}
await github.graphql(
`mutation($project:ID!, $item:ID!) {
archiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
}`,
{ project: project.id, item: item.id },
);
return true;
};
// Add the issue to the board (idempotent) and set its Triage State.
const upsertState = async (iss) => {
const stateName = pickState(labelNames(iss));
if (!stateName) {
core.info(`#${iss.number}: no triage label; skipping`);
return;
}
const optId = optionId(stateName);
if (!optId) {
core.warning(`#${iss.number}: no board option named "${stateName}"`);
return;
}
// Idempotent: returns the existing item if the issue is already added.
const added = await github.graphql(
`mutation($project:ID!, $content:ID!) {
addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } }
}`,
{ project: project.id, content: iss.node_id },
);
const itemId = added.addProjectV2ItemById.item.id;
// addProjectV2ItemById returns an EXISTING item as-is -- including
// when it was archived on close -- so unarchive to bring a
// reopened issue's card back onto the active board. No-op for
// items that are already active.
await github.graphql(
`mutation($project:ID!, $item:ID!) {
unarchiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
}`,
{ project: project.id, item: itemId },
);
await github.graphql(
`mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!) {
updateProjectV2ItemFieldValue(input:{
projectId:$project, itemId:$item, fieldId:$field,
value:{ singleSelectOptionId:$opt }
}) { projectV2Item { id } }
}`,
{ project: project.id, item: itemId, field: fieldId, opt: optId },
);
core.info(`#${iss.number} -> ${stateName}`);
};
// Build the work list.
let issues = [];
if (context.eventName === "workflow_dispatch") {
const all = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "all",
per_page: 100,
});
issues = all.filter(
(iss) =>
!iss.pull_request &&
labelNames(iss).some((l) => l === "bot:repro" || l.startsWith("triage/")),
);
} else {
const iss = context.payload.issue;
if (!iss || iss.pull_request) return;
issues = [iss];
}
// Per-issue try/catch so the bulk backfill reconciles every issue
// even if one fails (e.g. a transient GraphQL error). Single-issue
// event runs still fail loudly via setFailed below, since the next
// event would otherwise be the only chance to recover.
let failures = 0;
for (const iss of issues) {
try {
// Closed issues never belong on the board: triage is finished
// once the issue is closed (however it was closed). Handling it
// here -- rather than only on the `closed` event -- means label
// changes on an already-closed issue, and the dispatch backfill,
// also reconcile, so the board self-heals to "open triaged
// issues only". Reopening hits the else-branch with state "open"
// and is re-added below if it still carries triage labels.
if ((iss.state || "").toLowerCase() === "closed") {
const archived = await archiveCard(iss.number);
core.info(`#${iss.number}: closed; ${archived ? "archived" : "not on board"}`);
continue;
}
await upsertState(iss);
} catch (e) {
failures++;
core.warning(`#${iss.number}: sync failed: ${e.message}`);
}
}
if (failures > 0 && context.eventName !== "workflow_dispatch") {
core.setFailed(`${failures} issue(s) failed to sync`);
}
+93
View File
@@ -0,0 +1,93 @@
name: zizmor
# Always runs on PRs so the "Require code scanning results" ruleset rule
# always has a SARIF to inspect. Real analysis only runs when workflow files
# changed; otherwise we upload an empty SARIF so locale-only / code-only PRs
# aren't blocked waiting for a check that never reports.
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write # upload SARIF to code scanning
contents: read
actions: read
pull-requests: read # dorny/paths-filter calls the PRs API on pull_request events
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Detect workflow changes
id: changes
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
filters: |
workflows:
- '.github/workflows/**'
- '.github/actions/**'
- '.github/zizmor.yml'
- name: Decide whether to analyze
id: decide
env:
EVENT_NAME: ${{ github.event_name }}
CHANGED: ${{ steps.changes.outputs.workflows }}
run: |
if [ "$EVENT_NAME" != "pull_request" ] || [ "$CHANGED" = "true" ]; then
echo "analyze=true" >> "$GITHUB_OUTPUT"
else
echo "analyze=false" >> "$GITHUB_OUTPUT"
fi
- name: Run zizmor
if: steps.decide.outputs.analyze == 'true'
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
# Pin the tool version (the action defaults to `latest`) so a new
# zizmor release can't surface findings that block unrelated PRs.
version: "1.26.1"
# When skipped, upload an empty SARIF so the ruleset rule passes.
- name: Write empty SARIF
if: steps.decide.outputs.analyze != 'true'
run: |
cat > zizmor.sarif <<'EOF'
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "zizmor",
"semanticVersion": "0.0.0",
"rules": []
}
},
"results": [],
"automationDetails": {
"id": "zizmor/"
}
}
]
}
EOF
- name: Upload empty SARIF
if: steps.decide.outputs.analyze != 'true'
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: zizmor.sarif
category: zizmor