Files
OwnCord/.github/workflows/ci.yml
T
J3vbandClaude c0c8736674 B1-7: community intake and automation authorization (RL-21 / L-15, RL-22 / L-16, RL-16 / R-09) (#1419)
* ci(claude): constrain automation triggers and bound run cost (L-16)

The Claude Code workflow consumes a metered credential, and the repository
stated nothing about who may spend it or for how long. Whatever downstream
behaviour happens to hold, an invariant this repository depends on should be
asserted and tested here, not inherited from a pinned dependency that a routine
version bump can re-derive.

Three controls, in the one workflow that spends:

- **Authorization.** The job condition now requires the actor to be on an
  explicit maintainer allowlist as well as the trigger text to mention the bot.
  An allowlist rather than an association check: this repository has exactly one
  collaborator, the term is unambiguous to read and to review, and it matches
  the actor-term pattern `ci.yml` already uses to exclude Dependabot. Adding a
  login is a one-line edit, which is the honest cost.
- **Duration.** `timeout-minutes: 30`, in the band every other long-running job
  here uses. Without it the job inherits GitHub's 360-minute default — the wrong
  ceiling for metered work, and the only job in the repository that lacked one.
- **Fan-out.** A `concurrency` group keyed on the issue or pull request number
  with `cancel-in-progress: true`, so repeated triggers on one thread collapse
  into a single run instead of running in parallel. Exactly one of
  `github.event.issue.number` and `github.event.pull_request.number` is present
  per triggering event, so the key is stable across all four.

The `permissions:` block and the checkout are deliberately untouched. The
permissions are already minimal and the checkout takes no `ref:`, so it reads
the base branch rather than proposed code — both correct, and rewriting either
would be churn.

`scripts/check-workflow-guards.mjs` keeps all three from silently regressing.
Modelled on `scripts/check-doc-counts.mjs`: same `--selftest`-then-assert shape,
same dependency-free approach. It is text-level rather than YAML-parsed on
purpose — the root has no YAML parser, and adding a dependency to assert that a
file contains a `timeout-minutes` key would be a poor trade. That limit is
stated in the file: these are presence-and-shape checks, not semantics.

It runs from `CHECK_HYGIENE` in `scripts/run.mjs`, so it is reachable as
`npm run check:hygiene` locally and executes inside `Repository Hygiene`, which
is already a pinned required check on `dev`. No new CI job and no new pin — the
guard is blocking from the moment it lands.

`actionlint` cannot do this job. It validates expression syntax, action inputs
and runner labels; a job condition is valid input to it whatever the condition
admits, and it has no notion of cost at all. The two tools are complementary
and both now run.

Also corrects the record: `docs/plans/b1-repository-foundation-2026-08-25.md`
claimed impact was bounded by read-only content permissions. The workflow's own
token block is least-privilege, but that is not the only identity a run can
hold, so the claim was narrower than the truth and is now stated accurately.

And `docs/security.md` gains the private-coordination section that two planning
documents already cite it for. The citation pointed at a policy that was not
written down; it now says what stays private, that the rule covers the
repository's own automation and settings rather than only product code, and
that a commit message on a public repository is a disclosure channel.

Verified: both directions, per guard. `node scripts/check-workflow-guards.mjs`
exits 0 on the current tree and reports four guards present. Deleting the
`timeout-minutes` line makes it exit 1 naming that guard and the invariant to
restore; replacing the actor term with `true` makes it exit 1 naming that one;
restoring each returns exit 0. `--selftest` passes eight assertions covering
every guard's absence, a commented-out guard (which must not count), and the two
shapes that must not trip it — any positive timeout value, and any concurrency
key. `npm run check:hygiene` passes with prettier, shellcheck, actionlint and
both new steps running for real; actionlint accepts the edited workflow.

Not included: the workflow's `permissions:` block and checkout step, per above.
No change to the action version or its inputs. `ci.yml`, `release.yml` and
`load-baseline.yml` are outside this item — none is reachable the same way, and
each already carries per-job least-privilege permissions, and where relevant a
timeout and a concurrency group. `METERED` in the new script lists one workflow
because one workflow spends; a second entry is a one-line change when that
changes.

Refs RL-22, L-16

* feat(intake): structured bug form, and route ideas to Discussions (RL-21)

Both issue templates were Markdown with front matter, so nothing they collected
was structured, required, or validated. A reporter could submit the form
untouched. The Environment block was three bullets with `Windows 11` prefilled
as the OS — the single most common answer, pre-filled, on a project that ships
Windows and Linux builds and an ARM64 client.

And `feature_request.md` existed at all, which is the direct violation: BPR-100
says Issues is the bug tracker and Discussions hosts support, ideas and
community feedback. A feature-request template routes ideas into Issues by
construction.

Done:
- `bug_report.md` → `bug_report.yml`, a real issue form. Six fields are
  `validations: required` — what happened, steps to reproduce, component, OS,
  architecture, deployment mode — because those six are what turns a report into
  something reproducible. The rest are optional on purpose; a form that demands
  everything gets abandoned.
- `feature_request.md` deleted. Nothing in the tree referenced either template
  by filename, so this breaks no link, script, or workflow.
- `config.yml` gains three routed destinations and keeps `blank_issues_enabled:
  false` — which is what makes the routing hold, since a blank issue bypasses
  every form and every warning on one.

The new environment fields are drawn from what this project actually ships, not
from a generic template:
- **Architecture** x64 / ARM64, with the note that ARM64 is the Linux desktop
  client today and there is no ARM64 server release.
- **Deployment mode** covering the six paths `docs/deployment.md` documents —
  prebuilt binary on either OS, from source, Docker/Compose, systemd, Windows
  service.
- **TLS mode** matching `tls.mode`'s four values exactly, `off` quoted so YAML
  does not read it as boolean false.
- **Network topology** — direct, port forward, reverse proxy, Tailscale — because
  voice bugs in particular bifurcate hard on this, and the reverse-proxy path
  cannot carry the WebRTC UDP range at all.
- **Separate client and server versions.** They are obtained differently and can
  legitimately differ. The server field says where to look — admin panel or the
  startup banner — and explicitly tolerates "unknown", because the version is
  deliberately absent from the unauthenticated `/health` endpoint as
  anti-fingerprinting hardening, so a non-admin reporter genuinely cannot get it.
- **Client webview**, WebView2 or WebKitGTK. No "PWA" option: no PWA exists, B1
  excludes browser and PWA work, and BPR-092 forbids presenting unavailable
  behaviour as functional. The field is diagnostic today regardless — the desktop
  client renders through the OS webview, and that already drives real bug classes.

Every public template now carries the disclosure warning BPR-101 asks for, and
the security contact link is first in the chooser, above the Discussions links.

Four files, 189 insertions, 58 deletions.

Verified: both files parse as YAML, and the form was checked against the issue
form schema rather than only for parseability — 13 body elements, 12 unique ids
with no collisions, every non-markdown element carrying an id and a label, every
dropdown carrying options, and the markdown block carrying neither an id nor
validations (both of which GitHub rejects). `config.yml` has
`blank_issues_enabled: false` and four contact links each with exactly
name/url/about. `npm run check:hygiene` passes.

The gap that verification leaves, stated plainly: nothing in this repository
validates issue-form schema. Prettier confirms the YAML parses and actionlint
does not read `.github/ISSUE_TEMPLATE/` at all, so a file that is valid YAML but
an invalid form disappears from the "New issue" chooser silently. The checks
above are a local stand-in, not the real gate. The live chooser needs a look
after merge — which BPR-100's closure evidence ("dry-run submissions reach the
intended destination") requires in any case.

Not included: the Discussions `?category=` slugs are written as `q-a` and
`ideas`, GitHub's defaults. If this repository's categories were renamed, a
wrong slug drops the user on the category picker rather than erroring — confirm
against the live Discussions tab before relying on them. No PR-template or
documentation changes here; those are the next commit. L-15 is not closed by
this commit alone: BPR-100 names six surfaces and three of them are docs.

Refs RL-21, L-15

* docs(intake): route contributors, and state the security path (RL-21)

The previous commit fixed the forms. This is the half BPR-100 and BPR-102
actually ask for and the B1 plan's bullet does not mention: their closure
evidence names repository navigation, support links and contribution docs
alongside the issue forms, so a `.github/`-only change cannot satisfy either.

Three gaps, each verified rather than assumed:

**Discussions was invisible.** The only link to it anywhere in the tree was
inside `.github/ISSUE_TEMPLATE/config.yml` — the new-issue chooser. So "route
ideas and feedback to Discussions" worked for exactly one audience: people who
had already decided to file an issue. `README.md` and `docs/README.md` now each
carry the routing, so it is reachable from the two pages a newcomer actually
lands on.

**`docs/contributing.md` never mentioned security reporting.** Five files
carry the "never a public issue" rule — the root `README.md`, `CONTRIBUTING.md`,
`SECURITY.md`, `docs/security.md`, `CLAUDE.md` — and every one of them delegates
the full process to `docs/contributing.md`, which is also the document BPR-102's
evidence row sends a fresh contributor to. It said nothing about it. It now has
a routing table and a security section that says the thing that actually matters
on a public repository: the PR description, the commits and the branch name are
disclosure channels, so a fix for a vulnerability describes the control it adds
and nothing else.

**The README contradicted the issue chooser.** The banner said "there's no
support" while the chooser offered a link named "Community Support". Both were
defensible in isolation and together they told a user two different things
before they had read anything else. The banner now says the honest version — no
support *commitment* — and a "Getting Help and Reporting Problems" table names
the right destination for each kind of message without promising a response.

Also in the PR template, which the audit's remedy names as "PR guidance":
- The Test Plan asked for `npm test` / `go test ./...` / `npx tsc --noEmit`.
  Those predate B1-4's root facade; `npm run check` is the entry point CI gates
  on and the one `CONTRIBUTING.md` and `README.md` now tell people to run.
- A generated-files checkbox naming all five, since CI fails on drift and a
  hand-edited generated file is the failure that wastes a cycle.
- A `Not included:` prompt, because `docs/contributing.md` makes a written
  deferral a required commit element and the template asked for it nowhere.
- The disclosure warning BPR-101 wants on public templates.

Two stale claims fixed while in these files: `docs/contributing.md` said "ten
status checks are required" three lines from a section that says twelve, and
`docs/plans/README.md` still read "B1-0 done, B1-1 next" six phases later — in
the index that declares itself the authority over plan headers.

Five files, 70 insertions, 12 deletions.

Verified: `git grep "ten status checks"` returns nothing.
`node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched
documents — `docs/plans/README.md` and `README.md` are both watched, so a
count claim broken by these edits would have failed here.
`npm run check:hygiene` passes with prettier, shellcheck, actionlint and the
workflow-guard check all running.

One nearby claim checked and deliberately left: `docs/contributing.md` also says
"four of the ten" a hundred lines later. That is four of ten *CI steps keying on
a cache-dependency-path*, not required checks — correct in context, and changing
it would have been a wrong fix to a right-looking grep hit.

Not included: L-15 is **not** closed. BPR-100's closure evidence requires
dry-run submissions that reach the intended destination, and BPR-102's requires
a fresh Windows and Linux contributor to follow these docs and land a passing
sample change. Neither is a file edit. BPR-101 additionally wants a tabletop
report proving private receipt, triage, advisory and coordinated disclosure —
no such artifact exists in the tree, and this commit does not create one.
`CODE_OF_CONDUCT.md` and `GOVERNANCE.md` do not exist in this repository; adding
them is community-health scope, not RL-21's, and neither is named by the audit
row or the register row.

Refs RL-21, L-15

* ci(release): require exact-SHA gate evidence before publishing (RL-16)

A tag push starts `release.yml` and nothing else — `ci.yml` has no `tags:`
trigger. And `release.yml` re-runs none of the required checks: it verifies the
version, builds, boot-smokes and signs, which is a different question from
"did the gate pass on this commit". So a tag could publish from a commit whose
CI was red, and nothing would notice.

It already has. `v1.2.0-alpha.3` published from `fb04a579`, whose CI run
concluded **failure** — `Server Build & Test (windows-latest)`, the race and
coverage step. The Release run on the same commit went green and shipped. That
is R-09 demonstrated rather than hypothesised, and it is the fixture this commit
is verified against.

The obvious fix — re-run the test suite inside `release.yml` — is the wrong one.
It would double the tag-time cost, still not cover the checks that run in other
workflows (CodeQL's three `Analyze` jobs exist in no workflow file at all), and
answer a weaker question: "does it pass now" rather than "did the gate pass on
this commit". The evidence already exists; nothing was reading it.

Done:
- `scripts/verify-gate-evidence.mjs` resolves the tagged SHA's check runs and
  asserts every required context is present and `success`. `skipped` and
  `neutral` are not success — a required check that skipped on the tagged commit
  proves nothing about it — and a still-`in_progress` check is called out as
  unfinished rather than treated as absent. Where a context reported more than
  once, the latest attempt decides, in both directions.
- The required set is **parsed out of `b0-dev-branch-protection.sh`**, not
  restated. Pinning a thirteenth check cannot leave this gate behind, and a
  change to that file's shape fails the self-test rather than silently
  weakening the gate.
- A `gate-evidence` job in `release.yml` that `verify-versions` needs. Every
  build job already needs `verify-versions` and both publishers need those, so
  one edge gates the whole graph — including the GHCR push, which today can
  mutate `:latest` before `publish` has run at all.
- `permissions: checks: read` and nothing else.

It is a script rather than a `run:` block because of the rule in the `ci-check`
skill: a step that exists only in `release.yml` first executes at tag time, so
its own bugs surface on the release. `Server/scripts/docker-smoke.sh` is the
worked example — one script, two call sites. Here the second call site is
`--selftest`, run by `ci.yml`'s docs-consistency job on every pull request.

`docs/plans/b1-release-tag-protection.sh` covers the half a workflow file
cannot express: a ruleset on `refs/tags/v*` blocking update and deletion, and a
`release` environment with a required reviewer. **NOT APPLIED** — both are
repository-settings writes this session cannot make. Run
`bash docs/plans/b1-release-tag-protection.sh` when you want them.

Deliberately **no `environment: release` key** in `release.yml` yet. The key is
PR-landable, but naming an environment that does not exist stalls the next
release; the script says to add it after creating the environment, and says why.

Verified: both directions, on real data rather than only fixtures. Feeding the
actual check runs from `fb04a579` — the commit alpha.3 shipped from — through
`evaluate` returns **NOT RELEASABLE**, naming `Server Build & Test
(windows-latest): failure` first. Feeding PR #1418's real check runs on
`8875238` returns **RELEASABLE**, and correctly ignores the red
`github-advanced-security` result because it is not a pinned context — the gate
tracks the required set, not "everything is green". `--selftest` passes 12
assertions covering a missing check, a failure, an unfinished run, `skipped`,
`neutral`, both re-run orderings, an unrequired extra, and a commit with no
checks at all. `bash -n` and `shellcheck` are clean on the new script and both
its heredocs parse as JSON. `npm run check:hygiene` passes with actionlint over
both edited workflows.

The module gained a direct-invocation guard so it can be imported and tested
without reaching the network — compared against `argv[1]` rather than
`import.meta.main`, which needs Node 24.2 against an engines floor of `>=24`
and would silently no-op on 24.0.

Not included: the network path itself is exercised only at tag time. The
self-test covers the decision logic and the required-set parsing, which is where
the bugs live; a live API call needs a token this environment does not have.
R-09's "protected release approval" limb stays open until the settings script is
run — the register phases R-09 **B1/B10**, so that half is B10's. `release.yml`'s
version stamping, both signing keys, the fail-closed minisign verify,
`checksums.sha256`'s bare filenames, both cold-boot smokes and the `git archive`
source snapshot are untouched; the remedy says to retain them and this commit
only adds an edge in front of them.

Refs RL-16, R-09

* docs(plans): record B1 progress through B1-7

B1-6 (#1418) merged and B1-7 is this branch, so the header and the plan index
both move on. B1-8 — the platform contract map — is next, and it is documentation
only: it records the browser-neutral contract folders and their owners, and moves
no native behaviour. Adapter extraction stays B7.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8
watched documents, both edited files among them; prettier clean.

Refs R-08

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 17:25:22 +00:00

630 lines
25 KiB
YAML

name: CI
on:
# dev is deliberately not a push trigger: while a dev -> main PR is open every
# push to dev already fires pull_request(synchronize), so listing it here ran
# the whole suite twice for one push. Use workflow_dispatch for a dev branch
# with no PR open yet.
push:
branches: [main]
pull_request:
branches: [main, dev]
workflow_dispatch:
# Cancel in-progress runs for the same branch/PR
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
server-build-test:
name: Server Build & Test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
binary: chatserver.exe
- os: ubuntu-latest
binary: chatserver
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: Server/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
- name: Build server
run: go build -o ${{ matrix.binary }} -ldflags "-s -w" .
# Phase B + C build-tag matrix. Each tag variant must compile so the
# tag boundaries don't drift.
- name: Build with -tags otel (Phase B Step 8)
run: go build -tags otel ./...
- name: Build with -tags wazero (Phase C Step 9)
run: go build -tags wazero ./...
- name: Build with -tags otel,wazero (full community-hub build)
run: go build -tags otel,wazero ./...
- name: Go vulnerability check
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./...
# Generated sqlc output must never drift from db/queries/. One leg of
# the matrix is enough; make is not guaranteed on the Windows runner.
- name: Verify generated sqlc output (make sqlc-verify)
if: matrix.os == 'ubuntu-latest'
run: make sqlc-install sqlc-verify
# Protocol message-type constants (Go + TS) must never drift from
# protocol/schema.json — the single source of truth.
- name: Verify generated protocol constants (make protocol-verify)
if: matrix.os == 'ubuntu-latest'
run: make protocol-verify
- name: Run tests with race detection and coverage
run: go test -race -timeout 20m ./... -coverprofile=coverage.out -cover
- name: Run tests with deadlock detection
run: go test -tags deadlock -count=1 ./...
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
# only COMPILES the otel/wazero variants; the tests behind those tags
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
# nowhere until this step. Scoped to the two packages that carry tagged
# files — every other package is tag-invariant and already covered by the
# race run above. One leg is enough; no -race (the runtime under the tag
# is the concern, not new concurrency).
- name: Run tag-gated tests (-tags wazero, -tags otel)
if: matrix.os == 'ubuntu-latest'
run: |
go test -tags wazero -count=1 ./plugin/...
go test -tags otel -count=1 ./telemetry/...
- name: Upload Go coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: go-coverage-${{ matrix.os }}
path: Server/coverage.out
retention-days: 7
# verify: false — the action's default `config verify` pass fetches
# golangci-lint.run's JSONSchema over HTTPS before linting anything, so a
# timeout on that host fails a required job having run zero linters (it
# took main red on d352696). `golangci-lint run` rejects a bad config on
# its own; the schema pass only bought a prettier error message, priced
# at a third-party site inside the gate.
- name: Lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.11.3
working-directory: Server/
verify: false
# ubuntu-latest deliberately: the client TS code has zero win32-conditional
# paths (no process.platform / path.sep branches in src or the unit suites),
# prettier pins endOfLine: lf and .gitattributes forces eol=lf, so a Windows
# runner adds queue time without adding coverage. Windows-specific behavior
# is covered where it exists: rust-tests and the tauri-build matrix.
client-check:
name: Client Static Checks
runs-on: ubuntu-latest
defaults:
run:
working-directory: Client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: Client/package-lock.json
- name: Install npm dependencies
run: npm ci
# Scoped to shipped dependencies. The remaining high findings are all one
# advisory, brace-expansion <=5.0.7, reaching us only through dev tooling
# (eslint, @vitest/coverage-v8, stryker). Those are already on their
# latest versions, so no bump reaches the fix, and there is no patched
# release in the 1.x/2.x lines they pin. Forcing every copy to 5.0.9 via
# overrides was tried and broke the build: minimatch requires
# brace-expansion as CJS and v5 is not callable that way, which took out
# vitest's coverage provider. Nothing here ships to users; revisit when
# eslint and @vitest/coverage-v8 widen their minimatch ranges.
- name: Security audit (npm, shipped deps)
run: npm audit --omit=dev --audit-level=high
- name: Oxlint (fast correctness checks)
run: npx oxlint src/
- name: TypeScript check
run: npx tsc --noEmit
- name: TypeScript check (Playwright specs)
# The main tsconfig excludes tests/e2e from the app graph; this
# project typechecks every tests/e2e spec + fixtures + the
# playwright configs so type rot cannot hide there.
run: npx tsc -p tsconfig.e2e.json --noEmit
- name: ESLint (type-aware rules)
run: npx eslint src/
- name: Knip (unused code & deps)
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
# real unused-export finding sit invisible in every green run.
run: npx knip
# Unit tests live in their own job so a suite failure is visible as exactly one
# failing check instead of masking the static gates above. The suite is GREEN
# and must stay green — never "fix" a failing test by editing its assertions.
# ubuntu-latest for the same reason as client-check above: jsdom-only vitest
# with no platform-conditional code under test.
# The automated half of G-04: a planning document that states a finding count
# the ledger contradicts fails here instead of quietly misleading a reader.
# Deliberately tiny — no npm ci, because the script imports nothing outside
# node:. It also runs its own selftest, since the whole check rests on
# patterns narrow enough not to cry wolf.
docs-consistency:
name: Docs & Ledger Consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- name: Self-test the count matcher
run: node scripts/check-doc-counts.mjs --selftest
- name: Documents must agree with the findings ledger
run: node scripts/check-doc-counts.mjs
- name: Ledger schema is valid
run: node .superpowers/render-ledger.mjs --check
# R-09 / RL-16. The release gate itself is only invoked for real at tag
# time, which is the wrong place to find a bug in it — so its decision
# logic is exercised here, on every pull request, against fixtures. Same
# reason Server/scripts/docker-smoke.sh is called from both workflows.
# This also parses the required-check list out of
# b0-dev-branch-protection.sh, so a change to that list's shape fails here
# rather than silently weakening the gate.
- name: Self-test the release gate
run: node scripts/verify-gate-evidence.mjs --selftest
# RL-07. FINDINGS.md is not tracked, so it cannot drift -- but L-07 also
# asks that the rendering be reproducible and that CI reject a generation
# failure. Rendering twice and comparing tests both: the render must
# succeed (it validates and exits 1 before writing), and it must be a pure
# function of the ledger. The severity rule in validate() is what makes
# the second half true -- an unranked severity would make render()'s sort
# implementation-defined.
- name: FINDINGS.md renders, and renders identically twice
run: |
node .superpowers/render-ledger.mjs
cp .superpowers/FINDINGS.md "$RUNNER_TEMP/FINDINGS.first.md"
node .superpowers/render-ledger.mjs
cmp "$RUNNER_TEMP/FINDINGS.first.md" .superpowers/FINDINGS.md || {
echo "ERROR: rendering the ledger twice produced different output."
echo "render() must be a pure function of findings-ledger.json."
exit 1
}
# The rendering is the human-readable view and is deliberately untracked,
# so this artifact is how a reviewer reads it without a Node run.
# if: always() -- you want it downloadable precisely when the job failed.
- name: Upload the rendered findings ledger
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: findings-ledger-rendering
path: .superpowers/FINDINGS.md
retention-days: 7
# Repository-wide formatting, script lint and workflow lint (RL-19 / L-13, S-05).
#
# Root-scoped and ubuntu-only for the same reason as docs-consistency above:
# every gate here is platform-independent text analysis, and .gitattributes
# pins eol=lf so a second OS would only re-prove line endings.
#
# Prettier lives here rather than in client-check because it is no longer a
# client gate -- one config at the repository root covers Markdown, YAML,
# JSON, CSS and the root scripts as well as client TypeScript.
hygiene:
name: Repository Hygiene
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
# Root install only -- prettier is the sole dependency this job needs, and
# the client's install is client-check's job.
- name: Install root dependencies
run: npm ci
# shellcheck ships in the ubuntu runner image. actionlint does not, so it
# is pinned by version and checked by digest: an unpinned installer script
# would be the one unverified download in a workflow file that pins every
# action by commit SHA.
- name: Install actionlint
env:
ACTIONLINT_VERSION: 1.7.7
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
run: |
set -euo pipefail
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl -sSfL --retry 3 -o "$RUNNER_TEMP/actionlint.tar.gz" "$url"
echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Report tool versions
run: shellcheck --version && actionlint --version
# The same entry point a contributor runs. run.mjs takes its shellcheck and
# actionlint file lists from `git ls-files`, never a filesystem glob.
- name: Formatting, shell and workflow gates
run: npm run check:hygiene
client-tests:
name: Client Unit Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: Client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: Client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Run unit tests with coverage
run: npx vitest run --coverage --reporter=default
- name: Upload client coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: client-coverage
path: Client/coverage/
retention-days: 7
# Rust unit tests used to live inside tauri-build, which only runs on PRs to
# main — so #[cfg(test)] code never ran on pushes or on PRs to dev, and could
# rot for a whole release cycle. This job runs them on every event. Clippy is
# run with --all-targets here (tauri-build's lib-only clippy skips test code).
rust-tests:
name: Rust Unit Tests
runs-on: ubuntu-22.04
timeout-minutes: 30
defaults:
run:
working-directory: Client/src-tauri/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Install Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libdbus-1-dev \
libasound2-dev \
libssl-dev \
librsvg2-dev
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy, rustfmt
- name: Rust cache
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/src-tauri
# Ahead of clippy: a formatting failure is cheap to produce and cheap to
# fix, and there is no reason to spend a clippy pass to surface one.
- name: Rustfmt check
run: cargo fmt --all -- --check
- name: Clippy lint (including test targets)
run: cargo clippy --all-targets -- -D warnings
- name: Rust unit tests
run: cargo test --lib
# Playwright e2e against the mocked-Tauri dev server. Runaway protection
# lives in playwright.config.ts (maxFailures: 20 aborts a systemic cascade
# early; globalTimeout: 20 min self-terminates with a usable report) with
# timeout-minutes below as the outer backstop.
#
# BLOCKING since 2026-08-05 (DC-07): the post-repair soak recorded green
# full-suite runs at 270, 276 and 291 tests across the 08-04/08-05 audit
# branches, and the one hard CI failure in that window was a real spec bug
# (updater install-settle race), which a non-blocking job would have let
# rot. retries: 2 absorbs the known rare flake class (see E2E-ISSUES.md's
# flake accounting).
# See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21.
# The native config (playwright.config.native.ts) is deliberately not wired
# up — it needs a real server and a built desktop binary.
client-e2e:
name: Client E2E (Playwright)
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: Client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: Client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test --config=playwright.config.ts
# Browser-mode unit tests (tests/browser/): real AudioContext + WASM
# behind the same Chromium, so the noise-suppression pipeline has a
# test that actually runs somewhere (test-audit 2026-08-19, T-22).
- name: Run browser-mode unit tests
run: npm run test:browser
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: |
Client/playwright-report/
Client/test-results/
retention-days: 7
# Admin-panel journey against a REAL server (no mocks): start-server.sh
# builds the Go binary and boots it with a fresh temp data dir, and the
# suite drives the embedded SPA through the first-run wizard, dashboard,
# channel CRUD, audit log and re-login — the one DC-04 surface the mocked
# suites cannot reach. Non-blocking while it earns its soak, same
# graduation convention client-e2e followed.
# GRADUATION CRITERION (recorded 2026-08-15): flip continue-on-error to
# false once the job has ~30 consecutive green runs on main with no
# infra-flake reruns — the same evidence bar client-e2e cleared (270+ green
# runs cited in docs/audit-2026-08-04-docs-and-coverage.md) scaled to this
# job's lower traffic. Check with: gh run list -w CI -b main --json
# conclusion | jq '[.[] | .conclusion] | index("failure")'.
admin-e2e:
name: Admin Panel E2E (real server, non-blocking)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 20
defaults:
run:
working-directory: Client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: Client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run admin-panel journey
run: npx playwright test --config=playwright.config.admin.ts
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: admin-e2e-report
path: |
Client/playwright-report/
Client/test-results/
retention-days: 7
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
# the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW
# gate, group DMs, role change, custom-emoji autocomplete, voice moderation).
# These are new and authored green, so unlike the full legacy suite above they
# gate PRs: a regression on one of these features must fail CI. Kept as its own
# job (not folded into the non-blocking suite) so the legacy suite can keep
# earning its "few green pushes" before it too graduates to blocking.
client-e2e-parity:
name: Client E2E (parity subset, blocking)
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: Client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: Client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run parity e2e specs
run: npx playwright test --config=playwright.config.ts --grep "@parity"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report-parity
path: |
Client/playwright-report/
Client/test-results/
retention-days: 7
# Image build is verification only, so it is skipped on dev to keep day-to-day
# work on the fast check suite. Runs for main pushes and PRs targeting main.
server-docker-build:
name: Server Docker Build (verify)
if: github.ref_name == 'main' || github.base_ref == 'main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image (no push)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: false
load: true
tags: owncord-smoke:candidate
build-args: VERSION=ci
cache-from: type=gha
cache-to: type=gha,mode=max
# Same script release.yml runs before it signs or pushes anything. A
# boot regression — or a bug in the smoke harness itself, as happened on
# the first v1.2.0-alpha.3 release run — must fail here, not at tag time.
- name: Boot-smoke Docker image
run: bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier).
#
# Skipped for Dependabot: its PRs run under the separate `dependabot` secrets
# scope, so TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build`
# always aborts with "failed to decode secret key" while signing the updater
# artifact — after a successful compile and bundle. That burned ~50 min of
# runner time per dependency PR to produce a red check that never carried any
# signal. Granting Dependabot the signing key would fix the symptom but hands
# a release key to workflows triggered by third-party dependency updates.
#
# What still covers Dependabot PRs: the required `rust-tests` job compiles the
# crate (cargo clippy --all-targets + cargo test --lib), so a dependency bump
# that breaks the Rust build is still caught.
# What this gives up on those PRs: bundling (NSIS/AppImage/deb), Windows and
# ARM-specific compilation, and the `cargo audit` step below — that last one
# overlaps with Dependabot's own cargo scanning, which is what opens these PRs
# in the first place.
tauri-build:
name: Tauri Full Build (${{ matrix.os }})
needs: client-check
if: >-
github.event_name == 'pull_request'
&& github.base_ref == 'main'
&& github.actor != 'dependabot[bot]'
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
- os: ubuntu-22.04
- os: ubuntu-22.04-arm
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: Client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: Client/package-lock.json
- name: Install Linux system dependencies
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libdbus-1-dev \
libasound2-dev \
libssl-dev \
patchelf \
librsvg2-dev \
xdg-utils
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/src-tauri
- name: Install npm dependencies
run: npm ci
- name: Clippy lint (Rust)
working-directory: Client/src-tauri/
run: cargo clippy -- -D warnings
# Rust unit tests moved to the standalone `rust-tests` job so they run on
# every event, not just PRs to main.
- name: Security audit (Rust dependencies)
working-directory: Client/src-tauri/
run: |
cargo install cargo-audit@0.22.1 --quiet
cargo audit
- name: Build Tauri app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build