# Engineering Standards - Daniel Fry

These are my engineering standards: the patterns I hold code, repositories and
releases to, written down precisely enough to be enforceable. The throughline is
the same everywhere - **make the bad state impossible rather than merely
caught**. In types, that means the compiler proves correctness; in tests, every
test pins real behavior and can actually fail; in the supply chain, a
compromised dependency, action or token has nothing to grab.

**If you are a coding agent** asked to follow these standards: treat every rule
below as mandatory for the code, configuration and workflows you write or
review, wherever the rule's language or tooling applies. In examples,
`good:` marks the required pattern and `bad:` marks the anti-pattern to
reject. Some steps can only be done by a human in a browser - registering a
trusted publisher, creating a GitHub App, approving a staged npm publish. Give
exact instructions and wait; never substitute a long-lived token.

Canonical source: https://github.com/danfry1/standards (this page is generated
from it). Per-topic versions of this document:

- [TypeScript](/standards/typescript.md) - 27 rules for maximum, config-independent type strictness - discriminated unions, branded primitives, precise generics, `unknown` boundaries, `Result` over throwing; no `any`/`enum`/unchecked `as`.
- [Error handling](/standards/error-handling.md) - 14 rules so no error is ever opaque - typed custom errors with a literal discriminant, structured context, preserved `cause`, narrow-don't-cast in `catch`, and one exhaustive top-level handler.
- [Testing](/standards/testing.md) - 19 rules for tests that catch regressions instead of granting fake safety - exact assertions over truthiness, specific errors, determinism, isolation, behavior over implementation.
- [Supply-chain security](/standards/supply-chain-security.md) - 12 rules for the code you pull in - zero runtime dependencies, a 7-day cooldown at update and install time, exact pins, frozen installs, default-deny install scripts, owning your names, an audit gate, and checking what actually runs.
- [GitHub Actions](/standards/github-actions.md) - 15 rules for CI that cannot be turned against you - verified SHA pins, read-only tokens, no persisted credentials, no template injection, no release caches, an isolated publish job, and zizmor on every workflow.
- [Repository](/standards/repository.md) - 8 rules for how a repository is configured - tiers, security settings, rulesets on main and release tags, documented exceptions, Dependabot with a cooldown, and a security policy a solo maintainer can keep.
- [Releasing](/standards/releasing.md) - Principles and per-channel rules for npm (staged, OIDC, 2FA-approved), crates.io (trusted-only), binaries (checksums + attestations), Homebrew (source formulae + bottles behind a manual gate), Scoop, GitHub Actions, and security releases.
- [Maintainer accounts](/standards/accounts.md) - 7 rules for the humans behind it - passkeys, offline recovery codes, the recovery inbox as master key, no standing credentials, no personal access tokens, and a contact address on a domain you own.

---

# TypeScript standards

A precise, opinionated standard for writing TypeScript where **illegal states are
unrepresentable** and the compiler - not code review - is what proves the code
correct. The posture is constant: reach for the *narrowest type the compiler can
prove*, and design types so a bug fails to compile rather than at runtime.

Every rule is config-independent - each example compiles and stays strict under
any reasonable `tsconfig`. The [Enforcement](#enforcement-typescript-compiler-and-lint-flags)
section lists the flags that mechanically lock the rules in on a project you own.

---

## Core rules

### 1. Strictest type the compiler can prove - never widen on purpose

The default posture: the *narrowest* type that's still true. A type that admits
illegal states is a bug even if the code runs. Treat `string`, `number`,
`object`, and `any` as smells when something more specific is knowable.

### 2. Return types must not widen past what the body proves

If a function can only return `0` or `1`, its return type is `0 | 1`, **not**
`number`. Don't hand-annotate a wider type than the body produces, and don't let
inference widen - annotate the narrow literal union, or let inference keep it.

```ts
// bad: return type widened to `number`
function toBit(on: boolean): number {
  return on ? 1 : 0;
}

// good: caller gets `0 | 1` and can use it in a literal-typed context
function toBit(on: boolean): 0 | 1 {
  return on ? 1 : 0;
}

// good: also fine: let inference keep it narrow (no annotation widening it)
const toBit = (on: boolean) => (on ? 1 : 0); // inferred: (on: boolean) => 0 | 1
```

Same idea for string-returning functions (`"asc" | "desc"`, not `string`) and
for objects (return the exact shape, not a widened interface).

### 3. `as const` + `satisfies`, together, for config/lookup data

`as const` preserves the exact literal shape; `satisfies` checks it against a
constraint *without widening*. Annotating with `: Record<...>` is the
anti-pattern - it erases keys and widens values.

```ts
// bad: keys erased, values widened
const routes: Record<string, string> = { home: "/", user: "/users/:id" };

// good: literals + keys preserved AND constrained
const routes = {
  home: "/",
  user: "/users/:id",
} as const satisfies Record<string, `/${string}`>;
type RouteName = keyof typeof routes; // "home" | "user"
```

### 4. Literal & template-literal types over `string`

`string` is the `any` of strings. Closed set -> union. Shaped value (URLs, ids,
css lengths, event names) -> template literal.

```ts
type Method = "get" | "post" | "put" | "delete";
type CssLength = `${number}px` | `${number}rem` | `${number}%`;
type ApiUrl = `https://api.example.com/${string}`;
```

Prefer template literals for anything assembled from parts (URLs, keys, class
names) so the shape is enforced and the pieces stay derivable.

### 5. Objects typed as strictly as possible

No bag-of-optionals when the valid fields depend on a mode - that's a
discriminated union. Mark fields `readonly` by default. Don't reach for an
index signature when the keys are known.

```ts
// bad: illegal states representable: { status: "success", error: "..." }
type Result = { status: string; data?: User; error?: string };

// good: each variant carries exactly its own fields
type Result =
  | { readonly status: "loading" }
  | { readonly status: "success"; readonly data: User }
  | { readonly status: "error"; readonly error: Error };
```

### 6. Exhaustiveness with `never`

Close every `switch`/match over a discriminant with an `assertNever` so adding a
variant becomes a compile error.

```ts
function render(r: Result): string {
  switch (r.status) {
    case "loading": return "...";
    case "success": return r.data.name;
    case "error":   return r.error.message;
    default:        return assertNever(r);
  }
}
const assertNever = (x: never): never => {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
};
```

### 7. Generics and conditional types wherever they relate things

Generics exist to *relate* input to output, not to accept anything. Constrain
every type parameter, let inference flow, and reach for conditional types to
express the relationship.

```ts
// relates element type in to element type out; honors noUncheckedIndexedAccess
function first<const T extends readonly unknown[]>(xs: T): T[number] | undefined {
  return xs[0];
}

type Unwrap<T> = T extends Promise<infer U> ? U : T;
```

Control inference explicitly: a `const` type parameter preserves literal/tuple
types through the call, and `NoInfer<T>` stops a parameter from being an
inference source - so a default or fallback can't widen `T`.

```ts
function withDefault<T>(value: T | undefined, fallback: NoInfer<T>): T {
  return value ?? fallback;
}
```

### 8. Ban the escape hatches

- `any` -> `unknown`, then narrow with a *validated* type predicate / assertion fn.
- `as T` is a last resort; the only freely-allowed assertion is `as const`. No
  double casts - `as unknown as T` launders a lie through two casts; validate.
- No non-null `!` - narrow or prove via control flow.
- No `enum` - `as const` object + derived union.
- No weak types - `object`, `{}`, `Function`, `any[]` accept far too much (`{}`
  is anything non-nullish; `Function` is any callable with unchecked args). Use a
  precise signature, `Record<K, V>`, `unknown`, or `readonly unknown[]`.

```ts
// bad: const run = (fn: Function) => fn();
// good
const run = <A extends readonly unknown[], R>(fn: (...a: A) => R, ...a: A): R => fn(...a);
```

### 9. Brand every primitive that carries an invariant

A primitive whose value range or provenance matters is not just a `string` or
`number`. Brand it so the compiler tracks where it came from and two same-shaped
primitives stop being interchangeable. The `as Brand` cast lives **only** inside
the smart constructor / validator - never at a call site.

```ts
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type Email  = Brand<string, "Email">;
type Cents  = Brand<number, "Cents">;      // money in integer minor units
type Ratio  = Brand<number, "Ratio">;      // 0..1

// the single sanctioned `as`, localized to the constructor
function toEmail(raw: string): Email {
  if (!/^[^@\s]+@[^@\s]+$/.test(raw)) throw new Error(`bad email: ${raw}`);
  return raw as Email;
}
function transfer(from: UserId, to: UserId, amount: Cents): void {}
// transfer("a", "b", 100) - compile error: raw string/number rejected - must go through a validator
```

Brand by default for: entity ids, money/units (cents, millis, bytes), validated
strings (`Email`, `Url`, `Uuid`, `NonEmptyString`), bounded numbers (`Ratio`,
`PositiveInt`), and opaque tokens. If you'd write a comment explaining what a
`string`/`number` "really" is, brand it instead.

Money is always integer minor units, never a float. For ids or counters that can
exceed `Number.MAX_SAFE_INTEGER`, brand a `bigint`
(`type SnowflakeId = Brand<bigint, "SnowflakeId">`). Name constructors uniformly
- `toEmail` / `makeEmail` - and keep the cast nowhere else.

### 10. Name the discriminant `kind` by default

Every discriminated union (rule 5) needs a shared, `readonly`, string-literal
tag. Keep the *name* consistent across the codebase so unions read and refactor
the same way everywhere:

- **`kind`** - the default for variant / shape / message / node unions.
- **`status`** (or `state`) - when the field genuinely *is* a lifecycle state
  (`loading | success | error`); name it for what it means.
- **`type`** - only where an external convention requires it (Redux actions,
  DOM-style event objects); match the ecosystem rather than fight it.

```ts
type Shape =
  | { readonly kind: "circle"; readonly r: number }
  | { readonly kind: "rect"; readonly w: number; readonly h: number };

type Result =
  | { readonly status: "loading" }
  | { readonly status: "success"; readonly data: User }
  | { readonly status: "error"; readonly error: Error };
```

The tag is always lowercase, a string literal (never `boolean`/`number`), and
`readonly`. Pair with exhaustive `switch` + `assertNever` (rule 6).

### 11. Single source of truth - derive, don't duplicate

Never hand-maintain a type and a value (or two types) that must agree. Write one
canonical definition and derive the rest with `typeof` / `keyof typeof` /
`z.infer` / `Extract` / `Exclude` / the utility types.

```ts
const CONFIG = { retries: 3, region: "eu" } as const;
type Config = typeof CONFIG;                 // value is the source
type Region = (typeof CONFIG)["region"];     // "eu"

type Status = "idle" | "loading" | "success" | "error";
type Settled = Exclude<Status, "idle" | "loading">; // derive sub-unions, never re-list
```

`Extract<Union, { kind: "x" }>` picks a single variant out of a discriminated
union. `Extract`/`Exclude` fail *silently* on a typo'd member - when the filter
is a literal list, constrain it to the source first so a stale name is a compile
error:

```ts
type Subset<S extends U, U> = S;                    // errors if S is not a subset of U
type Pending = Subset<"idle" | "loading", Status>;  // typo -> compile error
```

### 12. Validate at the boundary, type the interior

Everything crossing a trust boundary (`JSON.parse`, `fetch`, `process.env`,
`postMessage`, file I/O) is `unknown`. Parse it once at the edge with a schema or
honest predicate; the interior then stays fully typed. Never `JSON.parse(x) as T`.

```ts
const User = z.object({ id: z.string(), age: z.number().int().nonnegative() });
type User = z.infer<typeof User>;            // single source of truth (rule 11)
const user = User.parse(await res.json());   // throws or returns a typed User
```

Schema-first, library-agnostic: Zod, Valibot, and ArkType all express the schema
once and derive the type from it - pick one per project and validate at every
edge.

### 13. Exhaustive `Record<Union, T>` for total lookups

When a lookup must cover every member of a union, type it
`Record<TheUnion, T>` (not `Partial`, not an index signature). Adding a union
member then fails to compile until the new entry exists - exhaustiveness for data.

```ts
const label = {
  loading: "Loading...",
  success: "Done",
  error: "Failed",
} as const satisfies Record<Result["status"], string>;
```

### 14. Narrow without casts - predicates & assertion functions

The two sanctioned roads from `unknown`/wide -> narrow without a cast. Pick by
what should happen when the value *isn't* the type. Either way the body must
*actually prove* what it claims - a predicate that returns `true` unchecked, or
an `asserts` that doesn't throw, is a disguised `as`.

**Type predicate - `x is T`.** Returns a `boolean`; you branch on it. Narrows
only inside the branch you take. Use when "not a `T`" is a normal path.

```ts
function isString(x: unknown): x is string {
  return typeof x === "string";
}
if (isString(v)) v.toUpperCase(); // v is string only inside the branch
```

**Assertion function - `asserts x is T`.** Returns `void` and *throws* on
failure; if it returns, the narrowing holds for the **rest of the scope** - no
branch. Use as a guard clause when "not a `T`" should abort.

```ts
function assertIsUser(x: unknown): asserts x is User {
  if (!isUser(x)) throw new TypeError("expected User");
}
assertIsUser(payload);
payload; // User from here to end of scope
```

|              | predicate `x is T`              | assertion `asserts x is T`   |
| ------------ | ------------------------------- | ---------------------------- |
| returns      | `boolean`                       | `void`                       |
| on failure   | returns `false` (you handle it) | **throws**                   |
| narrows      | inside the chosen branch        | rest of the scope, no branch |
| use when     | both outcomes are valid         | failure should abort         |

The bare `asserts cond` form (no `is`) narrows truthiness without naming a type -
how `node:assert` is typed: `function assert(c: unknown): asserts c`. An
`asserts` function **must** carry an explicit return-type annotation (TS won't
infer it).

### 15. Immutability by default

`readonly` fields; `ReadonlyArray<T>` / `ReadonlyMap` / `ReadonlySet` for params
and returns you don't mutate; `as const` tuples from factories/hooks
(`return [state, setState] as const`). Return new values instead of mutating
arguments. Widen to mutable only where mutation is the point.

### 16. Index access is `T | undefined` - guard it

Treat `arr[i]` and `obj[key]` as possibly-missing (what `noUncheckedIndexedAccess`
enforces). Guard, use `.at()`, or destructure with a check before use - never
assume presence.

```ts
const head = xs[0];               // T | undefined
if (head === undefined) return;
head;                             // T
```

### 17. Use `never` to make illegal states unrepresentable

`never` is the bottom type - it says "this can't happen" and the compiler proves
it. Use it to forbid a field in one arm of a union (so the *shape* rules out the
bad combination), and in `default` branches via `assertNever` (rule 6).

```ts
type Toggle =
  | { readonly on: true;  readonly reason?: never }
  | { readonly on: false; readonly reason: string };
```

### 18. `const` bindings; never widen by accident

Prefer `const`; `let s = "a"` widens to `string`, `const s = "a"` stays `"a"`.
When a literal must survive a `let` or a return, reach for `as const`. Don't
annotate a binding wider than its initializer.

### 19. Prefer `type`; compose with utility types

Default to `type` - it expresses unions, tuples, mapped and conditional types
that `interface` can't, and one consistent construct beats switching by case.
Build new shapes from a canonical one with `Pick`/`Omit`/`Partial`/`ReturnType`/
`Awaited` rather than restating fields.

`interface` earns its place where you specifically need declaration merging,
`extends`-based class/OO contracts, or the compiler-caching edge on very large
object hierarchies - reach for it deliberately there, not by default.

### 20. `Result<T, E>` for expected failures; throw only for bugs

Failures that are part of the domain (validation, not-found, a declined payment)
are *values*, not exceptions - return a tagged result so the caller must handle
them and the type system tracks both paths. Reserve `throw` for genuinely
exceptional / programmer-error cases.

```ts
type Result<T, E> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };

const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
```

Type the error channel as a **discriminated union of string-literal codes** (a
`kind`/`code` field per rule 10), not a bare `string` or `Error` - so callers can
exhaustively handle each failure and adding one is a compile error at every call
site.

```ts
type ParseError =
  | { readonly code: "empty" }
  | { readonly code: "too_long"; readonly max: number }
  | { readonly code: "bad_char"; readonly at: number };

function parseTag(raw: string): Result<Tag, ParseError> { /* ... */ }
```

### 21. Model "absent" vs "present-but-undefined" distinctly

With `exactOptionalPropertyTypes` on (see Enforcement), `x?: T` (the key may be
*missing*) and `x: T | undefined` (the key is *present*, value may be `undefined`)
are different contracts. Pick deliberately; don't pass `{ x: undefined }` to
satisfy an optional field. Use `?:` for "omittable", `| undefined` for "required
slot, emptiable".

### 22. One generic + conditional return over overloads

When a function's return type is a *function* of its input type, express that
with a single generic + conditional return - not a stack of overloads, which
drift from the implementation and don't compose. Overloads are a last resort,
reserved for genuinely unrelated argument shapes no single signature can express.

```ts
// good: one honest signature
function unwrap<T>(x: T): T extends Promise<infer U> ? U : T { /* ... */ }
```

### 23. Options object over positional booleans

`doThing(true, false)` is unreadable and easy to transpose. Past ~2 params, or
whenever a param is a bare boolean, take a single `readonly` options object -
ideally with literal-union fields instead of booleans.

```ts
// bad: render(item, true, false)
// good
render(item, { variant: "compact", interactive: true } as const);
```

### 24. Labeled tuples for positional data

When a tuple's positions have meaning, label them - the labels show in
hover/errors and document call sites without a separate comment. Pair with
`as const` / `readonly` so the arity and element types stay fixed.

```ts
type Point = readonly [x: number, y: number];           // not [number, number]
type RangeArgs = readonly [start: number, end: number, step?: number];
type Pair<K, V> = readonly [key: K, value: V];
```

### 25. Type-only imports

`import type { Foo }` for anything used only as a type - explicit intent, no
accidental runtime import, and required for clean output under
`verbatimModuleSyntax`.

### 26. `??` and `?.` over `||` and manual guards

`||` mis-fires on the falsy-but-valid values `0`, `""`, `false`. Use `??` for
defaulting and `?.` for optional access - they trigger only on `null`/`undefined`.

### 27. Total functions - handle every input, return for every path

A function's type should be honest about totality: cover every member of an input
union (rule 6), return on every path (no implicit `undefined` from a missing
branch), and prefer returning a value over throwing for in-domain inputs (rule 20).

---

## Enforcement: TypeScript compiler and lint flags

The rules above hold under *any* config, but on a project you own the compiler
should be the enforcer. `strict: true` is the baseline; these are the additional
flags that back specific rules and which `strict` does **not** enable on its own:

| Flag | Backs |
| --- | --- |
| `noUncheckedIndexedAccess` | rule 16 - `arr[i]`/`obj[k]` become `T \| undefined` |
| `exactOptionalPropertyTypes` | rule 21 - absent vs present-but-undefined |
| `noImplicitReturns` | rules 2 & 27 - every path returns |
| `noFallthroughCasesInSwitch` | rule 6 - exhaustive, no accidental fallthrough |
| `noPropertyAccessFromIndexSignature` | rule 5 - `obj["k"]` for index signatures |
| `noImplicitOverride` | safe subclassing - `override` keyword required |
| `verbatimModuleSyntax` | rule 25 - explicit type-only imports |

ESLint (typescript-eslint) to catch what types alone can't:
`@typescript-eslint/switch-exhaustiveness-check` (rule 6),
`no-explicit-any` + `no-non-null-assertion` + `no-unnecessary-condition`
(rule 8), `consistent-type-imports` (rule 25),
`no-floating-promises` / `await-thenable` (async correctness).

These belong on a project you own. On a repo you don't, the patterns carry the
strictness on their own - that is the whole point of writing them
config-independently.


---

# Error-handling standards (JS/TS)

How to handle failure so that **no error is ever opaque**. Two invariants run
through every rule:

1. **A caught error is never left as `unknown`.** Every value that reaches a
   `catch` is narrowed to a known type before anything reads it - no
   `(e as Error).message`, no blind `e.code`.
2. **A thrown error is never a bare `Error`.** Everything thrown is a custom
   error carrying the structured context needed to *catch it precisely* and
   *understand why it happened* - a discriminant, the inputs that triggered it,
   and the original `cause`.

The two ends meet: throws are typed and contextful so catches can narrow them
exhaustively. This standard is about *exceptional* failure (bugs, broken
invariants, infrastructure faults). Expected, in-domain failures should be
*values* - see [`Result<T, E>`](#typescript-standards) (TypeScript rule 20) - and most
of what follows applies to the `error` channel of a `Result` just the same.

---

## Core rules

### 1. Never throw a bare `Error` - throw a typed, contextful custom error

`throw new Error("failed")` produces a value a catcher can only re-inspect by
substring-matching the message. Define an error *class* per failure mode, give it
a stable discriminant, and attach the data that explains the failure.

```ts
// bad: untyped, contextless - the catcher learns nothing it can branch on
throw new Error(`payment failed for ${userId}`);

// good: a typed error carrying everything needed to catch and diagnose it
throw new PaymentDeclinedError({
  userId,
  amountCents,
  declineCode: "insufficient_funds",
  cause: gatewayError,
});
```

A throw is an API. Its type is part of that API; its message is for humans, not
for control flow.

### 2. Give every error a stable, literal discriminant

Branch on a `readonly code` (or `kind`/`name`) literal, never on the message or
on `instanceof` chains alone. A literal discriminant lets a catch narrow
exhaustively and makes adding a new failure mode a compile error at every
handler (per the discriminated-union model in [TypeScript](#typescript-standards) rules
6 and 20).

```ts
type AppErrorCode =
  | "validation"
  | "not_found"
  | "unauthorized"
  | "payment_declined"
  | "upstream_unavailable";

abstract class AppError extends Error {
  abstract readonly code: AppErrorCode;
}
```

Renaming a message must never break a handler; changing a `code` should.

### 3. Errors carry structured context as fields, not interpolated strings

The data that explains *why* belongs in typed fields the catcher can read - not
baked into the message where only a regex can recover it. Put the human summary
in `message` *and* the machine-readable facts in fields.

```ts
// bad: the only way to recover `field`/`max` is to parse the string
throw new Error(`field "title" exceeds 80 chars`);

// good: readable message + structured, queryable context
class ValidationError extends AppError {
  readonly code = "validation" as const;
  constructor(
    readonly field: string,
    readonly reason: "required" | "too_long" | "bad_format",
    readonly detail?: Readonly<Record<string, unknown>>,
  ) {
    super(`validation failed on "${field}": ${reason}`);
  }
}
```

### 4. Always preserve the original `cause`

When you wrap or translate an error, pass the original through the standard
`cause` option (ES2022). Dropping it discards the stack and the root reason -
the exact thing rule 1 exists to keep.

```ts
// bad: original error and its stack are gone
catch (e) {
  throw new UpstreamError("inventory lookup failed");
}

// good: the chain is preserved end to end
catch (e) {
  throw new UpstreamError("inventory lookup failed", {
    cause: toError(e),          // rule 6
    service: "inventory",
    sku,
  });
}
```

Custom error constructors must accept and forward `{ cause }` to
`super(message, { cause })`.

### 5. A caught value is `unknown` - narrow it before reading anything

`catch (e)` binds `e` as `unknown` (enable `useUnknownInCatchVariables`, on under
`strict`). JavaScript lets code `throw` *any* value - a string, `undefined`, a
plain object - so the type is honest: you don't yet know what you have. Narrow
before you touch it; never assert.

```ts
// bad: blind cast - explodes if something threw a string or null
catch (e) {
  log((e as Error).message);
}

// good: narrow with a guard, then handle each known shape
catch (e) {
  if (isAppError(e)) return handleAppError(e);   // rule 6
  throw e;                                        // rule 8 - not ours to swallow
}
```

This is the runtime companion to rule 1: typed throws are only useful if catches
refuse to treat them as `unknown`.

### 6. Narrow with type guards and a normalizer - never `as`

Provide a guard per error family and a single normalizer that turns *any* thrown
value into a known `Error`. Every `catch` goes through one of them.

```ts
function isAppError(e: unknown): e is AppError {
  return e instanceof AppError;
}

// any thrown value -> a real Error, with the original kept as `cause`
function toError(e: unknown): Error {
  if (e instanceof Error) return e;
  return new Error(`non-Error thrown: ${typeof e}`, { cause: e });
}
```

With these, no handler ever sees `unknown` past its first line, and no `catch`
needs an `as` (TypeScript rules on the two sanctioned roads from `unknown`).

### 7. Catch only what you can handle here; let everything else propagate

A `catch` that isn't narrowed catches *everything* - including the
`TypeError` from a bug three frames down - and turns a loud failure into silent
wrong behavior. Catch the specific code you can act on; re-throw the rest
unchanged so it surfaces where someone can.

```ts
// bad: swallows unrelated bugs as if they were the not-found case
try { return await load(id); }
catch { return null; }

// good: handle the one case you understand; propagate the rest with its context
try { return await load(id); }
catch (e) {
  if (isAppError(e) && e.code === "not_found") return null;
  throw e;
}
```

### 8. Never swallow - no empty `catch`, no log-and-continue

An empty `catch {}`, or one that only logs and falls through, converts a failure
into corrupt state that surfaces far away. Every catch must do exactly one:
**handle** (and recover), **translate** (rule 9), or **re-throw**. "Log and
continue" is none of those.

```ts
// bad: the operation failed but the program proceeds as if it succeeded
try { await persist(order); } catch (e) { console.error(e); }

// good: translate to a domain error and stop the broken path
try { await persist(order); }
catch (e) { throw new PersistError({ entity: "order", id: order.id, cause: toError(e) }); }
```

### 9. Translate errors at boundaries; don't leak foreign error types

Where an external dependency's errors enter your code (DB driver, HTTP client,
parser), convert them once into your own error union. Inside the domain, every
error is an `AppError`; raw `PrismaClientKnownRequestError` / `FetchError` /
`ZodError` never travel past the adapter that produced them.

```ts
async function getUser(id: UserId): Promise<User> {
  try {
    return await db.user.findUniqueOrThrow({ where: { id } });
  } catch (e) {
    if (isPrismaNotFound(e)) throw new NotFoundError({ entity: "user", id, cause: e });
    throw new UpstreamError("db.getUser failed", { cause: toError(e), id });
  }
}
```

This is what keeps rule 5's narrowing finite: callers handle *your* codes, not
the open-ended error zoo of every transitive dependency.

### 10. Prefer `Result<T, E>` for expected failures; reserve `throw` for the exceptional

If a failure is a normal outcome the caller must reckon with - validation,
not-found, a declined card - return it as a value so the type system forces
handling and there's no hidden control flow. Throw for broken invariants,
programmer error, and faults no local caller can sensibly handle. (Full model:
[TypeScript](#typescript-standards) rule 20.)

```ts
// expected outcome -> value the caller must destructure
function parseAmount(raw: string): Result<Cents, ValidationError> { /* ... */ }

// genuinely exceptional -> throw a typed error
function assertInvariant(cond: boolean, ctx: InvariantContext): asserts cond {
  if (!cond) throw new InvariantError(ctx);
}
```

Don't throw to signal an ordinary branch, and don't return a `Result` for a
state that means the program is already broken.

### 11. Make errors serializable and safe to log

An error is only as useful as what survives into the logs. `Error` fields don't
JSON-stringify by default and `cause` chains are easy to lose. Give the base
class a `toJSON` that emits the discriminant, the context fields, and a flattened
cause - and keep secrets out of it.

```ts
abstract class AppError extends Error {
  abstract readonly code: AppErrorCode;
  toJSON() {
    return {
      code: this.code,
      message: this.message,
      ...this.context,                       // typed, non-sensitive fields
      cause: this.cause instanceof Error
        ? { name: this.cause.name, message: this.cause.message }
        : this.cause,
    };
  }
}
```

Never put tokens, passwords, or full PII in error context; log an identifier, not
the secret.

### 12. Set up custom error classes correctly (prototype, name, stack)

Subclassing the built-in `Error` has two well-known traps: under transpilation to
ES5 the prototype chain breaks (so `instanceof` - rule 6 - silently fails), and
`name` defaults to `"Error"`. Fix both in the base class once.

```ts
abstract class AppError extends Error {
  abstract readonly code: AppErrorCode;
  constructor(message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = new.target.name;                 // real subclass name in stacks
    Object.setPrototypeOf(this, new.target.prototype); // instanceof works post-transpile
  }
}
```

### 13. No floating rejections - type and await every async failure path

An async function that can fail is the async form of a throw; the same typing and
the same "don't swallow" rules apply. An un-awaited promise rejects into the void
and is lost. `await` (or explicitly handle) every promise, and enable
`@typescript-eslint/no-floating-promises`.

```ts
// bad: rejection escapes the try; the catch never sees it
try { void persist(order); } catch (e) { /* never runs */ }

// good: awaited - failure is caught and translated (rule 9)
try { await persist(order); }
catch (e) { throw new PersistError({ id: order.id, cause: toError(e) }); }
```

### 14. One handler at the top translates errors to responses/exit codes

Domain code throws typed errors; it does not format HTTP responses or call
`process.exit`. A single boundary handler (Express error middleware, a route
wrapper, the CLI's top frame) switches on `code` to map each error to a status /
exit code / user message - exhaustively, so a new code forces a new branch.

```ts
function toHttp(e: AppError): { status: number; body: ErrorBody } {
  switch (e.code) {
    case "validation":         return { status: 400, body: render(e) };
    case "unauthorized":       return { status: 401, body: render(e) };
    case "not_found":          return { status: 404, body: render(e) };
    case "payment_declined":   return { status: 402, body: render(e) };
    case "upstream_unavailable": return { status: 502, body: render(e) };
    default: { const _exhaustive: never = e.code; throw e; }   // new code -> compile error
  }
}
```

This keeps formatting in one place and guarantees (rule 2) that every failure
mode has a deliberate outward representation.

---

## Enforcement: compiler and lint flags

The rules hold under any config, but on a project you own these mechanically
back them:

| Flag | Backs |
| --- | --- |
| `strict` / `useUnknownInCatchVariables` | Rule 5 - `catch` binds `unknown`, forcing narrowing. |
| `@typescript-eslint/no-floating-promises` | Rule 13 - every promise awaited/handled. |
| `@typescript-eslint/no-explicit-any` + `no-unsafe-*` | Rules 5-6 - no `as`/`any` escape hatch around caught values. |
| `@typescript-eslint/only-throw-error` | Rule 1 - only `Error` subclasses may be thrown. |
| `@typescript-eslint/use-unknown-in-catch-callback-variable` | Rule 5 - same narrowing for `.catch(cb)`. |
| `@typescript-eslint/switch-exhaustiveness-check` | Rules 2 & 14 - every error `code` handled. |


---

# Testing standards (JS/TS - Vitest & Jest)

How to write tests that catch real regressions instead of handing out **fake
safety** - green checks that prove nothing. A bad test is worse than no test: it
costs maintenance *and* lies about coverage. Every rule below keeps a test honest,
which means two things - it **pins the actual behavior** precisely, and it **can
actually fail** when that behavior breaks.

Examples use Vitest's `vi`; Jest is identical with `jest` in place of `vi` (the
matchers are the same). Scope: unit and integration tests of logic. DOM /
component testing (Testing Library) follows the same philosophy but has its own
query and interaction rules - a separate standard.

---

## Core rules

### 1. Assert exact values, never truthiness

`toBeTruthy`/`toBeFalsy` pass for a huge range of values - `1`, `"0"`, `[]`, `{}`
are all truthy - so they hide bugs where the function returns *something*, just
not the right thing. Assert the exact value.

```ts
// bad: passes for "ok", [], {}, any non-zero number... proves almost nothing
expect(isValid(input)).toBeTruthy();

// good: pins the real result
expect(isValid(input)).toBe(true);
expect(user.role).toBe("admin");
```

Same for `toBeDefined()` (passes for `null`, `0`, `false`) - assert what it *is*.

### 2. `toStrictEqual` over `toEqual`

`toEqual` is lenient in ways that hide bugs: it treats `{ a: undefined }` as
equal to `{}`, ignores sparse-array holes, and does **not** check that two values
share a type/class. `toStrictEqual` checks all three. Default to it for deep
equality.

```ts
// bad: toEqual: these all pass and shouldn't
expect({ a: 1, b: undefined }).toEqual({ a: 1 });        // missing key ignored
expect([1, , 3]).toEqual([1, undefined, 3]);             // hole vs undefined
expect(new Point(1, 2)).toEqual({ x: 1, y: 2 });         // class vs plain object

// good: toStrictEqual catches every one of those
expect(result).toStrictEqual({ a: 1, b: undefined });
```

Use `toBe` for primitives and reference identity (it's `Object.is`); reserve
`toStrictEqual` for structural deep equality. Beware the looser cousins -
`toMatchObject` and `expect.objectContaining` match *partially* and ignore
unasserted keys: fine when "contains at least" is genuinely the intent, a
fake-safety trap when you meant "equals". Reach for them deliberately, never as
the default.

### 3. Assert exact call arguments, not just "it was called"

`toHaveBeenCalled()` proves a function ran, not that it ran *correctly*. Assert
the arguments and the call count.

```ts
// bad
expect(save).toHaveBeenCalled();

// good: exact args + how many times
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ id: "u1", status: "active" });
```

Reach for `expect.objectContaining`/`expect.any` only when a field is genuinely
nondeterministic (a timestamp, a generated id) - and then assert its *shape*, not
skip it.

### 4. Assert the specific error - never a bare `toThrow()`

`toThrow()` with no argument passes for *any* thrown value, including the
`TypeError` from a typo in the test itself. Pin the error type and a stable part
of the message or shape.

```ts
// bad: passes if it throws for the wrong reason
expect(() => parse(bad)).toThrow();

// good: the error you actually expect
expect(() => parse(bad)).toThrow(ValidationError);
expect(() => parse(bad)).toThrow(/missing field: email/);
await expect(fetchUser(id)).rejects.toThrow(NotFoundError);
```

### 5. Every test must be able to fail

A test with no reachable assertion is green theater. For async/callback code,
guard that the assertions actually ran with `expect.assertions(n)` -
otherwise a promise that rejects early can skip every `expect` and still pass.

```ts
// good: proves the catch block was actually reached
it("rejects an expired token", async () => {
  expect.assertions(1);
  try {
    await authorize(expiredToken);
  } catch (e) {
    expect(e).toBeInstanceOf(TokenExpiredError);
  }
});
```

(Better still, prefer `await expect(authorize(t)).rejects.toThrow(...)` - rule 4
- which can't silently skip.)

### 6. Await every async assertion - no floating promises

`expect(p).resolves`/`rejects` returns a promise; un-awaited, the assertion
resolves *after* the test passes and its failure is lost. Always `await` (or
`return`) it.

```ts
// bad: assertion floats; test is green even when it should fail
expect(loadConfig()).resolves.toStrictEqual(defaults);

// good
await expect(loadConfig()).resolves.toStrictEqual(defaults);
```

Enable `@typescript-eslint/no-floating-promises` so the compiler catches these.

### 7. No control flow around assertions

`if`/`for`/`try` in a test body is how assertions get silently skipped - a loop
over an empty array asserts nothing; a misplaced branch never runs its `expect`.
Parametrize with `it.each` and keep one straight-line path.

```ts
// bad: if `cases` is empty, this test passes having asserted nothing
for (const c of cases) {
  if (c.valid) expect(validate(c.input)).toBe(true);
}

// good: each case is its own visible, countable test
it.each([
  { input: "a@b.co", expected: true },
  { input: "nope",   expected: false },
])("validate($input) -> $expected", ({ input, expected }) => {
  expect(validate(input)).toBe(expected);
});
```

Keep table rows literal - generated or computed cases reintroduce the tautology
of rule 8.

### 8. Hardcode expected values - don't recompute them

If the test computes its expectation with the same logic it's testing, it's a
tautology that passes even when both are wrong. Write the literal expected value.

```ts
// bad: tautology - reuses the function under test (or its formula)
expect(slug(title)).toBe(title.toLowerCase().replace(/\s+/g, "-"));

// good: a human-verified literal
expect(slug("Hello World")).toBe("hello-world");
```

### 9. Test observable behavior, not implementation

Assert what a caller can observe - return value, thrown error, a message put on a
queue, a row written. Don't assert that a private helper was called or that
internal state took a value; those pass while the behavior is broken and break
while the behavior is fine. The more a test mocks, the less it tests.

```ts
// bad: couples the test to how it's done; survives a total rewrite of behavior
expect(service["recalculateInternals"]).toHaveBeenCalled();

// good: asserts the outcome a user cares about
expect(await service.totalFor(cart)).toStrictEqual({ cents: 1299 });
```

### 10. Don't test your mocks

Asserting that a stub returns the value you told it to return tests nothing but
the mocking library. Mock only at real boundaries you don't own (network, clock,
filesystem); let the code under test run for real. If a test would still pass
with the implementation gutted, it's testing the mock.

### 11. Make tests deterministic

Flaky tests train people to re-run until green - the ultimate fake safety. Remove
every source of nondeterminism: freeze the clock, fake timers, seed randomness,
and never touch the real network or wall clock.

```ts
beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
});
afterEach(() => vi.useRealTimers());
```

Inject randomness/time as dependencies, or stub `Math.random`/`Date.now`. No
`setTimeout`-based sleeps to "wait" for things - advance fake timers or await a
real signal.

### 12. Isolate tests - reset state between them

Order-dependent tests give safety that evaporates when the file is re-sharded.
No shared mutable module state; restore mocks and clear singletons between tests.

```ts
// vitest.config: { test: { restoreMocks: true, clearMocks: true } }
afterEach(() => vi.restoreAllMocks());
```

Each test must pass when run alone and in any order (`--sequence.shuffle`).

### 13. Snapshots are a last resort

A large snapshot is reviewed once, then updated with `-u` forever - it asserts
"nothing changed", not "this is correct," and bloats diffs. Prefer explicit
assertions. When a snapshot genuinely earns its place (small, stable serialized
output), keep it *inline* and small so the expectation is reviewable in the test.

```ts
// good: small, local, reviewable
expect(formatTree(node)).toMatchInlineSnapshot(`"a > b > c"`);
```

Never snapshot a whole component tree / large object as a substitute for
asserting the few things that matter.

### 14. Coverage is a tool, not a goal

100% line coverage with weak assertions is the purest fake safety - every line
ran, nothing was checked. Coverage shows what was *executed*, never what was
*verified*. Aim assertions at behavior and edge cases; for a real measure of test
strength, mutation testing (does a test fail when the code is broken?) beats a
coverage number.

### 15. Name tests by behavior and condition

`it("works")` / `test("user")` tell you nothing when they fail. Name the expected
behavior and the condition that triggers it.

```ts
// bad: it("user")
// good
it("returns 401 when the token is expired", () => { /* ... */ });
```

### 16. One behavior per test; arrange-act-assert

A test that acts several times and asserts throughout hides which step failed.
One logical act, a focused set of assertions, clear AAA sections. If a test needs
"and" in its name, it's probably two tests.

### 17. Prefer real implementations and fakes over mocks

Mocks assert *interactions*; fakes (an in-memory repository, a test double that
actually behaves) assert *behavior*. Reach for a fake before a mock, and for the
real thing before a fake when it's cheap and deterministic - each step up
couples the test less to implementation detail (rule 9).

### 18. Test the unhappy path and the edges

Happy-path-only suites give confidence exactly where bugs aren't. Cover empty,
`null`/`undefined`, boundary values, duplicates, and the documented error cases -
that's where regressions actually live.

### 19. Don't test the framework or the language

No tests for "does `Array.map` work", a library's own behavior, or trivial
pass-through getters. Test *your* logic; trust dependencies, or test them once at
their boundary.


---

# Supply-chain security standards (dependencies - JS/TS, Rust, Go)

A standard for the code you pull in: every direct and transitive dependency, the
toolchain that installs it, and the bots that update it. The threat model is
concrete - a malicious version pushed to a package you already trust, a
typosquat or a name someone registered before you, a compromised maintainer, or
an advisory that sits unpatched in your lockfile for months.

The posture mirrors the rest of these standards: make the attack **impossible by
construction**, not merely monitored. Pin everything, refuse anything too new,
install exactly what was reviewed, and fail the build the moment a known
advisory appears. Most malicious releases are found and pulled within days, so
putting *time* between a publish and your install defeats a large class of
attacks on its own.

The rest of the supply chain has its own standard:

- [GitHub Actions](#github-actions-standards) - the CI that builds and publishes.
- [Releasing](#releasing-standards) - how artifacts leave the repo, with provenance.
- [Repository](#repository-standards) - settings, rulesets, Dependabot, security policy.
- [Maintainer accounts](#maintainer-account-standards) - the humans and tokens behind it all.

---

## Core rules

### 1. Minimize the dependency surface

Every dependency - and every one of *its* dependencies - is code you ship and
trust. Prefer the platform and standard library, prefer one well-maintained
library over several micro-packages, and delete what you no longer use. A
library should aim for **zero runtime dependencies**; what it needs at build or
test time stays in `devDependencies`.

When a feature needs a heavy package only some users want (a CLI that needs
`typescript`, say), make it an **optional peer dependency** loaded lazily by
that feature, so the core stays dependency-free.

```jsonc
// good: package.json - the CLI loads typescript on demand; the library never does
{
  "peerDependencies": { "typescript": ">=5" },
  "peerDependenciesMeta": { "typescript": { "optional": true } }
}
```

### 2. Put a cooldown between a publish and your install

Never take a version the moment it is published. Require it to have been public
for **7 days**, in two layers:

1. **Update bots** wait: Dependabot's `cooldown` holds the PR until the version
   has aged (see [Repository](#5-dependabot-on-every-ecosystem-weekly-grouped-with-a-cooldown)).
2. **The package manager** refuses young versions at install time, so a version
   added or bumped by hand gets the same gate.

```toml
# good: bunfig.toml (Bun >= 1.3) - seconds; 604800 = 7 days
[install]
minimumReleaseAge = 604800
```

```yaml
# good: pnpm-workspace.yaml (pnpm >= 10.16) - minutes; 10080 = 7 days
minimumReleaseAge: 10080
```

Installs from a committed lockfile are unaffected; the gate applies when a
version is resolved. Floating tags (`@latest`, `@next`) silently resolve to the
newest version old enough, rather than failing.

**Security fixes younger than the window.** When an advisory's fix is newer than
7 days, exclude that one package temporarily and remove the exclusion once the
fix has aged. Never lower the window for everything.

```toml
[install]
minimumReleaseAge = 604800
minimumReleaseAgeExcludes = ["undici"]   # good: temporary: GHSA-xxxx, fix published 2 days ago
```

### 3. Vet a dependency before it enters the tree

Adding a package is a security decision. Before it lands, check:

- **Who publishes it**: the registry's publisher field and provenance
  (`npm view <pkg>@<version> _npmUser dist.attestations`). A release published
  by CI with provenance is a stronger signal than a hand publish.
- **How old it is**, how often it releases, and how many maintainers it has.
- **Whether it runs install scripts**, and how many transitive packages it adds.
- **Known advisories** (`bun audit`, `cargo audit`, `govulncheck`) before and
  after adding it.

### 4. Pin exact versions

Ranges (`^`, `~`) let a freshly published patch in without a code change. Pin
exact versions of everything you install for yourself - `devDependencies`, and
every dependency of an application - and enforce it, so a stray `^` fails CI
rather than quietly widening the tree.

A **library's** `dependencies` and `peerDependencies` are the exception: they
describe what *consumers* may use, and ranges let consumers deduplicate. Your
own lockfile still pins exactly what your CI tests.

```toml
# good: bunfig.toml - `bun add` writes exact versions
[install]
exact = true
```

```jsonc
// good: .syncpackrc.json - devDependencies exact; a library's runtime and peer ranges left alone
{
  "semverGroups": [
    { "label": "Dev tooling is pinned exactly", "dependencyTypes": ["dev"], "range": "" },
    { "label": "Runtime and peer ranges are for consumers", "dependencyTypes": ["prod", "peer"], "isIgnored": true }
  ]
}
```

An application (or a library with no runtime dependencies) can pin
everything: drop the second group and add `"prod"` to the first. Run
`syncpack lint` in CI. Rust and Go get the same guarantee from their lockfiles
(`Cargo.lock`, `go.sum`) plus a locked build (rule 5).

### 5. Commit the lockfile; install frozen everywhere

The lockfile pins resolved versions *and* their integrity hashes. Commit it,
review changes to it like code, and make every CI and release install refuse to
change it.

```bash
# good: one per toolchain
bun install --frozen-lockfile
npm ci
pnpm install --frozen-lockfile
cargo build --locked              # and cargo package/publish --locked
go build -mod=readonly ./...
```

A lockfile diff that changes integrity hashes without changing versions is a
red flag, not a rebase artifact.

### 6. Default-deny install scripts

`preinstall`/`postinstall` scripts run arbitrary code on every machine that
installs - a top malware delivery vector.

- **Bun** runs install scripts for a built-in allow-list of popular packages
  unless `package.json` says otherwise. A *missing* `trustedDependencies` field
  means that list applies; set it explicitly, empty or with a justified list.
  `ignoreScripts = true` under `[install]` in `bunfig.toml` turns off every
  script instead.
- **npm**: `ignore-scripts=true` in `.npmrc`.
- **pnpm** default-denies; allow only what needs a native build, with
  `onlyBuiltDependencies` (pnpm 10) or `allowBuilds` (pnpm 11).

```jsonc
// good: package.json (Bun) - no dependency runs an install script
{ "trustedDependencies": [] }
```

```ini
# good: .npmrc
ignore-scripts=true
```

Any job that holds a publishing credential installs with scripts disabled, or
installs nothing at all (see [GitHub Actions](#7-split-build-from-publish)).

### 7. Own your names - kill dependency confusion and squatting

An internal or announced-but-unpublished name can be taken by someone else.

- **Scope** internal packages (`@org/...`) and pin the scope to its registry.
- **Register a name before you announce it** - including the name your README
  tells people to install. A `<project>-cli` package mentioned in docs but never
  published is an open invitation.
- Prefer shipping a CLI as a `bin` inside the main package (`npx <project>`) over a
  second package name you then have to defend.

### 8. Audit in CI, as its own job

Fail the build on known advisories of **high or critical** severity, on every
pull request and on `main`. Run the audit in its **own job** with no `needs:`,
so a new advisory (or a registry outage) is reported on its own instead of
skipping every other check.

```yaml
# good: an independent job
audit:
  name: Audit dependencies
  runs-on: ubuntu-latest
  timeout-minutes: 10
  steps:
    - uses: actions/checkout@<sha> # vX.Y.Z
      with:
        persist-credentials: false
    - uses: oven-sh/setup-bun@<sha> # vX.Y.Z
      with:
        bun-version: 1.3.14
    - run: bun install --frozen-lockfile
    - run: bun audit --audit-level=high
```

Rust: `rustsec/audit-check` (or `cargo audit`/`cargo deny check advisories`).
Go: `govulncheck ./...`. An ignored advisory names its ID, the reason, and the
date the ignore should be revisited.

### 9. Fix advisories in the smallest correct way

In order of preference:

1. **Lockfile-only, within range.** If the parent's declared range already
   accepts the fixed version, update just that lock entry (with the registry's
   integrity hash), prove it with a frozen install, and run the full quality job.
   Every install is frozen, so the lockfile *is* what CI and releases use.
2. **Bump the parent** when its range excludes the fix.
3. **Override** (`overrides`/`resolutions`) only when no parent release accepts
   the fix yet; comment it with the advisory ID and remove it when the parent
   catches up.

```bash
# bad: adds the transitive package to `dependencies` - wrong for a zero-dep library
bun update undici
```

Majors that must move together move in **one** change. A coverage plugin that
peers on an exact test-runner version breaks if either lands alone.

### 10. Check what actually runs, not what the lockfile says

Some packages ship their dependencies inside their own tarball, ignoring your
lockfile entirely:

- **npm** bundles its dependencies. Its lock entries can look patched while the
  installed npm still runs the old code; only upgrading npm itself fixes them.
  Check `node_modules/npm/node_modules/<dep>/package.json`.
- **A JavaScript GitHub Action** bundles its runtime into `dist/`. An advisory in
  a runtime dependency ships to every workflow that uses the action until
  `dist/` is rebuilt and released.

When an audit says "fixed", confirm the version in the installed tree or the
built artifact.

### 11. Keep the update backlog empty

Advisories accumulate in unmerged update PRs. Merge or close Dependabot's PRs
every week; a backlog older than a month is a finding in its own right.
Grouped minor/patch updates should be routine; majors get read (changelog, peer
ranges, engine requirements) and merged on their own.

### 12. Verify signatures and provenance on install

Beyond the lockfile's hashes, verify registry signatures and provenance for the
installed tree where the registry supports it.

```bash
npm audit signatures   # good: registry signatures + provenance attestations
```

A failed verification fails the build.

---

## Enforcement

| Control | Check | Automated |
| --- | --- | --- |
| Cooldown (bot) | `cooldown.default-days` of at least 7 on every Dependabot ecosystem | yes |
| Cooldown (install) | `minimumReleaseAge` of at least 7 days in `bunfig.toml` | yes |
| Install scripts | `trustedDependencies` set explicitly, or `ignoreScripts` | yes |
| Frozen installs | every install in a workflow is frozen | yes |
| Advisories | some workflow audits; no open high/critical Dependabot alert | yes |
| Audit job | the audit runs on pull requests in its own job | by hand |
| Backlog | no Dependabot PR older than 30 days | yes |
| Exact pins | `syncpack lint` (or equivalent) in CI | by hand |

The automated rows are checked across every repository weekly by the
[drift check](https://github.com/danfry1/standards#the-baseline-and-the-drift-check).


---

# GitHub Actions standards

A standard for the workflows that test, build and publish code. CI is the most
privileged code in a repository: it holds tokens that can push, publish and
sign, and it runs on every pull request. The threat model is a hijacked or
moved action tag, a commit that only exists in a fork, a poisoned cache, a pull
request title that becomes a shell command, and a token that outlives the one
step that needed it.

The posture: **every workflow is least-privilege, pinned and scanned**, and the
job that can publish is the smallest, dullest job in the repository.

---

## Core rules

### 1. Pin every action to a full commit SHA - and check the SHA is real

Tags and branches move. Pin every `uses:` to a 40-character commit SHA with the
version in a comment, and let Dependabot bump it.

```yaml
# good: immutable, and Dependabot can still read the version
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# bad: mutable - whoever moves the tag runs code in your CI
- uses: actions/checkout@v7
```

A pinned SHA must be reachable from the action's own repository. A commit that
exists only in a fork is still served under the parent's name (an **impostor
commit**), so verify new pins:

```bash
gh api repos/actions/checkout/commits/v7.0.1 --jq .sha   # must equal the pin
```

Pin a **tagged release**, not "latest" or a branch tip. The exception is an
action whose maintainers regenerate a branch and strand old commits
(`dtolnay/rust-toolchain`): pin a commit on its main branch and select the
toolchain with the `toolchain:` input.

Reusable workflows are actions too: `uses: owner/repo/.github/workflows/x.yml@<sha>`.

### 2. Default the token to read-only; grant per job

Set the workflow-wide default to `contents: read` (or `{}`), and grant extra
scopes only on the job that needs them.

```yaml
# good
permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      id-token: write   # this job, and only this job, can mint OIDC tokens
```

```yaml
# bad: every job, including the one running PR code, can write
permissions: write-all
# bad: grants read on every scope, including ones no job needs
permissions: read-all
```

The repository's default `GITHUB_TOKEN` permission is also read-only
([Repository](#2-settings-every-public-repository-gets)), so a
workflow without a `permissions:` block still cannot write.

### 3. Don't persist credentials in the checkout

`actions/checkout` stores the token in `.git/config` by default, where every
later step - and any artifact that uploads the workspace - can read it.

```yaml
# good
- uses: actions/checkout@<sha> # vX.Y.Z
  with:
    persist-credentials: false
```

The only exception is a job that must `git push` with that token afterwards;
justify it in a comment. `gh` reads `GH_TOKEN` from the environment, so
`gh release create` and friends work without persisted credentials.

### 4. Never interpolate untrusted input into a script

`${{ }}` is expanded *before* the shell runs, so a branch name, PR title or
workflow input containing `"; curl ... | sh` becomes part of your script. Pass
values through `env:` and quote them.

```yaml
# bad: template injection
- run: echo "Releasing ${{ github.ref_name }}"
# good: the shell sees a variable, not code
- env:
    TAG: ${{ github.ref_name }}
  run: echo "Releasing $TAG"
```

### 5. Keep untrusted code away from privileged triggers

`pull_request_target` and `workflow_run` run with the base repository's secrets
and a write-capable token.

- Build pull requests with plain `pull_request`.
- Never check out or run PR-authored code under `pull_request_target`.
- A `workflow_run` job that deploys must skip runs triggered by pull requests
  and must not consume artifacts produced by them.

```yaml
# good: a Pages deploy that only follows trusted runs
on:
  workflow_run:
    workflows: [Docs]
    types: [completed]
jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 10
```

### 6. No caches in release jobs

A cache written by a PR or another branch can be restored into a release build
(cache poisoning). Release and publish jobs restore nothing.

```yaml
# good: release jobs
- uses: actions/setup-node@<sha>
  with:
    node-version: 24
    package-manager-cache: false
- uses: actions/setup-go@<sha>
  with:
    cache: false
- uses: oven-sh/setup-bun@<sha>
  with:
    no-cache: true
```

Check a new input exists *at the pinned SHA*: an unknown input is only a
warning, so a typo leaves the cache on silently.

### 7. Split build from publish

The job that holds a publishing credential (`id-token: write`, an App token)
does as little as possible: it downloads or receives the artifact built and
tested by an earlier job, installs nothing new, runs no dependency scripts,
and publishes. Tests, builds and packing happen in jobs with read-only
permissions.

### 8. Gate releases on the tag

A release workflow triggers on a version tag and checks, before anything else:

- the tag equals the version in the manifest (`package.json`, `Cargo.toml`);
- the tagged commit is on `main`;
- the version is not already published (skip rather than fail, so a re-run is
  safe).

```yaml
# good
- name: Tag matches Cargo.toml
  env:
    TAG: ${{ github.ref_name }}
  run: |
    version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
    [ "$TAG" = "v$version" ] || { echo "tag $TAG != v$version"; exit 1; }
```

### 9. PR CI runs everything the release runs

A gate that only runs at release time fails on release day. Every check the
release performs - coverage thresholds, `publint`, `attw`, `npm pack`, the
tag-to-version check logic, packaging dry-runs - also runs on pull requests.
If the release runs `prepublishOnly`, PR CI runs the same script.

### 10. Pin the toolchain

`bun-version: latest`, `node-version: lts/*` and `go-version: stable` are
unpinned dependencies. Pin exact toolchain versions (or read them from a file
the repo controls, such as `packageManager` or `go.mod`), and let Dependabot or
a deliberate PR move them.

### 11. Scan every workflow with zizmor

Run [zizmor](https://docs.zizmor.sh) at a pinned version against every
workflow, fix what it finds, and justify what you keep inline so the next scan
is quiet. The ignore needs its own `#` after the version comment; zizmor does
not see `# vX.Y.Z zizmor: ignore[...]`.

```bash
uvx --from 'zizmor==1.31.1' zizmor .github/workflows
```

Use a current release: before 1.31, zizmor's default policy accepted tag pins
for `actions/*`, so `actions/checkout@v7` passed. Dependabot cannot bump a
version written inside a script, so move the pin deliberately.

```yaml
# good: a justified exception, on the flagged line, visible in review
# This job pushes the bump branch with the App token, so it keeps credentials.
- uses: actions/checkout@<sha> # vX.Y.Z # zizmor: ignore[artipacked]
  with:
    repository: owner/homebrew-tap
    token: ${{ steps.app-token.outputs.token }}
```

### 12. Cross-repo writes use a GitHub App token, never a PAT

`GITHUB_TOKEN` covers the repository the workflow runs in. For anything else -
pushing a formula to a tap, a manifest to a bucket - mint a short-lived token
from a GitHub App scoped to exactly the target repositories.

```yaml
# good: expires in an hour, scoped to one repository and the permissions it needs
- id: app-token
  uses: actions/create-github-app-token@<sha> # vX.Y.Z
  with:
    client-id: ${{ vars.TAP_BUMP_APP_CLIENT_ID }}
    private-key: ${{ secrets.TAP_BUMP_APP_PRIVATE_KEY }}
    repositories: homebrew-tap
    permission-contents: write
    permission-pull-requests: write
```

A personal access token is long-lived, tied to a person and usually
over-scoped. See [Maintainer accounts](#5-no-personal-access-tokens-one-key-per-github-app).

### 13. Bound every job

Give every job a `timeout-minutes`. A hung step otherwise holds a runner, and
its token, for six hours.

### 14. Keep scheduled workflows alive

GitHub disables scheduled workflows after 60 days without repository activity,
silently. A weekly canary that stopped running looks exactly like one that
keeps passing. Check for disabled workflows:

```bash
gh api repos/OWNER/REPO/actions/workflows \
  --jq '.workflows[] | select(.state != "active") | "\(.name): \(.state)"'
```

### 15. Required checks are pinned to the app that reports them

A ruleset's required check names a job *and* the app that must report it
(`integration_id: 15368` for GitHub Actions; code scanning reports `CodeQL`
from its own app), so another app cannot satisfy it with a same-named check.
Rename a job only together with the ruleset, and don't require matrix jobs
whose names change with every dependency bump.

---

## Enforcement

| Control | Check | Automated |
| --- | --- | --- |
| SHA pins, impostor commits | zizmor `unpinned-uses`, `impostor-commit` | yes (`--zizmor`) |
| Token scope | zizmor `excessive-permissions`; repository default token read-only | yes |
| Persisted credentials | zizmor `artipacked` | yes (`--zizmor`) |
| Template injection | zizmor `template-injection` | yes (`--zizmor`) |
| Dangerous triggers | zizmor `dangerous-triggers` | yes (`--zizmor`) |
| Release caches | zizmor `cache-poisoning` | yes (`--zizmor`) |
| Disabled workflows | workflow state via the API | yes |
| Required checks | every required check has an `integration_id` | yes |
| Isolated publish, tag gate, toolchain pins, timeouts | review of the release workflow | by hand |


---

# Repository standards

A standard for how a GitHub repository is configured: who can change `main`,
which tags are permanent, how vulnerabilities reach the maintainer, and how
dependencies are kept current. These are settings, not code, so they drift
silently. Every rule here is checked by the
[drift check](https://github.com/danfry1/standards#the-baseline-and-the-drift-check),
and the machine-readable version lives in
[`baseline/baseline.json`](https://github.com/danfry1/standards/blob/main/baseline/baseline.json).

The posture: **the defaults protect the repository**, and every exception is
written down with its reason.

---

## Core rules

### 1. Every repository has a tier

The tier decides which rules apply. Assign it when the repository is created
and record it in
[`baseline/repos.json`](https://github.com/danfry1/standards/blob/main/baseline/repos.json).

| Tier | What it is | Examples |
| --- | --- | --- |
| `package` | A published library or GitHub Action | npm packages, crates, JS actions |
| `tool` | A published CLI or TUI with binary releases | Homebrew/Scoop/crates.io tools |
| `distribution` | A repository bots publish into | a Homebrew tap, a Scoop bucket |
| `meta` | Shared config and standards | `standards`, `.github`, the profile repo |
| `project` | Public but not published | themes, demos, benchmarks, data |

Private repositories are out of scope unless they publish something; a private
repository that publishes is a `package` and should become public so its
releases can carry provenance.

### 2. Settings every public repository gets

| Setting | Value |
| --- | --- |
| Private vulnerability reporting | on |
| Dependabot alerts | on |
| Dependabot security updates | on |
| Secret scanning | on |
| Secret scanning push protection | on |
| Default `GITHUB_TOKEN` permission | read |
| Actions can create and approve pull requests | off |

Turning the token default to read-only is safe only after checking every
workflow either declares `permissions:` or needs nothing more than read. Check
first, then flip it.

### 3. Rulesets on `main` and on release tags

Use repository **rulesets** (not classic branch protection), with no bypass
actors.

**`main`, every tier:** block deletion and force-push.

**`main`, `package` and `tool`:** additionally require a pull request (zero
approvals - a solo maintainer cannot approve their own PR) and the CI checks
that gate a release, each pinned to the app that reports it (GitHub Actions,
`15368`, for workflow jobs).

```jsonc
// good: rulesets: main (package / tool)
{
  "name": "main",
  "target": "branch",
  "enforcement": "active",
  "bypass_actors": [],
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 0,
        "dismiss_stale_reviews_on_push": false,
        "require_code_owner_review": false,
        "require_last_push_approval": false,
        "required_review_thread_resolution": false
      }
    },
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "do_not_enforce_on_create": false,
        "required_status_checks": [{ "context": "Quality", "integration_id": 15368 }]
      }
    }
  ]
}
```

**Release tags (`v*`), `package` and `tool`:** block deletion, update and
force-push. Creation stays allowed, so releasing works; once a version tag
exists it can never point anywhere else.

```jsonc
// good: rulesets: release tags
{
  "name": "release tags",
  "target": "tag",
  "enforcement": "active",
  "bypass_actors": [],
  "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } },
  "rules": [{ "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "update" }]
}
```

A GitHub Action's moving major tags (`v0`, `v1`, ...) are pointers by design
and must stay movable: exclude each one from the ruleset
(`"exclude": ["refs/tags/v0", "refs/tags/v1"]`) and move them only as part of a
release.

**Migrating from classic protection:** back up the classic configuration
first, then diff it against the ruleset. A straight migration silently drops
settings such as "require branches to be up to date", linear history and
conversation resolution.

### 4. Write every exception down

Some repositories legitimately break a rule for a while: a release that moves
its tag cannot have that tag locked until the release is reworked, and a
package that has never been published may stay off PR-only until its first
release. Each exception lives in
`baseline/repos.json` with the rule it waives and why. An exception without a
reason is a finding.

### 5. Dependabot on every ecosystem, weekly, grouped, with a cooldown

Every ecosystem the repository uses - including `github-actions` - has an
entry. Updates are weekly, wait 7 days after a release, and arrive grouped so
the weekly review is a handful of PRs.

```yaml
# good: .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: bun            # or npm, cargo, gomod, pip
    directory: /
    schedule:
      interval: weekly
      day: monday
    open-pull-requests-limit: 10
    cooldown:
      default-days: 7
    groups:
      dev-dependencies:
        dependency-type: development
        update-types: [minor, patch]
      production-dependencies:
        dependency-type: production
        update-types: [minor, patch]

  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
      day: monday
    open-pull-requests-limit: 5
    cooldown:
      default-days: 7
    groups:
      github-actions:
        patterns: ["*"]
```

Majors arrive as separate PRs on purpose. Every `ignore:` entry has a comment
saying why - for example, holding `@types/node` on the major that matches
`engines.node`.

### 6. A security policy that a solo maintainer can keep

Every public repository has a `SECURITY.md`, either its own or the account
default from the `.github` repository. It says:

- **How to report**: GitHub private vulnerability reporting first, with a direct
  link to `/security/advisories/new`; a security mailbox on a domain you own as
  the fallback. Never a personal or account-recovery address.
- **What to expect**: an acknowledgement within **7 days** (inside the OpenSSF
  Best Practices limit of 14), credit in the advisory if wanted, and a fix
  followed by a published advisory.
- **What is supported**: usually the latest release only.

```markdown
<!-- good: SECURITY.md - Reporting a Vulnerability -->
Please report security issues privately, not in a public issue, discussion or pull request:

- **Preferred:** use GitHub's private vulnerability reporting. Open this repository's
  **Security** tab and choose **Report a vulnerability**, or go to
  <https://github.com/OWNER/REPO/security/advisories/new>.
- **Alternatively:** email **security@example.dev**.

You should expect an acknowledgement within 7 days, and credit in the advisory if you
want it.
```

Promise what you can keep: a 48-hour SLA that is missed once is worse than a
7-day one that never is.

### 7. Document how the repository releases

Any repository that publishes has a `RELEASING.md` written from what its
release workflow actually does: the trigger, the human steps (approvals,
dispatches), and what to do when a step fails. Agent instructions
(`CLAUDE.md`, `AGENTS.md`) must agree with it; stale agent instructions are a
supply-chain risk, because agents act on them.

### 8. Default community files live in `.github`

`SECURITY.md` and other community health files that apply everywhere live in
the account's public `.github` repository. A repository adds its own copy only
when it says something different (supported versions, supply-chain details).

---

## Enforcement

The drift check reads `baseline/baseline.json` and `baseline/repos.json` and
reports, per repository:

| Rule | Check |
| --- | --- |
| Tier recorded | repository listed in `repos.json` |
| Settings | the table in rule 2, via the REST API |
| Rulesets | `main` and `release tags` rulesets present with the tier's rules |
| Exceptions | every waived rule has a reason |
| Dependabot | `.github/dependabot.yml` covers each ecosystem + `github-actions`, with `cooldown` |
| Security policy | own `SECURITY.md` or the `.github` default; no personal addresses |
| Release docs | `RELEASING.md` present for `package` and `tool` |


---

# Releasing standards

A standard for how artifacts leave a repository: npm packages, crates, binaries,
Homebrew formulae, GitHub Actions, and the security releases that fix them. The
threat model is a stolen publishing token, a release built from the wrong
commit, a tag moved after the fact, an artifact nobody can trace back to its
source, and a maintainer who approves something they never looked at.

The posture: **releases are tag-triggered, keyless and verifiable, and a human
decides what ships** - by approving a staged version where the channel supports
it, and by pushing the tag where it does not.

Reference implementations. Each is cited only for the part named, not as a
complete example: release workflows are reviewed by hand against this
standard, and several of these still have gaps (for example, wordle-tui's
publish job still compiles the crate next to the token, and the single-job
npm releases run tests where the token is available).

| Part | Reference |
| --- | --- |
| npm: build job, isolated stage-publish job, GitHub release | [`danfry1/reflow-ts` release.yml](https://github.com/danfry1/reflow-ts/blob/main/.github/workflows/release.yml) |
| crates.io trusted-publisher auth action; the tap pull request | [`danfry1/wordle-tui` release.yml](https://github.com/danfry1/wordle-tui/blob/main/.github/workflows/release.yml) |
| The attest job, verified against published checksums | [`danfry1/waxon` release.yml](https://github.com/danfry1/waxon/blob/main/.github/workflows/release.yml) |
| Homebrew tap: test-bot bottles, manual publish | [`danfry1/homebrew-tap`](https://github.com/danfry1/homebrew-tap) |
| A JavaScript action's reproducible `dist/` | [`danfry1/jev-triage` ci.yml](https://github.com/danfry1/jev-triage/blob/main/.github/workflows/ci.yml) |

---

## Principles

### 1. Tag-triggered, from `main`, with one source of truth for the version

A release is a version tag pushed to a commit on `main`. The version lives in
exactly one place (`package.json`, `Cargo.toml`, or the tag itself for Go), and
the workflow fails if the tag and the manifest disagree
([GitHub Actions](#8-gate-releases-on-the-tag)).

### 2. Keyless publishing

No long-lived registry token exists anywhere: not in CI secrets, not on the
maintainer's machine. Registries that support it are published to with **OIDC
trusted publishing**; cross-repository writes use a **GitHub App** token
([GitHub Actions](#12-cross-repo-writes-use-a-github-app-token-never-a-pat)).

### 3. A human gate before anything ships

Where the channel supports it, CI builds, tests and *stages*, and a human
approves with 2FA: npm staged publishing, or a manually dispatched Homebrew
`pr-pull`. Where it does not (crates.io, binaries on a release, a Scoop
manifest), pushing the version tag is the human decision, and everything up
to that point must have run on the pull request. An automated release that
ships straight to `latest` makes every consumer the test.

### 4. Every artifact is traceable to its commit

npm and crates.io attach provenance through trusted publishing; binaries get a
build-provenance attestation; checksums are published next to every download.
Consumers can verify any artifact without trusting the maintainer's word.

### 5. Tags are permanent; fix forward

Release tags are locked by ruleset ([Repository](#3-rulesets-on-main-and-on-release-tags)).
A failed or bad release is fixed with a **new patch version**, never by moving
or re-using a tag. The one exception is a GitHub Action's major tag (`v1`),
which is a pointer by design.

### 6. Isolate the publishing job

The job that can publish downloads the artifact an earlier, read-only job built
and tested, and runs nothing else ([GitHub Actions](#7-split-build-from-publish)).

### 7. Verify after publishing

After every release, confirm from the outside what users get: the registry
shows the version with provenance, `latest` points where you expect, and the
installed artifact contains the change.

```bash
npm view <pkg> dist-tags versions --json
npm install <pkg>@<version> && npm audit signatures
gh attestation verify <file> --repo OWNER/REPO
```

---

## Channels

### npm

- **Trusted publisher** on npmjs.com: the repository, the workflow file, and
  the `npm` environment. Restrict it to **stage publish** where the package
  supports it.
- **Package setting:** "Require two-factor authentication and disallow tokens".
  Safe once every publish uses OIDC - check no workflow references
  `NPM_TOKEN`/`NODE_AUTH_TOKEN` and no such secret remains first.
- **Pin the npm that publishes** by pinning an exact Node version in the publish
  job (`node-version: 24.21.0`) whose bundled npm supports `npm stage`. The
  publish job then installs nothing. Jobs that don't publish may pin npm as a
  `devDependency` instead and put `node_modules/.bin` first on `PATH`.
- **Stage, then approve:**

  ```bash
  # in CI (OIDC, provenance generated automatically)
  npm stage publish
  # by the maintainer, with 2FA
  npm stage list
  npm stage download <id>    # inspect what will ship
  npm stage approve <id>
  ```

- **Skip versions already on the registry** (`npm view "<pkg>@$version"`), so
  re-running a release is safe.
- **The first publish** of a new name is a one-off bootstrap by hand with 2FA;
  every later version goes through CI. Remove any dist-tag left pointing at the
  bootstrap version.
- **Attach an SBOM** (CycloneDX) to the GitHub release.
- **A private repository cannot carry public provenance.** A package published
  from one should move to a public repository.

### crates.io

- **Trusted Publishing** on the crate's settings page (repository, workflow
  `release.yml`), then enable **"Require trusted publishing for all new
  versions"** so a leaked token could not publish either.
- **Verify in a read-only job, publish without building.** `cargo publish`
  normally verifies by compiling the crate, which runs dependencies' build
  scripts and proc macros - in the job that holds the token. Run
  `cargo package --locked` in a job with read-only permissions, then
  `cargo publish --locked --no-verify` in the publish job, so no dependency code
  runs next to the token.
- Mint the token per run with `rust-lang/crates-io-auth-action`.
- `cargo logout` on the maintainer's machine; no API tokens on the account.

```yaml
# good
verify-crate:
  runs-on: ubuntu-latest
  timeout-minutes: 20
  permissions:
    contents: read
  steps:
    - uses: actions/checkout@<sha> # vX.Y.Z
      with:
        persist-credentials: false
    - run: cargo package --locked   # builds and checks the exact package

publish-crate:
  needs: verify-crate
  runs-on: ubuntu-latest
  timeout-minutes: 10
  permissions:
    contents: read
    id-token: write
  steps:
    - uses: actions/checkout@<sha> # vX.Y.Z
      with:
        persist-credentials: false
    - uses: rust-lang/crates-io-auth-action@<sha> # v1.0.4
      id: auth
    - run: cargo publish --locked --no-verify   # verified above; nothing compiles here
      env:
        CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
```

### Binaries on GitHub Releases

- Build per target in a matrix; publish an archive and a SHA-256 checksum per
  target.
- **Attest** in one job appended after the last upload, in the same run:
  download the published assets, check them against the published checksums,
  then attest with `actions/attest` and `subject-checksums`. Appending the job
  means attestation never needs threading through each build, and a failure
  there cannot block the rest of the release.
- **Never attest an old release** to test the job: an attestation claims "built
  by this run". Dry-run the download and checksum logic instead.
- Be exact about what checksums prove: checksums from the same release detect
  corruption or a partial swap, not a full swap. The attestation covers the
  full swap.

```yaml
# good: the appended attest job
attest:
  needs: [upload]
  runs-on: ubuntu-latest
  timeout-minutes: 15
  permissions:
    contents: read
    id-token: write       # sign (Sigstore)
    attestations: write   # store
  steps:
    - env:
        GH_TOKEN: ${{ github.token }}
        TAG: ${{ github.ref_name }}
      run: gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir dist
    # ...verify against the published checksums, write subjects.txt...
    - uses: actions/attest@<sha> # v4.2.2
      with:
        subject-checksums: ${{ runner.temp }}/subjects.txt
```

Consumers verify with `gh attestation verify <file> --repo OWNER/REPO`.

### Homebrew

- **Formulae build from source** in a tap (`OWNER/homebrew-tap`) with
  `std_cargo_args` / `std_go_args`. Never ship a prebuilt, unsigned macOS binary
  in a cask that strips the quarantine attribute: that switches Gatekeeper off
  for that binary on every user's machine.
- **Bottles** remove the compile: the tap's `tests.yml` runs `brew test-bot` on
  each formula PR (macOS 15, Ubuntu x86_64, Ubuntu ARM - bottles built on the
  oldest supported macOS serve newer ones), and a **manually dispatched**
  `publish.yml` runs `brew pr-pull` to upload, attest and write the `bottle do`
  block. The dispatch is the human gate.
- **The release opens a PR, it does not push.** The tool's release job, using
  the GitHub App token, bumps `url`/`sha256`, deletes the stale `bottle do`
  block (and the blank line it leaves) and any `revision`, opens a PR on the tap,
  and comments the exact publish command.
- **Real tests.** `test do` exercises behaviour offline (config paths, a
  failure message that names the fix, a TUI driven in a pseudo-terminal) instead
  of `--version`. Prove each test can fail.
- **Run homebrew-core's audit** (`brew audit --new --strict --online`) in the
  tap's CI so a formula is ready to submit upstream when the project qualifies.
- **Moving a cask to a formula:** delete the cask and add
  `tap_migrations.json` (`{"tool": "OWNER/tap"}`). Homebrew migrates users on
  the next `brew update`. Stop the release tool regenerating the cask *before*
  deleting it, or the next release brings it back.

### Scoop

The release pushes the manifest to the bucket with the same GitHub App token
used for the tap; nothing else holds write access to the bucket.

### JavaScript GitHub Actions

- Commit `dist/` and check in CI that a fresh build reproduces it byte for byte
  (`git diff --exit-code dist/`), so the committed bundle is provably the
  source.
- A runtime dependency fix is a release: rebuild `dist/`, tag `vX.Y.Z`, publish
  release notes, then move the major tag (`v1`) to the same commit so
  `@v1` users get it.

---

## Security releases

When a vulnerability is confirmed:

1. **Fix with a regression test** that fails without the fix; add a property or
   fuzz comparison against the reference behaviour where one exists.
2. **Decide where to fix.** If the bug is already public (a failing CI log, a
   public issue), fix in the open. Otherwise use the advisory's private fork.
3. **Release a patch version** through the normal pipeline, then verify the
   published artifact contains the fix.
4. **Publish a GitHub security advisory**: summary, impact, affected range,
   patched version, severity, CWE, and workarounds. Dependabot then alerts every
   affected user. Request a CVE if the package has users outside your own repos.
5. **Remove dist-tags that still point at vulnerable versions.**
6. **Credit the reporter** if they want it.

---

## Enforcement

| Rule | Check | Automated |
| --- | --- | --- |
| Keyless | no repository, Dependabot or environment secret looks like a registry token or PAT | yes |
| npm | each package requires 2FA and disallows tokens; trusted publisher configured | by hand |
| crates.io | "require trusted publishing" enabled | by hand |
| Tag gate | the release checks tag == version, on `main`, not already published | by hand |
| Isolated publish | the job with `id-token: write` builds and tests nothing | by hand |
| Attestation | binary releases publish checksums and run an attest job | by hand |
| Homebrew | tap CI green; formulae build from source; no quarantine-stripping casks | by hand |
| Stale tags | no dist-tag points below the latest patched version | by hand |


---

# Maintainer account standards

A standard for the accounts behind the code: GitHub, the package registries,
and the email inboxes that can reset them. Every pipeline in these standards
ultimately trusts a person's login. The threat model is a phished password, an
intercepted SMS code, a forgotten token on an old laptop, an OAuth app nobody
remembers granting, and an inbox that can reset everything else.

The posture: **phishing-resistant sign-in everywhere, no standing credentials,
and recovery paths as strong as the accounts they recover.**

---

## Core rules

### 1. Passkeys as the preferred second factor; no SMS

Use a passkey or security key as the **preferred** 2FA method on GitHub, npm,
crates.io's login provider (GitHub), and every email account in the recovery
chain. Keep a second method registered as a backup where the service allows
one - a second passkey or key, or an authenticator app. Remove SMS: a number
can be ported to an attacker's SIM.

A synced passkey (in a password manager or the platform keychain) is its own
backup across devices. Review the list of registered passkeys and remove any
you cannot account for.

### 2. Recovery codes offline, outside the password manager

Recovery codes are the way back in when every device is gone. Store them
somewhere a compromise of the password manager does not reach: printed, or on
an encrypted drive at home.

### 3. The recovery email is the master key

Whoever controls the inbox that can reset your GitHub or registry account
controls the account. Give that inbox the same protection as the account (a
passkey, current recovery options), and never publish it as a contact address.

### 4. No standing credentials on your machine

With every release on trusted publishing, the laptop does not need to stay
logged in to any registry.

```bash
npm logout      # good: after a one-off bootstrap publish or an approval
cargo logout
npm token list  # good: should be empty
```

On npm, set 2FA to **auth-and-writes** (2FA on login *and* on every write), and
set each package to "require 2FA and disallow tokens".

### 5. No personal access tokens; one key per GitHub App

Automation that needs to act across repositories uses a GitHub App
([GitHub Actions](#12-cross-repo-writes-use-a-github-app-token-never-a-pat)):

- Give the App the minimum permissions (for a tap bumper: Contents read/write,
  Metadata read) and install it only on the repositories it writes to.
- Keep **one** private key. When rotating, verify the new key works before
  deleting the old one: sign an App JWT locally and call `GET /app`.
- Delete the downloaded `.pem` once it is stored as a secret.

Delete expired and unused personal access tokens; there should be none.

### 6. Review what can act as you

- **OAuth apps** and **GitHub Apps** with access to your account: revoke what
  you no longer use. An App with Contents write on a repository whose tags
  publish is, in effect, a publisher.
- **SSH keys**: one per device, named after it; remove any you cannot match to
  a device by fingerprint (`ssh-keygen -lf ~/.ssh/<key>.pub`).
- **Sessions**: sign out of unknown sessions.
- **Dormant integrations**: delete the account rather than just rotating its
  key; an unused service with a valid key is pure risk.

### 7. Email: noreply for commits, your own domain for contact

- Commit with the GitHub noreply address (`ID+user@users.noreply.github.com`)
  and enable **"Keep my email addresses private"** and **"Block command line
  pushes that expose my email"**. The privacy setting also makes commits made
  with the merge button use the noreply address.
- Publish a **contact address on a domain you own** (`dan@`, `security@`), with
  SPF, DKIM and DMARC. Move DMARC to `p=reject` once legitimate mail passes; for
  a domain that sends nothing else, that can be days, not weeks.
- Keep old addresses as **verified secondary emails** forever: removing one
  unlinks every past commit made with it from your profile.
- Registry profiles (npm, crates.io) use the domain address too.

---

## Enforcement

These are account settings, so most are checked by hand. Run through this list
once a quarter:

- [ ] Preferred 2FA is a passkey on GitHub, npm and each recovery inbox; no SMS
- [ ] Recovery codes stored offline and still valid
- [ ] `npm token list` empty; no `~/.npmrc` or `~/.cargo/credentials.toml` token
- [ ] No personal access tokens; each GitHub App has one key
- [ ] OAuth apps, GitHub Apps, SSH keys and sessions reviewed
- [ ] No personal or recovery address in any `SECURITY.md` or package metadata
- [ ] DMARC at `p=reject` on the contact domain
