Skip to main content

Repository Automations

This document is a functional overview of the always-on GitHub automations that run on the HAI-Co² repository: what fires, when, what it does, and where each one is configured. It follows the lifecycle of a change, from filing an issue to deploying a release. These are the automations that are live today. The separate, label-gated AI-assisted pathway (an AI agent, Coco, run by gh-aw that plans a labelled issue, warns about higher-risk traits, and implements it after a human comments /approve-plan) is documented in ai-pathway.md; this document covers the rest. For the label vocabulary these automations depend on, see labels-reference.md. For the day-to-day operational view (what a maintainer watches and tunes), see maintainers-handbook.md. How the live automations chain along the lifecycle of a change:

At a glance


1. Filing an issue: issue forms

Trigger: a user clicks New issue. The issue chooser offers four structured forms (Bug, Feature, Docs, Refactor) plus contact links that route usage questions and security reports elsewhere. Blank issues are disabled, so every issue starts from a form. Each form auto-applies its type:* label and needs-triage (the refactor form also pre-applies path:traditional). Note: GitHub reads these forms from the default branch only, so they appear in the UI once merged there. Config: .github/ISSUE_TEMPLATE/.

2. Opening a PR: branch-name validation

Trigger: a PR is opened, edited, reopened, or synchronized against main or develop. The workflow checks the PR’s head branch against <type>/<short-kebab-description> (allowed types: feature, bugfix, fix, hotfix, release, chore, refactor, docs, test, experiment). If it doesn’t match, the check fails with a message listing the convention. main, develop, and bot branches (dependabot/*, renovate/*, github-actions/*) are exempt, so release PRs (develop → main) and dependency-bump PRs pass. Config: branch-name.yml. The same rule is enforced locally (opt-in) by .githooks/pre-push.

3. Opening a PR: area labeler

Trigger: a PR is opened, reopened, synchronized, or marked ready for review. actions/labeler adds area:* labels based on which paths the PR touches (backend/** → area:backend, docs/** and *.md → area:docs, .github/** → area:ci, and so on). It is add-only (sync-labels: false), so a label a triager added by hand is never stripped. It runs as pull_request_target, so it also works on fork PRs. Config: labeler.yml (workflow) + .github/labeler.yml (path rules).

4. Every push or PR: CI (lint + test)

Trigger: any push to any branch, and every pull request. Three jobs run in parallel: frontend build + lint, frontend tests, and backend tests (with coverage). A consolidated ci-complete gate then passes only if all three succeeded, which is the single status to mark as a required check in branch protection. Config: ci.yml (the lint/test jobs and the gate).

5. Merges to develop / main: Release Drafter

Trigger: a push (merge) to develop or main runs the drafter; pull_request_target open/edit/sync events against those branches run the autolabeler. Release Drafter maintains a continuously-updated draft GitHub Release: every merged PR is listed, grouped into categories by its type:* label, and the next SemVer version is computed from those labels. You never write release notes by hand. Since release-drafter v7 the autolabeler is a separate action (release-drafter/release-drafter/autolabeler@v7), so each workflow has two steps: the autolabeler runs on pull_request_target and the drafter (the root action) runs on push. Two channels run independently so they don’t pollute each other:
  • dev channel: watches develop, drafts dev-vX.Y.Z notes (release-drafter-dev.yml).
  • prod channel: watches main, drafts vX.Y.Z notes and lists the published container images (release-drafter-prod.yml). Its version is not guessed from labels: a push-only step looks up the dev-vX.Y.Z tag that points at the promoted commit and passes that number to the drafter, so the prod draft’s tag, title and body carry the version validated on dev (if no dev tag points at HEAD, the step warns and the label-based resolver guesses instead).
Three config blocks do the work:
  • categories: maps each type:* label to a notes section through a when: condition (type:enhancement → ”✨ Features”, type:bug → ”🐞 Bug fixes”, …), and a type: pre-exclude category honours the skip-changelog label, dropping such PRs before the notes and the version are computed. This mapping is mirrored in the table in labels-reference.md §2.
  • semver-increment on each category chooses the bump (v7 replaced the separate version-resolver: block): breaking/type:breaking → major, type:enhancement/type:refactor → minor, everything else → patch, with a trailing type: version-resolver category as the patch fallback for unlabelled PRs.
  • autolabeler: (read by the autolabeler action) infers the type:* label from a Conventional-Commits PR title (feat: → type:enhancement, fix:/hotfix: → type:bug, …), so even an unlabelled PR is categorised correctly. This is why the PR template asks for a Conventional-Commits title.
Release Drafter only writes the draft; it never deploys, and neither does clicking Publish on the draft: no workflow listens for a published release. What builds and deploys is the tag push in step 6; publishing the draft is a separate, manual step, a courtesy for people reading the Releases tab (see maintainers-handbook.md §4). The PR-triggered step runs as pull_request_target so it reads the workflow from the base branch, and Release Drafter expects its config file to live on the default branch. Config: the two release-drafter-*.yml configs and their workflows. Operational detail (keeping the two autolabeler blocks in sync, watching the draft) is in maintainers-handbook.md.

6. Pushing a tag: build, push, deploy

Trigger: pushing a SemVer tag, dev-vX.Y.Z or vX.Y.Z, after CI is green. The same ci.yml pipeline resolves the tag to a channel, then builds and ships:
  1. resolve-release classifies the tag: dev-vX.Y.Z → the dev GitHub Environment; vX.Y.Z → the prod GitHub Environment.
  2. build-and-push builds the backend and frontend images and pushes them to GHCR, tagged with both the version and the channel tag (:dev or :latest). It also bakes a build stamp into both images as build args: APP_VERSION (the tag’s X.Y.Z, from resolve-release), APP_CHANNEL (dev or prod), APP_COMMIT (the tagged SHA) and APP_BUILT_AT (UTC ISO-8601, taken once by resolve-release so both images carry the same stamp), so every image knows which release it is (GET /api/version and the frontend footer report it).
  3. deploy connects to the target server over VPN + SSH, generates .env files from the channel’s .env.example (filling values from GitHub Environment secrets), ships the compose tree, and runs the deploy script. A post-deploy assertion step then polls the freshly deployed stack over SSH (for up to 90 s): the backend’s GET /api/version and the frontend’s served <html data-app-version data-app-commit> must both report the tag’s version and commit, otherwise the job fails. A required reviewer on the prod environment can gate production behind a human.
  4. release-summary writes the published image refs and deploy result to the run summary.
  5. announce-release runs only after deploy has succeeded (it needs that job) and posts the release to Discord through a channel webhook, via .github/scripts/discord-release.sh. The text comes from the committed frontend/src/data/whats-new.json, as checked out at the tag, never from the GitHub Release body (still a draft at this point, and the repository is private, so its links would be dead ends for Discord readers). A prod tag posts an embed with that version’s entry to #announcements (or a one-sentence maintenance notice when the file has no entry for it); its title and its “All updates” line both link to the public /whats-new page, which renders the same entries (NOTES_URL overrides that target). A dev tag posts a one-line ops note to the dev channel. The webhook URLs are the repository secrets DISCORD_RELEASE_WEBHOOK_URL and DISCORD_DEV_WEBHOOK_URL, repository-level on purpose: the job declares no environment:, so an Environment secret would resolve to empty there without an error. When the secret for the channel is unset, the step writes a note to the run summary and exits 0. Each tag is announced once: after a successful post the script creates the git ref refs/discord-announced/<tag> (the job’s only reason for contents: write), and a later run for the same tag sees the ref and skips, which makes a re-run after a transient failure safe. The run summary records the Discord message id.
So the full release chain is: merge PRs (Release Drafter accumulates notes) → push the tag → ci.yml builds, stamps, pushes, deploys, and announces on Discord. Publishing the draft release is a separate, manual step that never deploys anything and is not what triggers the announcement; the tag push alone is what builds, deploys, and announces. Config: ci.yml. The branching/release procedure is in CONTRIBUTING.md; the two-branch rationale is ADR-0001; the curated-notes rationale is ADR-0008.

6a. Manual dispatch: re-send a Discord announcement

Trigger: manual (workflow_dispatch) only. discord-resend.yml runs the same announcement script for a tag you name, outside the release pipeline: to re-post after a Discord outage, to force a corrected post (force: true ignores the marker ref), or to smoke-test the payload (dry_run, on by default, prints the JSON that would be sent to the run summary and posts nothing). It reads whats-new.json as of the tag (the script itself comes from the branch you dispatch from, so tags that predate it still work), or as of notes_ref when that input is set, for an entry committed after the tag was cut; a v* tag goes to #announcements, a dev-v* tag to the dev channel. The operational how-to (first-time webhook setup, when to re-send) is in maintainers-handbook.md §4. Config: discord-resend.yml and .github/scripts/discord-release.sh.

7. Weekly: stale bot

Trigger: a weekly schedule (Monday 06:00 UTC), or manual dispatch. Deliberately conservative: an issue/PR with no activity for 180 days is labelled status:stale and warned, then closed after a further 21 days unless someone comments. It processes at most 30 items per run, and exempts blocked, help wanted, good first issue, high-priority, and path:ai-automation items. This avoids the well-known anti-pattern of an aggressive stale bot driving contributors away. Config: stale.yml.

8. Weekly: Dependabot

Trigger: a weekly schedule, per ecosystem. Dependabot opens dependency-update PRs across eight streams: github-actions, pip (backend, bumping the compiled, hash-pinned requirements.txt), npm (frontend), docker for the backend and frontend base images, and docker-compose for the deployment/dev, deployment/prod and deployment/local stacks. Minor and patch version updates are grouped into one PR per ecosystem and wait out a 7-day cooldown; security updates are grouped separately and open as soon as a fix exists. Majors for the framework and toolchain (next, react, eslint, vitest, vite, tailwindcss, the langgraph trio, the Node and Python base images) are ignored on purpose and upgraded by hand (see Dependency updates in CONTRIBUTING). Each PR is labelled type:chore plus the relevant area:*, so it flows through the labeler/Release Drafter machinery like any other change. Independently, the Dependency Audit job in CI runs npm audit --omit=dev and pip-audit on every PR and reports high/critical advisories without blocking the merge. Config: dependabot.yml.

How labels tie it all together

A single type:* and area:* pair drives most of the chain:
  • Issue forms apply type:* at creation.
  • The labeler applies area:* on the PR from changed paths.
  • Release Drafter reads type:* to place the PR in the right notes section and to compute the version bump, falling back to the PR title via autolabeler.
  • The stale bot reads status/priority labels to decide what to leave alone.
Because of this, the label taxonomy in .github/labels.yml is the connective tissue, and the release-drafter-*.yml categories and labeler rules must stay in sync with it. Apply the taxonomy to a repo with .github/scripts/init-labels.sh.

The AI-assisted pathway

A second, opt-in pathway dispatches labelled issues to a provider-agnostic AI agent (run by gh-aw on the repo’s own API key), gated by a path:ai-automation[-<engine>] label. The engine is chosen by label: the default path:ai-automation (Copilot, needs a seat) or explicit path:ai-automation-copilot; path:ai-automation-claude and path:ai-automation-codex are seat-free. Assigning the HAI-Coco bot account to an issue automatically adds path:ai-automation (via coco-assign-label.yml). Adding the label opens a draft PR with the plan as its first comment; /coco <change> before /approve-plan revises the plan (no code written); /approve-plan implements the plan; CI runs; /promote dev deploys to dev; /coco X after approval iterates on the code; marking the PR ready for review runs Coco’s automated review; a human approves and merges. The workflows live in .github/workflows/ as ai-{plan,implement,iterate,review,review-ondemand}-{claude,codex,copilot}.md (gh-aw sources sharing prompt bodies from .github/aw/shared/, compiled to *.lock.yml), plus the classic coco-plan-comment.yml, coco-approve-ack.yml, coco-assign-label.yml, coco-stage-labels.yml, promote-dev.yml, notify-dev-ready.yml, and notify-approved.yml. A stage:* label (managed by coco-stage-labels.yml) tracks each AI-pathway issue and PR through the lifecycle, so the Issues and Pull-requests lists show where every item is at a glance. The full how-it-works + setup is in ai-pathway.md; the rationale for having two pathways is ADR-0002.

Where to go deeper