Instruction file imported from enorm-labs/event-junkie (
.github/instructions/ci-cd.instructions.md). Copyright stays with the author.
CI/CD & Automation
What each workflow is for, which checks are required, and the shapes that fail silently. Every workflow's header carries its own decisions in full; this file is the map and the traps.
The workflows
Gates on pull requests
build-backend.yml—ktlintCheck, the three detekt analyses (nine SARIF reports in one upload, kept apart byrunAutomationDetails.id, #1394), build, test, Kover to the summary and a sticky PR comment, an informational OWASP scan. SetsORG_GRADLE_PROJECT_warningsAsErrors=true, so a Kotlin warning fails here and nowhere else. Also builds both container images forlinux/amd64andlinux/arm64withtype=cacheonlyand never pushes — it runs on fork PRs.build-frontend.yml—npm ci, lint, build, unit, Playwright e2e, the frontend image from its owndist/. Both build images on PRs only, sincerelease.ymlbuilds them on every push tomain. Both declareworkflow_dispatch(gh workflow run build-backend.yml --ref <branch>), which ignores the path filters — and only exists for workflows on the default branch, so one added in a PR is unusable until it merges.codeql.yml— advanced setup, not default setup, because default setup produces no run on a fork PR and the required context sat Pending forever (#581). Never move it back. The job names are the required contexts; a new step inanalyzeneedsif: steps.relevance.outputs.value == 'true'or it runs on a docs-only PR with nothing checked out.dependency-review.yml— newly introduced vulnerabilities (high+) and licence issues against the Advisory Database.dependency-submission.yml— the Gradle graph to GitHub onmainpush.validate-chart.yml—helm lint --strict,helm template | flux schema validateover every values file and cluster,helm unittest,scripts/cluster-assertions.sh,scripts/uid-consistency.sh.HELM_VERSIONtracks the SDK helm-controller embeds (Helm 4 since v1.6.x) — a constraint, not a lag; it was held at 3.x on a lapsed premise until #1006, and Helm 3's--strictmisses what 4's rejects. Reaches no cluster.validate-infra.yml—tofu fmt -check,init -backend=false+validateper stack in a matrix, ShellCheck on cloud-init,check_user_data.py. Neverplan— nothing outside the cluster holds a credential (PLATFORM_SETUP.md §4), so no drift detection.validate-workflows.yml— actionlint and zizmor at--min-severity medium; suppressions inzizmor.ymlor inline# zizmor: ignore[…], each with a reason and a date.unpinned-uses: hash-pinsince #443.validate-docs.yml—scripts/format-markdown.sh checkover every.md, plusskill-parity.shandrules-parity.sh. Checks, never writes (a push-back would need write access on fork PRs). Installs the frontend's lockfile for the pinned oxfmt;package-lock.jsonis in its filter because an oxfmt bump reformats every document.validate-notices.yml—scripts/notices-parity.sh check. Its own workflow because regenerating needs a JDK and Node in one job. Required, so nopull_requestfilter; runs on every PR at ~1 min warm, 3.5–4.5 cold, priced in #1084 because the next workflow removes the reason it would be red on bot branches. Works only because the generator writes no timestamp (#1037).fix-notices-on-bot-prs.yml— regeneratesnotices.jsonon Dependabot and Renovate branches and pushes, since neither bot can. Four decisions in the file:workflow_run, notpush(a Dependabot push gets a read-only token and no secrets); notpull_request_target(a writable token plusnpm cion the branch's lockfile is arbitrary code execution); the App token is minted belownpm ci, withcontents: writeonly; the commit author is notrenovate[bot], so both bots abandon the branch rather than force-push the fix away — at the cost that the bump stops auto-rebasing.validate-python.yml—ruff check+ruff format --checkatRUFF_VERSIONfrom the pinned image, then the two Python tests (#1189).validate-scripts.yml— ShellCheck the same way, plus the script test suites (version-test.sh,version-deserved-test.sh,release-highlights-test.sh).validate-comments.yml—scripts/comment-lint.sh check.label-pr.yml— type labels from the Conventional Commits title (fix(api)!:→fix+breaking-change),importerfrom an added*Importer.ktunderscraper/. Required, and red on afeatoutside a product scope (frontend,events,promoters,venues,artists,importer,scraper,bff,images,branding) — afeatearns a minor, andfeat(ci)once did (v0.17.0);scripts/scope-parity.shholds every copy of that list.pull_request_target, no checkout,github-script.milestone-dependabot.yml— same shape; gives every bot PR (Dependabot, Renovate, the release App should it open one again) the oldest open milestone, never overwrites one, and its dispatch sweeps the backlog. Both match on the bot's login, so a new bot joinsBOTSor arrives without a milestone.merge-gate.yml(#1424) — required; fails when the author is a Bot outsidedependabot[bot],renovate[bot],event-junkie-release[bot]unless a User has approved the current head. A push after approval turns it red again; theclaudeApp's approval does not count. Runs onpull_request_targetandpull_request_reviewon purpose: the file executes as it stands onmain, so an App withworkflows: writecannot edit it green from the PR it gates. It checks out nothing.
Publishing and what follows it
release.yml— the only workflow that publishes anything. Four images and the chart from one computed version, Trivy before push, a snapshot on every push tomain, a release on av*tag. It does not deploy; Flux pulls. Deliberate and easy to "fix" wrongly: no path filters (the chart'sappVersionnames all image tags, so a chart without all images is broken), no tests (the PR gates), two builds per image (a multi-platform image cannot be loaded for scanning before it is pushed), publishes on an allowlist (push, or a dispatch withpublishticked — never "everything but the dry run"), and tests itself on PRs that change it, because the dispatch button does not exist until the merge that publishes. Uploads the Trivy tables as atrivy-reportsartifact forpublish-failure-issue.yml.cut-release.yml(#868) — publishes the GitHub Release thatrelease.ymlkeys on;workflow_dispatchonly,dry_rundefault. Refuses a commit whose snapshot publish is not green (v0.3.10 left an empty tag, #1117). The version is never typed and never chosen: no file carries it (ADR-032), andscripts/version.sh deservedreads the commits since the last release tag — afeatin a product scope is a minor, a break a major (a minor before 1.0.0), the rest a patch (RELEASING.md § What a release deserves).release.ymlcomputes the same number from the same commits and refuses a tag that claims another. Only input:at_least, for1.0.0.scripts/version-deserved-test.shasserts the rule and whatcomputenames a commit. Mints an App token because a release created withGITHUB_TOKENfires norelease: published, narrowed withpermission-contents, which zizmor'sgithub-appaudit enforces. Opens no pull request: the next snapshot is named after the next number by the same script, so staging followsmainthe moment something merges.publish-failure-issue.yml(#1122) — one issue per red streak ofrelease.ymlonmain, closed by the next green publish;workflow_run, no checkout, a job guard admitting only a push tomainor areleaseevent. Quotes the Trivy tables when the artifact exists. Its dispatch replays arun_id.image-scan-scheduled.yml— nightly Trivy on the deployed images, which the publish gate cannot do: a CVE disclosed against a running image triggers no build. Scans a published tagscripts/deployed-versions.shresolves as Flux does (paginated — #1027 scanned a fortnight-old snapshot for weeks), both arches, thresholds identical torelease.yml's so a finding here means a new advisory. The fix for a red run is a release, not a re-run.dependency-check-scheduled.yml— the authoritative nightly OWASP scan; owns the NVD cache the PR scan restores.dast.yml(#1421, #1423, #1461) — the only scanner that sends requests; three jobs, no cluster credential.dast-k3dnightly: the newest signed snapshot on an ephemeral k3d viak3d-rehearsal.sh flux-up,.zap/seed.sql, the full scan through Traefik, the API scan past the limiter on purpose, Nuclei'smisconfig,exposure,tech,cvetemplates.dast-productionweekly passive andnuclei-productionweekly at a fifth of the limiter's rate, againstSITE_URL. Red only on aFAILrule in.zap/rules-*.tsvor a Nuclei match outside.nuclei/waivers-<target>.tsv; everyIGNOREand waiver dated and reasoned. Reports are artifacts; nothing opens an issue; nopull_requesttrigger, decided.deployment-status.yml(#565) — turns Flux'srepository_dispatch(HelmRelease/event-junkie.flux-system) into a GitHub deployment. Parses the commit out of the chart version throughscripts/version.sh's two shapes — change one, change this — after stripping helm-controller's+<digest>build metadata.flux-source-failure.yml(#1454) — the other listener, onOCIRepository/event-junkie.flux-systemfrom each cluster'ssource-failureAlert; a failed signature or an unreachable registry keeps the last artifact and emits no HelmRelease event, so this job is red by construction — the one shape GitHub mails about. Neither can be tested from a PR:repository_dispatchruns the default branch only, so both fail loudly on an unrecognised payload.site-probe.yml— the daily outer half of #271's alerting, againstSITE_URLwith the apex fallback (ADR-021 says why Better Stack is the other half).mail-probe.yml(#637) — proveshello@andsecurity@still receive, because a dead mailbox looks exactly like a quiet week.
Reminders — none can place a board card (GITHUB_TOKEN cannot write to an org project, and Auto-add is unreliable, #1092): check after a run and use
scripts/issue-board.sh status <n> Ready.
restore-drill-reminder.yml— quarterly, and on any push tomaintouchingbackups.shorpostgres.sh; idempotent by listing open issues, not search.credential-expiry-reminder.yml(#569) — 30 days before a credential expires, louder once it has. The dates are a literalCREDENTIALStable in the workflow, duplicated indocs/CREDENTIALS.md§2; reading them from GitHub would needadmin:org, a stronger expiring token watching a weaker one.node-pin-reminder.yml(#1068) — whenk3s_versionorwalg_versionfalls behind upstream. Refuses to open a PR, load-bearing: both pins feeduser_data, so a bump replaces the node, andwalg_checksumsmust move with the version orbackups.shaborts the boot.scripts/upstream-node-pins.shholds the comparison (exit 0 current, 1 behind, 2 could not check — only 2 fails the job); titles carry the pinned version so upstream moving cannot pile up issues. What to do with the issue isdocs/ops/K3S_UPGRADE.md, and it is not a rebuild.
Agent workloads (#387) — six workflows, each driving one of this repository's prompts through claude-code-action, each opening a PR or a report and
never pushing to main. Decisions shared by all six:
--unattendedis a clause in each prompt, not a hint: the "ask first" tier has nobody to ask, so unattended dismisses, files and rewrites nothing it is not sure of.--allowedToolsis load-bearing — the prompts carry no frontmatter, so without it the agent has no shell and reads the tree and does nothing.github_tokenis never passed, so the action authenticates as the Claude App: a PR pushed onGITHUB_TOKENstarts no check run and sits Pending forever.ACTIONS_GITHUB_TOKENcarries${{ github.token }}under a name the action leaves alone, because the action overwritesGITHUB_TOKENandGH_TOKENwith the App token, which cannot read code scanning — every run before #1021 inventoried nothing and reported clean. The two names must agree, and nothing fails loudly if they stop. Dependabot alerts403for both tokens.- A schedule cannot pass inputs: on a cron
inputs.xis empty, so every input readsinputs.x || '<default>'and the dry-run flag is additionally gated ongithub.event_name == 'workflow_dispatch'— "scheduled runs are live" is written down, not inherited. Scheduled workflows run from the default branch only, GitHub disables the schedule after 60 days without activity, and the run is attributed to whoever last edited thecronline, which must be a human. Five run nightly, staggered (security 04:23, refactor 04:41, plausibility 05:17, comments 06:14, docs 06:35 UTC), OWASP Monday 06:31;concurrencystops a dispatch racing its cron. - None can be tested from a branch — upstream: the App token is issued only when the workflow file is byte-identical to
main's, and the step then endsoutcome=successhaving run nothing.Fail if the report is a stub(under 400 bytes or five lines) is the guard, and a run with no report fails the job. Verify a change after merge, by dispatching onmainwithdry_run: true. display_report: true, nevershow_full_output— the latter dumps every tool result, which the action warns may hold secrets. A step summary has no API, so each workflow extracts the final report fromexecution_fileinto anagent-reportartifact; only the report, never the file. Every count in a report carries the command that produced it — two runs minutes apart disagreed about a pattern's existence, and the zero without evidence was wrong.- The Claude App holds
workflows: writerepository-wide (#996), granted for a pin sweep Renovate now does (#1071); nothing needs it, and review of any agent PR touching.github/workflows/is the actual control.
The six: agent-security.yml (/security-triage; nightly and on a red publish via workflow_run, walking the prompt's § A blocked publish with
--failed-publish; code scanning only), agent-refactor.yml (/refactor; fenced away from SlugGenerator, GenreNormalizer, ArtistNameMapping,
MoneyExtensions, where a change passes every test and still changes the rows — reported, never applied), agent-comments.yml (/compact-comments; DELETE,
RENAME, EXTRACT only, RELOCATE and KEEP reported, venue KDoc off-limits, --all not --worst N because density ranking selects the files that are dense on
purpose, twelve files per PR; default model claude-opus-4-8, a measured verbosity preference), agent-docs.yml (/update-docs; corrects facts, rewrites no
argument, docs/adr/ off-limits — a stale ADR gets a Status line, not a rewrite; installs the pinned oxfmt), agent-plausibility.yml (/plausibility-check;
no dry_run because every run is one, no issues: write on the agent's job, ADR-007's politeness rules on a runner) and agent-owasp.yml (/owasp-top-10,
#1422; weekly, diff-first). The last two mail their report by commenting on one issue per month through .github/actions/report-to-issue, from a separate
notify job that alone holds issues: write (#1499); nothing becomes an issue until a person files it.
Rules every workflow follows
- Every
uses:names a commit SHA with the version in a trailing comment (#443):uses: actions/checkout@3d3c42e5… # v7.0.1. Dependabot reads the comment and rewrites both.Lint & audit workflowsrefuses a tag; the repository settingsha_pinning_requiredrefuses the run — read it fromrepos/{owner}/{repo}/actions/permissions, because the repository object answersnullwhatever the value. Everyactions/checkoutsetspersist-credentials: false; nothing here pushes with git, and zizmor'sartipackedcannot read a version out of a SHA. - Steps that verify come first; steps that report to GitHub come last (#507).
if:carries an implicitsuccess(), so a failed step skips everything below it, and a skipped step leaves no annotation. Anything that calls the API (upload-sarif, a comment,github-script) goes after everything that builds, tests or scans, withsuccess() || failure(). It bit twice during one GitHub incident: a coverage comment before the image build dropped the build on the PR that changed the base image (#506); a SARIF upload before the publish left staging without a chart. - Every scanner gate asserts a denominator as well as an exit code (#1087) —
scripts/scan-coverage.shagainstscripts/scan-coverage-baseline.txt, the one place output formats are parsed, so an extraction that stops matching is an error. Three shapes: a floor that only moves up (zizmor,flux schema validate), a property (every rendered resource valid, none skipped, count positive), a floor of zero where an upstream database moves the count (Trivy, Dependency-Check — the toolDependencies Scanned: 0happened to). - Every step that writes to GitHub is guarded on
github.event.pull_request.head.repo.fork != true— coverage comments, SARIF uploads — or a fork PR goes red for a403its author did not cause. Add the guard in the same change as the step.
The main ruleset
- Required checks are a repository setting; this file does not name their number (it drifted twice). Read
gh api repos/{owner}/{repo}/rulesets— notbranches/main/protection, which answers404 Branch not protectedbecause enforcement is a ruleset, and that404reads as "nothing enforced". - Each required check runs on every PR, because GitHub keeps a required-but-skipped check Pending forever (#447 never ran
Lint & render). So: never add apaths:filter to thepull_requesttrigger of a required workflow (the failure looks like a hung check), and adding a stack tovalidate-infra's matrix creates a context that is not required — add it to the ruleset in the same change. Build & Test (backend)and(frontend)are required, and neither is the job that does the work. Each workflow isdetect-changes(seconds, no checkout, the PR's file list from the API), the build gated on it, and agatejob that always runs and carries the context — red when the build failed, green when skipped, red when detection failed (a detection bug must never read as a green build). A job skipped by a workflow-levelpaths:creates no check run at all, so the required context must never be the conditional job. The gate declarescontents: readonly, which is what keeps the fork path working while the build holds write scopes for its comment and SARIF.codeql.yml's three contexts do the same with arelevancestep. OWASP is deliberately outside the gate'sneeds:NVD_API_KEYis empty on a fork.- No bypass actor, nothing in CI pushes to
main, and no unlisted App lands a PR. #443 removed the admin bypass. GitHub refuses the Actions bot as a bypass actor, and a second ruleset restricting updates broke auto-merge (GitHub's deferred merge runs without bypass, known and not planned) — it is gone. Design any workflow that wants to write as generate-on-demand or open-a-PR, never push-to-main or merge; a snapshot workflow was written, merged and deleted before this was learned. What the gate does not stop: an App withcontents: writepushing onto a person's open branch with auto-merge armed — an accepted row in the threat model. - Fork pull requests work because every required check declares
contents: readand depends on no secret (#479, #579). Adding a secret or awritepermission a step depends on breaks the fork path invisibly, since nothing exercises it routinely.NVD_API_KEYis referenced on the PR path and is empty on a fork; the invariant is "no PR depends on a secret".pull_request_targetis safe only because the workflow checks out and runs nothing —label-pr.yml,milestone-dependabot.yml,merge-gate.ymleach open with that banner; one innocuousactions/checkoutmakes it arbitrary code execution with write scopes. - Secret scanning, push protection and validity checks are on;
non_provider_patternsandai_detectionare off, decided (#443, #385, #1427). Read them:gh api repos/{owner}/{repo} --jq '.security_and_analysis | map_values(.status)'. gitleaks in pre-commit is the local half. - Commits are deliberately not signed (#443): with one maintainer a signature proves authorship to the person who holds the only merging account, at the cost of a key on every machine and every agent. The condition that changes this is a second committer.
- Releases are immutable (#443), and publishing one triggers a production publish (#264) — a release published by mistake is fixed by shipping forward,
never by reusing the tag.
repos/{owner}/{repo}/immutable-releases. - Every published image carries a buildx SBOM (
sbom: true), a signed provenance attestation, and a keyless cosign signature on the digest (#443, #1425); the chart carries the signature too. Provenance says where it came from (gh attestation verify oci://… --repo enorm-labs/event-junkie, quiet on success); the SBOM says what is inside; the cosign signature is the one a cluster enforces (spec.verify, identity…/release.yml@<ref>), and a signing failure leaves a verifying cluster on its last artifact until a re-run signs the same digest. - When CI misbehaves, check githubstatus.com first. Seen in the 2026-08-06 outage: no run created at
all (webhooks throttled — the PR gets no label either); a run "failing" with zero steps, annotated
The job was not acquired by Runner of type hosted— runner starvation, not a test failure; runs for branches deleted hours ago; the Webhooks component reading Operational throughout;gh run listempty while CodeQL still reports; andhead_shaon/actions/runssilently returningtotal_count: 0for an abbreviated SHA. With CI down,/verifylocally and say in the PR that CI never ran.
Dependency updates
Three mechanisms, and they must not overlap (#384, ADR-024): Dependabot owns the six ecosystems declared in dependabot.yml; Renovate owns what
belongs to no ecosystem — Flux and the charts it installs, images in plain manifests, the CI tool pins in .github/workflows/, .pre-commit-config.yaml, the
Gradle wrapper; /update-dependencies is the deliberate sweep that knows which Gradle versions are BOM-managed and must not be pinned.
- Dependabot runs weekly, everything grouped.
gradleby library family;npmwithversioning-strategy: increase(keeps the exact pins) and five toolchain families plus afrontend-minor-patchsweep that must carrypatterns: ["*"]— a dependency joins the most specific matching group, and a group with nopatterns:outranks a wildcard, which is what split the oxlint pair twice (#494);github-actionsin one group, with.github/actions/report-to-issueas a second directory or its pins rot unseen;opentofu(notterraform— separate registries, and theterraformupdater rewrites the lock file'sregistry.opentofu.org), all four directories in one PR, moving.terraform.lock.hcland notversions.tf;dockerfor the tag-and-digest base-image pins — an open Dependabot PR is a live vulnerability, and when bumping a base image check the branch is still being rebuilt (nginx 1.29shipped three-month-old Alpine packages);docker-compose, which decides what Testcontainers exercises. - A tool version pinned as a plain string belongs to no ecosystem —
HELM_VERSION,FLUX_VERSION,FLUX_SCHEMA_VERSION,HELM_UNITTEST_VERSION,TRIVY_VERSION, gitleaks'rev:. Renovate'scustomManagerswatch them bydepName, with no# renovate:comment beside a pin, so one PR moves a pin across every file that carries it. The twoTRIVY_VERSIONpins must agree or the scheduled scan stops being comparable with the publish gate. - Renovate's
enabledManagersis an allow-list, and that is the whole safety argument: a manager absent from it cannot open a PR.custom.regexis doubly gated — disabled wholesale by apackageRulesentry and re-enabled perdepName; a pin incustomManagersand not in that list shows asSKIPPED: disabledin a dry run.flux,kubernetesandpre-commitdo nothing on their defaults.managerFilePatternsis additive, not a replacement — exclude withpackageRules+matchFileNames.labels: ["dependencies"]is load-bearing (release notes sort by label). Thechore(deps)prefix comes fromconfig:recommendedand is not configured; never key anything on the prefix, the label is the stable signal.
Skills, commits, releases
- A skill is three files —
.github/prompts/<name>.prompt.md, one-line@pointers in.claude/skills/and.claude/commands/, a bullet inAGENTS.md§ Project skills — andscripts/skill-parity.shkeeps them in step, because nothing else does. - Conventional Commits 1.0.0.
/open-pris the explicit go-ahead for the commit and push the "no unsolicited commits" rule withholds. - Release notes (
.github/release.yml) group by the labelslabel-pr.ymlapplies, first category wins, so specific (importer,dependencies) precede general;ignore-for-releasehides a PR; the release App's own PRs are excluded by author, kept from before ADR-032 when it opened a bump every cycle. A visitor-facing summary sits above the categories:cut-release.ymlruns/release-highlights; three sources in order — thehighlightsinput, the model,scripts/release-highlights.sh— and the run summary names which. The model step iscontinue-on-errorand reports success without running when the file differs frommain's, so the next step checks the file, never the outcome.