Reusable GitHub Actions workflows for the Data Science Club.
- Node Build - Build Node.js applications with Infisical secrets
- Vercel Deploy - Deploy to Vercel
- Quality Gate - Lint and typecheck
- Quality Sonar - SonarQube analysis
- Dispatch Docs Update - Notify the docs app (nexus-docs) when a PR merges so the changelog gets updated
- Feedback Triage - Route feedback issues to Claude (
route:agent) or a developer (route:dev). See Feedback loop - Feedback Fix - Have Claude fix
route:agentissues and open a PR for review. See Feedback Fix - Claude Mention -
@claudeon a feedback PR asks Claude for a change. See Claude Mention - Feedback Notify - Tell the organizer in Discord when a fix is ready to check, live, on staging, or needs info. See Feedback Notify
App repos opt in with a .github/feedback.yml and a caller workflow. Organizers' feedback becomes issues labelled feedback, and triage decides who fixes each one. Spec: Feedback loop.
app: cxc
strictness: strict # strict | relaxed
branches:
production: main
staging: dev
lead: <github-handle>
dev_team: cxc-dev # empty = every dev issue goes to the lead
devs: # optional overrides for clear ownership
- github: <handle>
areas: [landing, auth]
agent:
enabled: true
max_files: 5
never_touch: [migrations/**, lib/auth/**, proxy.ts, config/site.ts, .github/**, package.json, pnpm-lock.yaml]
project: # optional: keep the issue's status on an org project board
number: 28
field: Status # default
statuses: # defaults shown
backlog: Backlog
agent: In Progress
review: In Review
dev: Todonever_touch patterns work like .gitignore. A pattern without / matches that file name at any depth (package.json also blocks apps/web/package.json). A pattern with / is anchored at the repo root. ** crosses directories and * doesn't.
.github/workflows/feedback-triage.yml runs when an issue gets the feedback label, and again whenever it gets the retriage label. It has two jobs:
- Analyze. Claude (
claude-sonnet-5-5, read-only tools, a read-only token) reads the issue, its comments, its screenshots,AGENTS.md, the config and the code, and returns a verdict. The comments carry answers to triage's questions and organizers' follow-ups from Discord; the workflows' own comments are left out.- Which code: the branch the issue's env label points at, the same branch the fix works on:
env:stagingreadsbranches.stagingandenv:prodreadsbranches.production. The config always comes from the default branch. - When it isn't there: if what the issue describes isn't on that branch, triage asks whether the label is wrong (for example, the page is only on the live site) instead of guessing.
- Which code: the branch the issue's env label points at, the same branch the fix works on:
- Apply. A script, using the
nexus-feedbackGitHub App token, turns the verdict into labels, an assignee and a triage comment. Labels from an App token trigger other workflows; labels fromGITHUB_TOKENwouldn't.
The verdict only suggests a route. The script sends the issue to the agent only when every one of these holds:
agent.enabledis true- the fix needs between 1 and
max_filesfiles, and none of them matchnever_touch - no new dependency, database change or environment variable is needed
- the expected result is clear
Anything else goes to route:dev, and the comment says which rule decided it.
| Outcome | Labels | Assignee |
|---|---|---|
route:agent |
route:agent, size:*, area:* |
the lead, who reviews the agent's PR |
route:dev |
route:dev, size:*, area:* |
a devs override for the area; otherwise the dev_team member who most recently changed the likely files, skipping anyone with 3+ open feedback issues; otherwise the lead |
unclear, no env:prod / env:staging label, or not found on that env's branch |
needs-info, plus a question comment |
none |
Project board (optional). With project: set, the apply step adds the issue to that org project and sets its status: agent (In Progress) for route:agent, dev (Todo) for route:dev, and backlog (Backlog) for a question. Feedback Fix sets review (In Review) when the agent's PR is opened or updated, and dev when it hands an issue to a developer. A missing board, field or status is a warning, and triage still finishes.
-
The
nexus-feedbackApp needs the organization permission Projects: Read and write. Grant it before addingproject:, since the token asks for it onceproject:is set. -
For Backlog as soon as the issue is created, turn on the board's built-in Item added to project workflow with Status: Backlog, and have Auto-add to project add
is:issueitems. -
The route label is added last, so the fix workflow finds the comment and assignee in place.
-
area:*values come from the repo's existingarea:*labels. -
strictrepos never close anything and flag possible duplicates for the lead.relaxedrepos close duplicates. -
An issue that already has a route label isn't triaged again unless it gets the
retriagelabel (or the caller passesretriage: true). The apply step removesretriageagain. When re-triage lands on the same route, the route label is taken off and put back, soroute:agentstarts a new fix. Create theretriagelabel in the repo; nexus-bot adds it when an organizer answers a question or says a merged fix is still not right. -
Only the
labeledevent (addingfeedback) starts triage. Creating an issue with labels fires bothopenedandlabeled, soopenedis skipped.
Inputs
| Input | Default | |
|---|---|---|
infisical-project-slug |
required | Project holding the feedback secrets |
infisical-secret-path |
/nexus-feedback |
Folder with FEEDBACK_ANTHROPIC_API_KEY, FEEDBACK_APP_CLIENT_ID (or FEEDBACK_APP_ID) and FEEDBACK_APP_PRIVATE_KEY |
infisical-env-slug |
prod |
|
config-path |
.github/feedback.yml |
|
issue-number |
the event's issue | |
retriage |
false |
Triage again even if a route label is set |
model / max-turns |
claude-sonnet-5-5 / 15 |
|
allowed-bots |
empty | e.g. nexus-feedback[bot], for issues filed from Discord |
workflows-ref |
v4 |
Ref the feedback scripts are loaded from; match the ref you call the workflow at |
Secrets: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET. The secrets are exported to a file and read per job, so the agent step only ever sees the Anthropic key.
Caller example
on:
issues:
types: [labeled]
jobs:
triage:
if: github.event.label.name == 'feedback' || github.event.label.name == 'retriage'
uses: uw-datasci/nexus-workflows/.github/workflows/feedback-triage.yml@v4
permissions:
contents: read
issues: read
with:
infisical-project-slug: uwdsc-54e-y
allowed-bots: nexus-feedback[bot]
secrets:
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}.github/workflows/feedback-fix.yml runs when an issue gets route:agent. Claude makes the change and the workflow opens a PR for a person to review. The agent never merges. If it can't finish, a developer takes over.
The model never runs in a job that holds a write token:
- Gate. Decides what to do from the event (table below). The config is always read from the default branch.
- Queue, then agent. Waits its turn in the repo's queue, then installs dependencies and runs Claude (
claude-sonnet-5-5, 40 turns, 20 minutes). It can edit files and run the lint and typecheck commands, nothing else. Its token can't push. What it changed is uploaded as a patch. - Publish. With the
nexus-feedbackApp token and no model, it applies the patch and checks the guardrail. Only then does it commit, push and open or update the PR. App-token pushes start the app's quality gate on the PR. - Hand off. If anything went wrong, it assigns a developer (same rules as triage), comments why, and swaps
route:agentforroute:dev.
| Event | What happens |
|---|---|
route:agent added |
The agent works on feedback/<issue>-<slug>, cut from branches.staging for env:staging or branches.production for env:prod, and opens a PR into that branch. If the branch already exists (a second try), it continues on it. |
route:agent added, but agent.enabled: false or no env:* label |
Hand off |
A person adds route:dev |
The running agent is cancelled and a developer is assigned (one who is already assigned keeps it) |
A person removes route:agent |
The running agent is cancelled |
| The PR's quality gate fails | The first time, the agent retries once with the failure output. The second time, hand off. |
Guardrail. Before anything is pushed, every file the branch changes against its base (renames and deletions included) is checked. The run hands off without pushing if:
- any path matches
never_touch - any path is in
.github/workflows/, whatever the config says - the branch changes more than
max_filesfiles
The same check is in scripts/feedback/guardrail.js for the @claude workflow.
The PR
- Body: the repo's PR template (
.github/pull_request_template.md), with the issue number filled in and the agent's list of changes. It includescloses #N. - Reviewer: the
leadis requested, and CODEOWNERS applies as usual. - Closing: GitHub only closes issues on merges into the default branch, so a PR into
devwon't close its issue.
One agent at a time. Each repo runs one agent at a time and drops none. A GitHub concurrency group keeps only one pending run, so it isn't used here. Instead, a queue job waits until every older run of the queue workflows has finished: the caller's own workflow, plus queue-workflows. List the @claude caller there, and list this caller in its queue-workflows, so the two never work on a branch at the same time. Per issue, a concurrency group cancels the old run when the labels flip.
Inputs
| Input | Default | |
|---|---|---|
infisical-project-slug |
required | As for triage |
infisical-secret-path / infisical-env-slug |
/nexus-feedback / prod |
|
config-path |
.github/feedback.yml |
|
model / max-turns / timeout-minutes |
claude-sonnet-5-5 / 40 / 20 |
|
node-version / pnpm-version |
24 / from packageManager |
|
install-command |
pnpm install --frozen-lockfile |
|
lint-command / typecheck-command |
pnpm lint / pnpm typecheck |
The only commands the agent may run |
queue-workflows |
empty | Other workflow files whose agent runs share the queue, e.g. the @claude caller |
queue-timeout-minutes |
90 |
Keep it under 200, inside the job's 4-hour limit |
allowed-bots |
nexus-feedback[bot] |
Triage adds route:agent as the App, so it must be listed |
workflows-ref |
v4 |
Secrets: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET. The agent job only ever holds the Anthropic key and a read-only GITHUB_TOKEN. The App token never reaches a job that runs the model, and it has no workflows permission. Build output that lint or typecheck writes must be gitignored, or it counts towards max_files.
Caller example
on:
issues:
types: [labeled, unlabeled]
workflow_run:
workflows: ["CI Quality Gate"]
types: [completed]
jobs:
fix:
uses: uw-datasci/nexus-workflows/.github/workflows/feedback-fix.yml@v4
permissions:
contents: read
issues: read
pull-requests: read
actions: read # the queue and the quality-gate logs
packages: read # pnpm install from GitHub Packages
with:
infisical-project-slug: uwdsc-54e-y
queue-workflows: claude.yml # the @claude caller shares the queue
secrets:
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}.github/workflows/claude-mention.yml lets reviewers iterate on a feedback PR by commenting @claude instead of fixing things by hand. From F2, the nexus-feedback App posts these comments for organizers' "Still not right" replies in Discord. Claude makes the requested change on the PR's branch, or answers the question, and the workflow replies on the PR.
What counts
- The comment:
- a new PR comment, review comment or review that mentions
@claudeas a whole word - on an open PR from a
feedback/*branch in the same repo
- a new PR comment, review comment or review that mentions
- The author:
- a person with write access
- a bot listed in
allowed-bots(defaultnexus-feedback[bot])
- Anything else does nothing, with no reply.
What happens
- Acknowledge. The comment gets a π reaction. Reviews can't take reactions, so they get none.
- Wait. The run waits its turn in the same repo queue as the fix workflow.
- Run Claude. Claude starts from the latest commit on the branch, with the same model setup and the same tools as the fix workflow (30 turns, 15 minutes). It reads:
- the comment
- the PR and its recent conversation
- the feedback issue and its screenshots
- the code
- Push and reply. The same guardrail runs on the whole branch against its base. If it passes, the change is pushed as the App and the reply says what changed. If not, nothing is pushed and the reply says why. Questions get an answer and no commit.
- Where: review comments are answered in their thread. Anything else gets a PR comment.
- No pings: replies never ping anyone and can't start another run.
- Retry budget: a push resets the fix workflow's quality-gate retry, so each requested change gets one automatic retry if the quality gate fails.
If agent.enabled is false, the workflow only replies that the agent is turned off.
Inputs: the same as Feedback Fix, except max-turns (30) and timeout-minutes (15). Nothing is handed off: a failed run just replies.
Caller example
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
jobs:
claude:
uses: uw-datasci/nexus-workflows/.github/workflows/claude-mention.yml@v4
permissions:
contents: read
issues: read
pull-requests: read
actions: read
packages: read
with:
infisical-project-slug: uwdsc-54e-y
queue-workflows: feedback-fix.yml
secrets:
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}.github/workflows/feedback-notify.yml replies to an issue's Discord card as its fix moves along. It only acts on issues filed from Discord: nexus-bot ends those with a hidden <!-- nexus-feedback {"guild","channel","message","user"} --> block naming the card. Issues without it are skipped quietly.
| When | Event | Message |
|---|---|---|
| Preview ready | deployment_status success, environment Preview, on the head commit of an open feedback/* PR |
#12 is ready to check, with Open preview, Looks good and Still not right |
| Live | deployment_status success, environment Production, for a feedback/* PR merged into branches.production |
#12 is live, with production-url |
| On staging | pull_request closed, a feedback/* PR merged into branches.staging |
#12 is on staging, with staging-url |
| Needs info | issues labeled needs-info |
triage's question, with Answer |
- The message: a reply to the card, in its channel or thread, that pings only the person who sent the feedback. Embeds are suppressed.
- The token:
DISCORD_BOT_TOKENfrom the Infisical folder, used for one REST call. The job holds only a read-onlyGITHUB_TOKENand runs no model. - Failures: a deleted channel is a warning. Any other Discord error fails the job, since it means the bot's token or permissions are wrong.
- The URLs: the preview link is Vercel's deployment URL from the deployment status. Vercel's GitHub integration creates the deployments and statuses.
The buttons are handled by nexus-bot's feedback module. Their custom_ids are the contract between the two:
| custom_id | Button | What nexus-bot does |
|---|---|---|
fb:good:<owner/repo>:<pr>:<issue> |
Looks good | Comments on the PR |
fb:again:<owner/repo>:<pr>:<issue> |
Still not right | Open PR: an @claude comment (route:agent) or a mention of the assigned dev. Merged: reopens the issue, comments and adds retriage |
fb:answer:<owner/repo>:<issue> |
Answer | Comments on the issue and adds retriage |
A custom_id over Discord's 100 characters (a very long repo name) is left off with a warning.
Inputs
| Input | Default | |
|---|---|---|
infisical-project-slug |
required | Project holding the feedback secrets |
infisical-secret-path |
/nexus-feedback |
Folder with DISCORD_BOT_TOKEN |
infisical-env-slug |
prod |
|
config-path |
.github/feedback.yml |
For branches |
staging-url |
empty | The staging branch's deployment, e.g. https://cxc-git-dev-uwdsc.vercel.app |
production-url |
the deployment's URL | The live site |
workflows-ref |
v4 |
Caller example
on:
deployment_status:
pull_request:
types: [closed]
issues:
types: [labeled]
jobs:
notify:
if: >-
(github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'feedback/')) ||
(github.event_name == 'issues' && github.event.label.name == 'needs-info')
uses: uw-datasci/nexus-workflows/.github/workflows/feedback-notify.yml@v4
permissions:
contents: read
issues: read
pull-requests: read
with:
infisical-project-slug: uwdsc-54e-y
staging-url: https://cxc-git-dev-uwdsc.vercel.app
production-url: https://cxc.uwdatascience.ca
secrets:
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}The scripts behind all four workflows live in scripts/feedback/, and their tests run with node --test 'scripts/**/*.test.js'.
This repository uses automatic versioning based on conventional commits with manual fallback options.
Workflow: .github/workflows/release.yml
Automatically creates releases based on commit messages following Conventional Commits.
When you push to main, the workflow automatically:
- Analyzes commit messages since the last release
- Determines the version bump (major, minor, or patch), falling back to patch when no commit uses a conventional type (e.g. a direct commit to
mainlikeupdate docs) - Creates and pushes a version tag (e.g.,
v1.2.3) - Generates release notes from commits
- Creates a GitHub release
- Updates the major version tag (e.g.,
v1) to point to the latest release
For situations where you need manual control:
- Go to Actions β Release β Run workflow
- Choose options:
- Release type:
auto- Analyze commit messages, falling back to patch (default)major- Force major version bump (e.g., 1.0.0 β 2.0.0)minor- Force minor version bump (e.g., 1.0.0 β 1.1.0)patch- Force patch version bump (e.g., 1.0.0 β 1.0.1)
- Dry run: Preview the version and changelog without creating a release
- Release type:
- Click Run workflow
Use manual mode when:
- You need to force a specific version bump regardless of commits
- You want to preview what will be released (dry run)
- Commit messages don't reflect the actual changes
- You need to create a release without waiting for new commits
Workflow: .github/workflows/release-tag.yml
Creates releases when you push a version tag (bypasses automatic versioning entirely).
To use:
# Create and push a tag
git tag v1.0.0
git push origin v1.0.0
# For pre-releases
git tag v1.0.0-beta.1
git push origin v1.0.0-beta.1Use tag-based when:
- The automatic release workflow is not working
- You need complete manual control over versioning
- Creating hotfix releases on older versions
Follow Conventional Commits for automatic releases:
<type>(<scope>): <description>
[optional body]
[optional footer]
feat:- New feature (triggers minor release: 1.0.0 β 1.1.0)fix:- Bug fix (triggers patch release: 1.0.0 β 1.0.1)perf:- Performance improvement (triggers patch release)docs:- Documentation changes (triggers patch release)refactor:- Code refactoring (triggers patch release)chore:- Maintenance (no release)test:- Test changes (no release)ci:- CI changes (no release)BREAKING CHANGE:- Breaking change (triggers major release: 1.0.0 β 2.0.0)
# Minor release (new feature)
feat: add new workflow for Docker builds
# Patch release (bug fix)
fix: correct artifact path in build workflow
# Major release (breaking change - option 1)
feat!: redesign workflow input parameters
# Major release (breaking change - option 2)
feat: change deployment strategy
BREAKING CHANGE: deployment now requires vercel-project-id
# No release
chore: update dependencies
test: add unit tests for workflowsJust commit using conventional commit format and push to main:
git commit -m "feat: add new deployment workflow"
git push origin main
# Automatically creates a release!If you need to force a specific version:
- Go to Actions β Release
- Click Run workflow
- Select
major,minor, orpatch - Click Run workflow
If semantic-release isn't working:
git tag v1.0.0
git push origin v1.0.0The release workflow uses github-tag-action to analyze conventional commits and determine version bumps automatically.