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
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:
@@ -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
|
||||
@@ -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\`\`\``,
|
||||
});
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }}"
|
||||
@@ -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}.`,
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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
|
||||
@@ -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(
|
||||
""
|
||||
)
|
||||
| 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
|
||||
@@ -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
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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/*'
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 }}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user