diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 39c6b58b..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug Report -about: Report a bug in OwnCord -title: "bug: " -labels: bug ---- - -## Description - - - -## Steps to Reproduce - -1. -2. -3. - -## Expected Behavior - - - -## Actual Behavior - - - -## Environment - -- **OS**: Windows 11 (version) -- **OwnCord Version**: -- **Component**: Server / Client / Both - -## Screenshots / Logs - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2b3d3e62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9697221c..6d33be27 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index f415b94c..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: Feature Request -about: Suggest a new feature for OwnCord -title: "feat: " -labels: enhancement ---- - -## Problem - - - -## Proposed Solution - - - -## Alternatives Considered - - - -## Additional Context - - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b17e22bc..76a2a512 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 + + + +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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f80bdb9f..9ab6aa2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 79839351..b0a577e4 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0e4d24b..7d4b4fab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 45a3a7a6..cf408326 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/README.md b/docs/README.md index e4d65f99..8f1a4b8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/contributing.md b/docs/contributing.md index 1da1876a..eba1df50 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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 diff --git a/docs/plans/README.md b/docs/plans/README.md index 1a396c31..f7dc0e0d 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -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. | diff --git a/docs/plans/b1-release-tag-protection.sh b/docs/plans/b1-release-tag-protection.sh new file mode 100644 index 00000000..df007de4 --- /dev/null +++ b/docs/plans/b1-release-tag-protection.sh @@ -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 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 - < /^\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(); diff --git a/scripts/run.mjs b/scripts/run.mjs index 8210b6b5..ec879565 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -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 = { diff --git a/scripts/verify-gate-evidence.mjs b/scripts/verify-gate-evidence.mjs new file mode 100644 index 00000000..08626581 --- /dev/null +++ b/scripts/verify-gate-evidence.mjs @@ -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 # 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 "); + 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(); +}