mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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>
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in OwnCord
|
||||
title: "bug: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Clear description of the bug -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- What should happen -->
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<!-- What actually happens -->
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS**: Windows 11 (version)
|
||||
- **OwnCord Version**:
|
||||
- **Component**: Server / Client / Both
|
||||
|
||||
## Screenshots / Logs
|
||||
|
||||
<!-- Paste relevant logs or screenshots -->
|
||||
@@ -0,0 +1,169 @@
|
||||
# A YAML issue form, not a Markdown template: only this format can mark a field
|
||||
# required, so the environment detail a maintainer needs to reproduce a bug
|
||||
# arrives with the report instead of after a round trip.
|
||||
#
|
||||
# Nothing in this repository validates this file's schema — prettier checks it
|
||||
# parses as YAML and actionlint does not read it. A form that is valid YAML but
|
||||
# an invalid issue form silently stops appearing in the chooser, so changes here
|
||||
# want a look at the live "New issue" page afterwards.
|
||||
name: Bug report
|
||||
description: Something in the server, desktop client, or admin panel is broken.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Do not report security vulnerabilities here.** Use
|
||||
[private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new)
|
||||
instead — a public issue discloses the problem before there is a fix.
|
||||
|
||||
Questions, ideas and feedback belong in
|
||||
[Discussions](https://github.com/J3vb/OwnCord/discussions), not here.
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What went wrong, and what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps from a known starting state. A bug nobody can reproduce cannot be fixed.
|
||||
placeholder: |
|
||||
1. Start the server with …
|
||||
2. In the client, open …
|
||||
3. …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
options:
|
||||
- Server
|
||||
- Desktop client
|
||||
- Admin panel
|
||||
- Both server and client
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Server version
|
||||
description: >-
|
||||
Admin panel → Updates, or the banner the server prints at startup. It is
|
||||
deliberately not exposed on the unauthenticated /health endpoint, so
|
||||
"unknown" is a fine answer if you are not the operator. A server built
|
||||
from source reports "dev".
|
||||
placeholder: "1.2.0-alpha.3 / dev / unknown"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: client-version
|
||||
attributes:
|
||||
label: Client version
|
||||
description: Settings → Logs shows it. Leave blank for a server-only bug.
|
||||
placeholder: "1.2.0-alpha.3"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Windows 10
|
||||
- Windows 11
|
||||
- Linux
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: arch
|
||||
attributes:
|
||||
label: CPU architecture
|
||||
description: ARM64 currently applies to the Linux desktop client; there is no ARM64 server release yet.
|
||||
options:
|
||||
- x64
|
||||
- ARM64 (aarch64)
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: How is the server deployed
|
||||
options:
|
||||
- Prebuilt binary (Windows)
|
||||
- Prebuilt binary (Linux)
|
||||
- Built from source
|
||||
- Docker / Compose
|
||||
- Linux systemd service
|
||||
- Windows service (NSSM or Task Scheduler)
|
||||
- Not applicable — client-only bug
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: tls-mode
|
||||
attributes:
|
||||
label: TLS mode
|
||||
description: The `tls.mode` setting in config.yaml.
|
||||
options:
|
||||
- self_signed
|
||||
- acme
|
||||
- manual
|
||||
- "off"
|
||||
- Not applicable / not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: topology
|
||||
attributes:
|
||||
label: How do clients reach the server
|
||||
options:
|
||||
- Same machine or LAN, direct
|
||||
- Port forwarding to a public IP
|
||||
- Behind a reverse proxy
|
||||
- Tailscale
|
||||
- Not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: webview
|
||||
attributes:
|
||||
label: Client webview
|
||||
description: >-
|
||||
The desktop client renders through the OS webview — WebView2 on Windows,
|
||||
WebKitGTK on Linux — so rendering and networking bugs often depend on it.
|
||||
Skip this for a server-only bug.
|
||||
options:
|
||||
- WebView2 (Windows)
|
||||
- WebKitGTK (Linux)
|
||||
- Not applicable / not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, or anything else
|
||||
description: >-
|
||||
Server console output or Settings → Logs from the client. Redact tokens,
|
||||
invite codes and anything else you would not post publicly.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,5 +1,23 @@
|
||||
# Issues are the bug tracker only. Ideas, questions and feedback go to
|
||||
# Discussions; vulnerabilities go to private security reporting. Keeping
|
||||
# blank_issues_enabled false is what makes that routing hold — a blank issue
|
||||
# bypasses every form and every warning on it.
|
||||
#
|
||||
# The ?category= slugs must match this repository's actual Discussions
|
||||
# categories. A slug that does not exist silently drops the user on the category
|
||||
# picker rather than erroring, so check the live Discussions tab after changing
|
||||
# one.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community Support
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/J3vb/OwnCord/security/advisories/new
|
||||
about: Private disclosure. Never open a public issue for a security bug.
|
||||
- name: Ask a question
|
||||
url: https://github.com/J3vb/OwnCord/discussions/categories/q-a
|
||||
about: Setup, deployment and usage questions.
|
||||
- name: Suggest an idea
|
||||
url: https://github.com/J3vb/OwnCord/discussions/categories/ideas
|
||||
about: Feature requests and design suggestions start here, not as issues.
|
||||
- name: General discussion and feedback
|
||||
url: https://github.com/J3vb/OwnCord/discussions
|
||||
about: Ask questions and get help from the community
|
||||
about: Anything that is not a reproducible bug.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for OwnCord
|
||||
title: "feat: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
<!-- What problem does this solve? -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!-- How should it work? -->
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
<!-- Other approaches you thought about -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Mockups, links, or related issues -->
|
||||
@@ -19,14 +19,31 @@
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit tests pass (`npm test` / `go test ./...`)
|
||||
- [ ] TypeScript check passes (`npx tsc --noEmit`)
|
||||
- [ ] `npm run check` passes from the repository root — the one entry point that
|
||||
runs what CI gates on. `check:server` / `check:client` / `check:rust` /
|
||||
`check:hygiene` / `check:docs` run a single stack if that is all you touched
|
||||
- [ ] Manual testing done (describe below)
|
||||
- [ ] Generated files were regenerated, not hand-edited — `Server/db/dbgen/`,
|
||||
`Server/ws/message_types.go`, `Client/src/lib/protocolTypes.ts`,
|
||||
`Client/src/generated/`, `.superpowers/FINDINGS.md`. CI fails on drift
|
||||
- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose
|
||||
"Source of truth" files this PR touches is updated in the same PR
|
||||
(their maintenance rule), and reference docs (`api.md`, `protocol.md`,
|
||||
`schema.md`, `server-configuration.md`) reflect any surface changes
|
||||
|
||||
## Scope
|
||||
|
||||
<!-- What adjacent work did you deliberately leave out, and why? A written
|
||||
deferral is a deliverable — see docs/contributing.md#commit-format. -->
|
||||
|
||||
Not included:
|
||||
|
||||
> **No security detail in this PR.** This repository is public, so the
|
||||
> description, the commits and the branch name are all disclosure channels. If
|
||||
> this change repairs a vulnerability, report it through
|
||||
> [private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new)
|
||||
> first and describe only the control this PR adds.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If UI changes, add before/after screenshots -->
|
||||
|
||||
@@ -193,6 +193,16 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -10,14 +10,41 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Repeated triggers on one issue or pull request collapse into a single run
|
||||
# rather than fanning out. `github.event.issue.number` is present on the issues
|
||||
# and issue_comment events; `github.event.pull_request.number` on the two review
|
||||
# events. Exactly one of the two is non-empty per event, so the group is stable.
|
||||
concurrency:
|
||||
group: claude-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
# Two independent conditions, both required.
|
||||
#
|
||||
# 1. The actor is on the maintainer allowlist. This workflow consumes a
|
||||
# metered API credential, so the repository states its own trust boundary
|
||||
# here rather than relying on any downstream check. Add a login to this
|
||||
# list to grant access; there is no other way in.
|
||||
# 2. The trigger text mentions @claude.
|
||||
#
|
||||
# scripts/check-workflow-guards.mjs asserts that both this actor term and the
|
||||
# cost bounds below survive; actionlint checks expression syntax and cannot
|
||||
# see authorization intent.
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
contains(fromJSON('["J3vb"]'), github.actor) &&
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
# Every other long-running job in this repository declares a cap
|
||||
# (ci.yml rust-tests, client-e2e, admin-e2e, client-e2e-parity;
|
||||
# load-baseline). Without one the job inherits GitHub's 360-minute default,
|
||||
# which is the wrong ceiling for metered work.
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -15,12 +15,47 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# R-09 / RL-16. ci.yml has no `tags:` trigger, so a tag push starts this
|
||||
# workflow and nothing else — and this workflow re-runs none of the required
|
||||
# checks. It builds, smokes and signs, which is a different question from
|
||||
# "did the gate pass on this commit".
|
||||
#
|
||||
# It did not, at least once: v1.2.0-alpha.3 published from a commit whose
|
||||
# `Server Build & Test (windows-latest)` had concluded failure. Nothing
|
||||
# noticed, because nothing looked.
|
||||
#
|
||||
# The required set is read out of b0-dev-branch-protection.sh rather than
|
||||
# restated here, so pinning a new check cannot leave this gate behind. The
|
||||
# logic lives in a script with a --selftest that ci.yml runs on every PR:
|
||||
# a step that exists only in this file first executes at tag time, which is
|
||||
# the wrong place to discover its bugs.
|
||||
gate-evidence:
|
||||
name: Verify exact-SHA gate evidence
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Required checks must be green on the tagged commit
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/verify-gate-evidence.mjs "${{ github.sha }}"
|
||||
|
||||
# The v1.1.0-alpha.4 release shipped clients still versioned 1.1.0-alpha.3
|
||||
# because the client manifests weren't bumped before tagging — deployed
|
||||
# clients then never saw the update. Fail fast on that mismatch, before any
|
||||
# expensive build starts.
|
||||
verify-versions:
|
||||
name: Verify client version matches tag
|
||||
# Every build job needs verify-versions, and both publishers need those, so
|
||||
# one edge here gates the whole graph — nothing builds, pushes to GHCR, or
|
||||
# creates a Release on a commit that did not pass.
|
||||
needs: gate-evidence
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
A self-hosted chat app I build for me and my friends — text channels, voice and video, and a server you actually own.
|
||||
|
||||
> **Alpha, and a hobby project.**
|
||||
> This is something I build for fun and run for a small group of friends. It isn't a product, there's no support, and it isn't production-ready. Expect rough edges, rapid changes, and the occasional breaking change.
|
||||
> This is something I build for fun and run for a small group of friends. It isn't a product, it comes with no support commitment, and it isn't production-ready. Expect rough edges, rapid changes, and the occasional breaking change.
|
||||
>
|
||||
> Don't use it for anything sensitive.
|
||||
|
||||
@@ -238,6 +238,18 @@ nine, newest first.
|
||||
|
||||
See [docs/contributing.md](docs/contributing.md) for the full process.
|
||||
|
||||
## Getting Help and Reporting Problems
|
||||
|
||||
Nothing here is a support promise — see the note at the top — but there is a
|
||||
right place for each kind of message:
|
||||
|
||||
| Kind | Where |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| A reproducible bug | [Issues](https://github.com/J3vb/OwnCord/issues/new/choose) |
|
||||
| A question about setup or usage | [Discussions → Q&A](https://github.com/J3vb/OwnCord/discussions/categories/q-a) |
|
||||
| An idea or feature suggestion | [Discussions → Ideas](https://github.com/J3vb/OwnCord/discussions/categories/ideas) |
|
||||
| A security vulnerability | [Private advisory](https://github.com/J3vb/OwnCord/security/advisories/new) — never an issue |
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
+9
-7
@@ -11,13 +11,15 @@ dated snapshots that were true when written and were never updated, and
|
||||
|
||||
## Start here
|
||||
|
||||
| I want to… | Read |
|
||||
| ---------------------- | --------------------------------------- |
|
||||
| Run a server | [quick-start.md](quick-start.md) |
|
||||
| Deploy for real | [deployment.md](deployment.md) |
|
||||
| Contribute a change | [contributing.md](contributing.md) |
|
||||
| Understand the system | [architecture/](architecture/README.md) |
|
||||
| Report a vulnerability | [security.md](security.md) |
|
||||
| I want to… | Read |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| Run a server | [quick-start.md](quick-start.md) |
|
||||
| Deploy for real | [deployment.md](deployment.md) |
|
||||
| Contribute a change | [contributing.md](contributing.md) |
|
||||
| Understand the system | [architecture/](architecture/README.md) |
|
||||
| Report a bug | [Issues](https://github.com/J3vb/OwnCord/issues/new/choose) |
|
||||
| Ask, or suggest an idea | [Discussions](https://github.com/J3vb/OwnCord/discussions) |
|
||||
| Report a vulnerability | [security.md](security.md) |
|
||||
|
||||
## Guidance
|
||||
|
||||
|
||||
+28
-1
@@ -164,6 +164,33 @@ beside it.
|
||||
|
||||
---
|
||||
|
||||
## Reporting problems, and where things go
|
||||
|
||||
Bugs, questions and vulnerabilities have three different destinations, and the
|
||||
difference matters most for the third.
|
||||
|
||||
| Kind | Where |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A reproducible bug | [Issues](https://github.com/J3vb/OwnCord/issues/new/choose) — the form asks for the environment detail needed to reproduce it |
|
||||
| A question about setup or usage | [Discussions → Q&A](https://github.com/J3vb/OwnCord/discussions/categories/q-a) |
|
||||
| An idea or feature suggestion | [Discussions → Ideas](https://github.com/J3vb/OwnCord/discussions/categories/ideas) — not an issue |
|
||||
| A security vulnerability | [Private advisory](https://github.com/J3vb/OwnCord/security/advisories/new) |
|
||||
|
||||
### Security reporting
|
||||
|
||||
**Never open a public issue, pull request, or discussion for a security bug.**
|
||||
Use [private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new);
|
||||
[SECURITY.md](../SECURITY.md) is the canonical policy and states the response
|
||||
timeline, and [docs/security.md](security.md) says what stays private and for
|
||||
how long.
|
||||
|
||||
This repository is public, so a commit message, a PR description and a branch
|
||||
name are all disclosure channels. If you are fixing something you believe is a
|
||||
security problem, say so in the private advisory first and let the fix be
|
||||
coordinated — do not describe the weakness in the public change that repairs it.
|
||||
The same applies to weaknesses in the repository's own automation and settings,
|
||||
not only to bugs in the server or client.
|
||||
|
||||
## Branch and PR model
|
||||
|
||||
This section is the single source of truth for the branch model. Everywhere
|
||||
@@ -174,7 +201,7 @@ links here rather than restating it.
|
||||
- `main` -- releases only. `dev` is merged to `main` for a release, and release
|
||||
tags are cut from `main`.
|
||||
|
||||
`dev` is protected and PR-only: direct pushes are rejected, ten status checks
|
||||
`dev` is protected and PR-only: direct pushes are rejected, twelve status checks
|
||||
are required, `required_approving_review_count` is 0, and the rule is enforced
|
||||
on admins. So a PR is self-mergeable once CI is green, but no commit reaches
|
||||
`dev` without CI having run on it. Settings and rationale live in
|
||||
|
||||
@@ -17,7 +17,7 @@ authority**.
|
||||
| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 88 planning rows. Public-safe; not a replacement for the ledger. |
|
||||
| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. |
|
||||
| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. |
|
||||
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 done, B1-1 next.** B1 execution plan. Re-verifies every RL-* claim against HEAD; several are refuted. |
|
||||
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-7 done, B1-8 next.** B1 execution plan. Re-verifies every RL-* claim against HEAD; several are refuted. |
|
||||
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
|
||||
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# R-09 / RL-16, second limb: make a release tag hard to create by accident, and
|
||||
# put a human between a green tag and a published artifact.
|
||||
#
|
||||
# B1-7 landed the first limb in the workflow itself: `gate-evidence` in
|
||||
# release.yml refuses to build or publish unless every required check was green
|
||||
# on the exact tagged commit. That closes the "published from a red commit"
|
||||
# hole — v1.2.0-alpha.3 really did ship from a commit whose
|
||||
# `Server Build & Test (windows-latest)` had failed.
|
||||
#
|
||||
# What a workflow file cannot do is stop the tag existing, or require a person
|
||||
# to approve the publish. Both are repository settings. That is this script.
|
||||
#
|
||||
# Run this yourself: Claude Code's sandbox blocks repo-settings writes.
|
||||
# bash docs/plans/b1-release-tag-protection.sh
|
||||
#
|
||||
# Choices worth knowing:
|
||||
# Two independent controls, deliberately.
|
||||
# The ruleset stops a tag appearing by mistake. The environment stops a
|
||||
# tag that does exist from publishing without a person. Either alone
|
||||
# leaves a gap: a ruleset does not review, and an environment does not
|
||||
# prevent a bad tag from starting three build jobs first.
|
||||
# Ruleset, not the legacy tag-protection endpoint.
|
||||
# `repos/{owner}/{repo}/tags/protection` is deprecated. Rulesets are the
|
||||
# supported form and can additionally block update and delete, which
|
||||
# matters here: the Release workflow's own concurrency comment records a
|
||||
# tag being deleted and re-pushed, so "the tagged commit" has not always
|
||||
# been a stable referent.
|
||||
# bypass_actors: [] — nobody bypasses, including you.
|
||||
# Same reasoning as enforce_admins in b0-dev-branch-protection.sh. On a
|
||||
# solo-admin repo a bypass makes the guard decorative. Add yourself back
|
||||
# temporarily if a release genuinely needs it; that is a deliberate act
|
||||
# rather than a silent default.
|
||||
# The `release` environment has NO wait timer.
|
||||
# The point is a person looking, not a delay. A timer without a reviewer
|
||||
# is theatre; a reviewer without a timer is the control.
|
||||
#
|
||||
# AFTER RUNNING THIS, one more edit is needed and it is NOT done here:
|
||||
# add `environment: release` to the `release-server-docker` and `publish`
|
||||
# jobs in .github/workflows/release.yml. That is deliberately left out of
|
||||
# B1-7 — an `environment:` key naming an environment that does not exist yet
|
||||
# stalls the next release. Create the environment first, then add the key.
|
||||
#
|
||||
# To undo:
|
||||
# gh api -X DELETE "repos/J3vb/OwnCord/rulesets/<id>" # id from the list call below
|
||||
# gh api -X DELETE "repos/J3vb/OwnCord/environments/release"
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${REPO:-J3vb/OwnCord}"
|
||||
OWNER="${REPO%%/*}"
|
||||
|
||||
# ── 1. Protect refs/tags/v* ──────────────────────────────────────────────────
|
||||
# creation is allowed (you still need to cut releases); update and deletion are
|
||||
# not, so a published tag cannot be quietly re-pointed at a different commit.
|
||||
gh api -X POST "repos/${REPO}/rulesets" --input - <<'JSON'
|
||||
{
|
||||
"name": "Release tags",
|
||||
"target": "tag",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"include": ["refs/tags/v*"],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{ "type": "update" },
|
||||
{ "type": "deletion" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
# ── 2. A reviewed environment for the publishing jobs ────────────────────────
|
||||
# Required reviewers gate the job at the point it would push to GHCR or create
|
||||
# the Release — after the gate-evidence job has already proved the commit is
|
||||
# green, so the reviewer is confirming intent, not re-checking CI.
|
||||
gh api -X PUT "repos/${REPO}/environments/release" --input - <<JSON
|
||||
{
|
||||
"wait_timer": 0,
|
||||
"prevent_self_review": false,
|
||||
"reviewers": [
|
||||
{ "type": "User", "id": $(gh api "users/${OWNER}" -q .id) }
|
||||
],
|
||||
"deployment_branch_policy": null
|
||||
}
|
||||
JSON
|
||||
|
||||
echo
|
||||
echo "Applied. Verifying:"
|
||||
gh api "repos/${REPO}/rulesets" -q '
|
||||
.[] | select(.name == "Release tags") |
|
||||
" ruleset: " + .name + " (" + .enforcement + ", target " + .target + ")"'
|
||||
gh api "repos/${REPO}/environments/release" -q '
|
||||
" environment: " + .name,
|
||||
" reviewers: " + ((.protection_rules[]? | select(.type=="required_reviewers") | .reviewers | length) // 0 | tostring),
|
||||
" wait timer: " + ((.protection_rules[]? | select(.type=="wait_timer") | .wait_timer) // 0 | tostring)'
|
||||
echo
|
||||
echo "Next: add 'environment: release' to release-server-docker and publish in"
|
||||
echo ".github/workflows/release.yml. Not before — the key stalls a release if"
|
||||
echo "the environment does not exist."
|
||||
@@ -3,9 +3,9 @@
|
||||
**Drafted:** 2026-08-25
|
||||
**Base commit:** `6a1561fa` (`dev`, post-PR #1409)
|
||||
**Status:** in progress; **entry gate met — HP-0 accepted 2026-08-25**. B1-0
|
||||
(#1410), B1-1 (#1411), B1-2 (#1412), B1-3 (#1414), B1-4 (#1415), B1-5 (#1417)
|
||||
and B1-6 (this branch) are complete; B1-7, community intake and automation
|
||||
authorization, is the next step.
|
||||
(#1410), B1-1 (#1411), B1-2 (#1412), B1-3 (#1414), B1-4 (#1415), B1-5 (#1417),
|
||||
B1-6 (#1418) and B1-7 (this branch) are complete; B1-8, the platform contract
|
||||
map, is the next step — and it is documentation only.
|
||||
|
||||
Primary inputs:
|
||||
|
||||
@@ -147,7 +147,7 @@ that changes the work.
|
||||
| **RL-13** module namespace | **Confirmed, bounded** | `Server/go.mod` declares `github.com/owncord/server`: **722 occurrences across 344 Go files**, plus six non-Go (go.mod, a `sed` in `Server/Makefile`, two docs, the ledger pair). **Zero** in any workflow or Dockerfile; no `.goreleaser` exists. |
|
||||
| **RL-19** format/lint gaps | **Confirmed, all sub-claims** | No `.editorconfig` anywhere. Prettier is scoped to the client's `src/` and `tests/` TypeScript, so root Markdown, all of `docs/`, every YAML/JSON and all CSS are formatted by nothing. `.golangci.yml` enables 19 linters but no `gofmt`/`gofumpt`/`goimports`. No `cargo fmt --check`. No shellcheck/actionlint/yamllint. |
|
||||
| **RL-21** intake | **Confirmed, understated** | `feature_request.md` still exists. Both templates are **Markdown, not YAML issue forms** — no `body:`, no `validations: required`, so nothing is structured or enforced. The Environment block hardcodes one OS. |
|
||||
| **RL-22** paid automation authorization | **Confirmed** | Insufficient; impact bounded today by read-only content permissions. Mechanism, guard text, and fix stay out of public commits, issues, and PR bodies per [docs/security.md](../security.md). Tracked as `L-16` only. |
|
||||
| **RL-22** paid automation authorization | **Confirmed; done in B1-7** | Insufficient. The earlier note that impact was bounded by read-only content permissions understated it: the workflow's own token block is least-privilege, but that is not the only identity a run can hold. Mechanism, guard text, and fix stay out of public commits, issues, and PR bodies per [docs/security.md](../security.md). Tracked as `L-16` only. |
|
||||
|
||||
Net effect: **RL-09 and RL-10 shrink to near-nothing; RL-08 grows a toolchain
|
||||
constraint; RL-05, RL-07, RL-20 and RL-21 are each worse than written.**
|
||||
@@ -469,10 +469,13 @@ behaviour rather than adopting workspaces on principle.
|
||||
Environment block hardcodes one OS. Convert to YAML forms; add browser/PWA,
|
||||
CPU architecture, and deployment-mode fields; route ideas and feedback to
|
||||
Discussions.
|
||||
- **`RL-22/L-16`** — harden authorization for externally triggered paid
|
||||
automation. Impact is bounded today by read-only content permissions. Mechanism
|
||||
and fix are coordinated privately per [docs/security.md](../security.md); they
|
||||
do not appear in public commits, issues, or PR descriptions.
|
||||
- **`RL-22/L-16`** — **done.** The workflow now states its own trust boundary,
|
||||
bounds each run's duration, collapses repeated triggers, and carries a
|
||||
regression test inside the pinned hygiene gate. The earlier claim that impact
|
||||
was bounded by read-only content permissions understated it — a run's effective
|
||||
identity is not only the workflow's token block. Mechanism and fix are
|
||||
coordinated privately per [docs/security.md](../security.md); they do not
|
||||
appear in public commits, issues, or PR descriptions.
|
||||
- **`RL-16/R-09`** — tag publication consumes exact-SHA gate evidence.
|
||||
|
||||
## B1-8 — Platform contract map (documentation only)
|
||||
|
||||
@@ -13,6 +13,26 @@ The repository-root [SECURITY.md](../SECURITY.md) is the canonical reporting
|
||||
policy — what to include and the response timeline (initial response within
|
||||
7 days) live there, so the two files cannot disagree.
|
||||
|
||||
## What stays private, and for how long
|
||||
|
||||
This applies to weaknesses in the repository's own automation and settings —
|
||||
workflow authorization, credential scope, release gating — as much as to bugs in
|
||||
the server or client. Planning documents cite this section as the rule; it is
|
||||
written here so the citation points at something.
|
||||
|
||||
- Public artifacts — commits, issues, pull request descriptions, changelogs —
|
||||
carry an **opaque identifier, the affected property, safe acceptance criteria,
|
||||
and a status**. Nothing more.
|
||||
- Reproduction steps, source-to-sink traces, exploit conditions, and the state a
|
||||
fix replaced stay in the private advisory. A commit that fixes a weakness
|
||||
describes the control it adds, not the gap it closes.
|
||||
- Every private finding has exactly one public owner, so nothing is tracked only
|
||||
in private and nothing is silently dropped.
|
||||
- Release notes may describe repaired impact after coordinated remediation,
|
||||
without the detail needed to reproduce it.
|
||||
|
||||
This repository is public. A commit message is a disclosure channel.
|
||||
|
||||
## Two-Factor Authentication
|
||||
|
||||
OwnCord supports TOTP-based 2FA:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
// Fail when a workflow that consumes a metered credential loses one of its
|
||||
// guards (L-16).
|
||||
//
|
||||
// node scripts/check-workflow-guards.mjs
|
||||
// node scripts/check-workflow-guards.mjs --selftest
|
||||
//
|
||||
// Why this exists rather than trusting review: the guards below are three lines
|
||||
// in a YAML file that nothing else verifies. actionlint checks expression
|
||||
// syntax and action inputs — it has no concept of authorization or of cost, and
|
||||
// `if: contains(...)` is valid input to it whatever the expression says. A
|
||||
// dependency the guards rely on can also be updated by a routine bump, so the
|
||||
// repository asserts its own invariants here instead of inheriting them.
|
||||
//
|
||||
// Deliberately text-level, not YAML-parsed: there is no YAML parser among the
|
||||
// root devDependencies, and adding one to assert "this file contains a
|
||||
// timeout-minutes key" would be a dependency bought for a substring search. The
|
||||
// cost is that these checks are about presence and shape, not semantics — which
|
||||
// is the honest limit of what a regression test can claim here.
|
||||
//
|
||||
// Scope: workflows that reference a metered secret. Add one to METERED below
|
||||
// when a new workflow starts spending.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
// Workflows whose runs consume a metered credential, and therefore must carry
|
||||
// every guard in CHECKS. An unlisted workflow is not checked.
|
||||
const METERED = [".github/workflows/claude.yml"];
|
||||
|
||||
// Each check is (name, test, why). `why` is the failure message: it states the
|
||||
// invariant a contributor has to restore, not the history behind it.
|
||||
export const CHECKS = [
|
||||
{
|
||||
name: "timeout-minutes",
|
||||
test: (src) => /^\s*timeout-minutes:\s*\d+\s*$/m.test(src),
|
||||
why: "a workflow consuming a metered credential must declare timeout-minutes; without one it inherits GitHub's 360-minute default",
|
||||
},
|
||||
{
|
||||
name: "concurrency group",
|
||||
test: (src) => /^concurrency:\s*$/m.test(src) && /^\s*group:\s*\S/m.test(src),
|
||||
why: "a workflow consuming a metered credential must declare a concurrency group so repeated triggers collapse instead of running in parallel",
|
||||
},
|
||||
{
|
||||
name: "cancel-in-progress",
|
||||
test: (src) => /^\s*cancel-in-progress:\s*true\s*$/m.test(src),
|
||||
why: "the concurrency group must set cancel-in-progress: true, or superseded runs keep spending",
|
||||
},
|
||||
{
|
||||
name: "actor allowlist",
|
||||
// The job condition must test who triggered the run, not only what the
|
||||
// trigger text says. A content-only condition is satisfied by anyone.
|
||||
test: (src) => /github\.actor/.test(src) && /\bif:/.test(src),
|
||||
why: "the job condition must constrain github.actor, not only the trigger text — a content-only condition places no limit on who can start a run",
|
||||
},
|
||||
];
|
||||
|
||||
export function auditWorkflow(src) {
|
||||
return CHECKS.filter((c) => !c.test(src)).map((c) => ({ name: c.name, why: c.why }));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
|
||||
for (const rel of METERED) {
|
||||
const p = join(ROOT, rel);
|
||||
if (!existsSync(p)) {
|
||||
failures.push(
|
||||
`${rel}: listed in METERED but does not exist — fix the list in ${"scripts/check-workflow-guards.mjs"}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const src = readFileSync(p, "utf8");
|
||||
for (const { name, why } of auditWorkflow(src)) {
|
||||
failures.push(`${rel}: missing ${name} — ${why}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} workflow guard(s) missing:\n`);
|
||||
for (const f of failures) console.error(` ${f}`);
|
||||
console.error("\nThese guards bound who can start a metered run and how long it may last.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${CHECKS.length} guard(s) present in ${METERED.length} metered workflow(s): ${METERED.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function selftest() {
|
||||
let failed = 0;
|
||||
const assert = (cond, msg) => {
|
||||
console.log(`${cond ? "PASS" : "FAIL"} ${msg}`);
|
||||
if (!cond) failed++;
|
||||
};
|
||||
|
||||
const good = [
|
||||
"name: X",
|
||||
"concurrency:",
|
||||
" group: x-${{ github.event.issue.number }}",
|
||||
" cancel-in-progress: true",
|
||||
"jobs:",
|
||||
" j:",
|
||||
" if: |",
|
||||
" contains(fromJSON('[\"someone\"]'), github.actor) && true",
|
||||
" runs-on: ubuntu-latest",
|
||||
" timeout-minutes: 30",
|
||||
].join("\n");
|
||||
|
||||
assert(auditWorkflow(good).length === 0, "a fully guarded workflow reports nothing");
|
||||
|
||||
const missing = (src) => auditWorkflow(src).map((f) => f.name);
|
||||
|
||||
assert(
|
||||
missing(good.replace(" timeout-minutes: 30", "")).includes("timeout-minutes"),
|
||||
"a missing timeout-minutes is caught",
|
||||
);
|
||||
assert(
|
||||
missing(good.replace("concurrency:", "# concurrency:")).includes("concurrency group"),
|
||||
"a missing concurrency group is caught",
|
||||
);
|
||||
assert(
|
||||
missing(good.replace(" cancel-in-progress: true", " cancel-in-progress: false")).includes(
|
||||
"cancel-in-progress",
|
||||
),
|
||||
"cancel-in-progress: false is caught",
|
||||
);
|
||||
assert(
|
||||
missing(good.replace("contains(fromJSON('[\"someone\"]'), github.actor) && ", "")).includes(
|
||||
"actor allowlist",
|
||||
),
|
||||
"a condition with no actor term is caught",
|
||||
);
|
||||
|
||||
// The shapes that must NOT trip it.
|
||||
assert(
|
||||
auditWorkflow(good.replace("timeout-minutes: 30", "timeout-minutes: 5")).length === 0,
|
||||
"any positive timeout satisfies the check, not one specific value",
|
||||
);
|
||||
assert(
|
||||
auditWorkflow(good.replace("github.event.issue.number", "github.ref")).length === 0,
|
||||
"the concurrency key is not prescribed, only its presence",
|
||||
);
|
||||
|
||||
// A commented-out guard is not a guard.
|
||||
assert(
|
||||
missing(good.replace(" timeout-minutes: 30", " # timeout-minutes: 30")).includes(
|
||||
"timeout-minutes",
|
||||
),
|
||||
"a commented-out timeout does not count",
|
||||
);
|
||||
|
||||
console.log(
|
||||
failed ? `\nselftest: ${failed} assertion(s) failed` : "\nselftest: all assertions pass",
|
||||
);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
if (process.argv.includes("--selftest")) selftest();
|
||||
else main();
|
||||
@@ -146,6 +146,13 @@ const CHECK_HYGIENE = [
|
||||
".",
|
||||
"actionlint not on PATH — no clean Windows install; CI runs it",
|
||||
),
|
||||
// L-16. actionlint validates expression syntax and action inputs; it has no
|
||||
// concept of who a condition admits or how long a job may run. This asserts
|
||||
// the guards on workflows that spend. `step`, not `optional`: it is Node, and
|
||||
// this file is Node. It lives in check:hygiene so it runs inside the pinned
|
||||
// Repository Hygiene job rather than needing a new required check.
|
||||
step("node", ["scripts/check-workflow-guards.mjs", "--selftest"], "."),
|
||||
step("node", ["scripts/check-workflow-guards.mjs"], "."),
|
||||
];
|
||||
|
||||
const TASKS = {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
// Fail when the commit being released does not carry green evidence for every
|
||||
// required check (R-09 / RL-16).
|
||||
//
|
||||
// node scripts/verify-gate-evidence.mjs <sha> # assert, using $GITHUB_TOKEN
|
||||
// node scripts/verify-gate-evidence.mjs --selftest
|
||||
//
|
||||
// A tag push starts release.yml and nothing else. ci.yml has no `tags:` trigger,
|
||||
// so the tagged commit is only ever covered by the CI that ran when that same
|
||||
// commit sat on a branch — and until this script existed, nothing checked that
|
||||
// it had. release.yml re-runs none of the required contexts: it builds, smokes
|
||||
// and signs, which is a different question from "did the gate pass".
|
||||
//
|
||||
// This is deliberately a script and not a `run:` block. .claude/skills/ci-check
|
||||
// states the rule: 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, called from both workflows. Here the
|
||||
// second call site is `--selftest` in ci.yml, which exercises the decision logic
|
||||
// on fixtures every PR without needing a tag or a network call.
|
||||
//
|
||||
// The required set is read from b0-dev-branch-protection.sh rather than
|
||||
// duplicated, so pinning a new check cannot leave this gate behind.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PROTECTION_SCRIPT = "docs/plans/b0-dev-branch-protection.sh";
|
||||
|
||||
// The contexts array in the protection script is the single source of truth for
|
||||
// what "the gate" means. Parsed out of the heredoc rather than re-listed.
|
||||
export function requiredContexts(scriptSrc) {
|
||||
const block = scriptSrc.match(/"contexts"\s*:\s*\[([^\]]*)\]/);
|
||||
if (!block) throw new Error(`no "contexts" array found in ${PROTECTION_SCRIPT}`);
|
||||
return [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
// checkRuns is the API's check_runs array, already collected across pages.
|
||||
// Returns the reasons this commit is not releasable; empty means it is.
|
||||
export function evaluate(required, checkRuns) {
|
||||
const problems = [];
|
||||
const byName = new Map();
|
||||
for (const run of checkRuns) {
|
||||
// A context can report more than once (a re-run). The latest attempt wins,
|
||||
// which is what the branch-protection UI shows and what a human would read.
|
||||
const prev = byName.get(run.name);
|
||||
if (!prev || (run.started_at ?? "") >= (prev.started_at ?? "")) byName.set(run.name, run);
|
||||
}
|
||||
|
||||
for (const name of required) {
|
||||
const run = byName.get(name);
|
||||
if (!run) {
|
||||
problems.push(`${name}: never reported on this commit`);
|
||||
continue;
|
||||
}
|
||||
if (run.status !== "completed") {
|
||||
problems.push(`${name}: still ${run.status} — the gate is not finished`);
|
||||
continue;
|
||||
}
|
||||
// `neutral` and `skipped` are not success. A required check that skipped on
|
||||
// the tagged commit proves nothing about it.
|
||||
if (run.conclusion !== "success") {
|
||||
problems.push(`${name}: ${run.conclusion}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
async function fetchCheckRuns(repo, sha, token) {
|
||||
const runs = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const url = `https://api.github.com/repos/${repo}/commits/${sha}/check-runs?per_page=100&page=${page}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-github-api-version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
runs.push(...body.check_runs);
|
||||
// total_count is the count for the whole commit, not the page.
|
||||
if (runs.length >= body.total_count || body.check_runs.length === 0) break;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sha = process.argv[2];
|
||||
if (!sha) {
|
||||
console.error("usage: node scripts/verify-gate-evidence.mjs <sha>");
|
||||
process.exit(2);
|
||||
}
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!repo || !token) {
|
||||
console.error("GITHUB_REPOSITORY and GITHUB_TOKEN must be set");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const p = join(ROOT, PROTECTION_SCRIPT);
|
||||
if (!existsSync(p)) {
|
||||
console.error(`missing ${PROTECTION_SCRIPT} — it defines the required set`);
|
||||
process.exit(2);
|
||||
}
|
||||
const required = requiredContexts(readFileSync(p, "utf8"));
|
||||
|
||||
const runs = await fetchCheckRuns(repo, sha, token);
|
||||
const problems = evaluate(required, runs);
|
||||
|
||||
console.log(`commit ${sha}: ${runs.length} check run(s), ${required.length} required`);
|
||||
if (problems.length) {
|
||||
console.error(`\n${problems.length} required check(s) do not evidence a green gate:\n`);
|
||||
for (const x of problems) console.error(` ${x}`);
|
||||
console.error(
|
||||
"\nThis commit is not releasable. Publication consumes exact-SHA gate\n" +
|
||||
"evidence: the tag must point at a commit whose required checks all passed.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`all ${required.length} required check(s) green — releasable`);
|
||||
}
|
||||
|
||||
function selftest() {
|
||||
let failed = 0;
|
||||
const assert = (cond, msg) => {
|
||||
console.log(`${cond ? "PASS" : "FAIL"} ${msg}`);
|
||||
if (!cond) failed++;
|
||||
};
|
||||
|
||||
// Parsing the real protection script, not a fixture: if its shape changes,
|
||||
// this gate must find out on a pull request rather than at tag time.
|
||||
const real = requiredContexts(readFileSync(join(ROOT, PROTECTION_SCRIPT), "utf8"));
|
||||
assert(real.length >= 10, `reads the required set from ${PROTECTION_SCRIPT} (${real.length})`);
|
||||
assert(
|
||||
real.includes("Server Build & Test (ubuntu-latest)"),
|
||||
"an ampersand name survives parsing",
|
||||
);
|
||||
|
||||
const req = ["A", "B"];
|
||||
const ok = (name, extra = {}) => ({
|
||||
name,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
...extra,
|
||||
});
|
||||
|
||||
assert(evaluate(req, [ok("A"), ok("B")]).length === 0, "all required green → releasable");
|
||||
assert(
|
||||
evaluate(req, [ok("A"), ok("B"), ok("Extra")]).length === 0,
|
||||
"an unrequired extra check does not block",
|
||||
);
|
||||
|
||||
const why = (runs) => evaluate(req, runs).join(" | ");
|
||||
assert(why([ok("A")]).includes("B: never reported"), "a missing required check is caught");
|
||||
assert(
|
||||
why([ok("A"), { name: "B", status: "completed", conclusion: "failure" }]).includes(
|
||||
"B: failure",
|
||||
),
|
||||
"a failed required check is caught",
|
||||
);
|
||||
assert(
|
||||
why([ok("A"), { name: "B", status: "in_progress", conclusion: null }]).includes("still"),
|
||||
"a still-running required check is caught, not treated as absent",
|
||||
);
|
||||
assert(
|
||||
why([ok("A"), { name: "B", status: "completed", conclusion: "skipped" }]).includes(
|
||||
"B: skipped",
|
||||
),
|
||||
"skipped is not success — a skipped required check proves nothing",
|
||||
);
|
||||
assert(
|
||||
why([ok("A"), { name: "B", status: "completed", conclusion: "neutral" }]).includes(
|
||||
"B: neutral",
|
||||
),
|
||||
"neutral is not success",
|
||||
);
|
||||
|
||||
// Re-runs: the latest attempt decides, in both directions.
|
||||
assert(
|
||||
evaluate(req, [
|
||||
ok("A"),
|
||||
{ name: "B", status: "completed", conclusion: "failure", started_at: "2020-01-01T00:00:00Z" },
|
||||
ok("B", { started_at: "2020-01-02T00:00:00Z" }),
|
||||
]).length === 0,
|
||||
"a green re-run supersedes an earlier failure",
|
||||
);
|
||||
assert(
|
||||
why([
|
||||
ok("A"),
|
||||
ok("B", { started_at: "2020-01-01T00:00:00Z" }),
|
||||
{ name: "B", status: "completed", conclusion: "failure", started_at: "2020-01-02T00:00:00Z" },
|
||||
]).includes("B: failure"),
|
||||
"a failed re-run supersedes an earlier success",
|
||||
);
|
||||
|
||||
assert(evaluate(req, []).length === 2, "a commit with no checks at all is not releasable");
|
||||
|
||||
console.log(
|
||||
failed ? `\nselftest: ${failed} assertion(s) failed` : "\nselftest: all assertions pass",
|
||||
);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
// Run only when invoked directly, so `evaluate` and `requiredContexts` can be
|
||||
// imported and exercised without the module trying to reach the network.
|
||||
// Compared against argv[1] rather than `import.meta.main`, which needs Node
|
||||
// 24.2 while package.json's engines floor is >=24 — on 24.0 it is undefined and
|
||||
// the script would silently do nothing.
|
||||
const invokedDirectly =
|
||||
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (invokedDirectly) {
|
||||
if (process.argv.includes("--selftest")) selftest();
|
||||
else await main();
|
||||
}
|
||||
Reference in New Issue
Block a user