CI, Dependencies & Releases¶
CI workflow¶
.github/workflows/ci.yml runs on every pull request and every push to master. Its one job, verify, named
Lint and build, runs on ubuntu-latest: check out, set up Node 26 with the npm cache, npm ci,
npm run lint, npm run build. It needs no secrets.
name: CI
on:
pull_request:
push:
branches: [master]
jobs:
verify:
name: Lint and build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
- run: npm ci
- run: npm run lint
# Supabase clients are constructed per request, so the build needs no secrets.
- run: npm run build
Documentation workflow¶
.github/workflows/docs.yml builds and publishes this site.
- When it runs: on pull requests and pushes to
masterthat changewebsite/**or a repository file a page includes (the schema, the twopackage.jsonfiles, the workflows and the Dependabot config), and on manual runs (workflow_dispatch). - The
docsjob checks that the nav and the pages match (check_nav.py, since--strictdoesn't catch a nav entry whose page is missing), then builds withzensical build --clean --strict, which fails on broken links, missing anchors and missing included files. On a push tomasteror a manual run it also uploads the site as the Pages artifact. - The
deployjob runs only for those deploying events and publishes the artifact to thegithub-pagesenvironment, which accepts deployments frommasteronly. The site is served at https://docs.tldrq.com. - Hardening: actions are pinned by commit SHA, checkout doesn't keep the token in
.git/config, nothing is cached, and each job gets only the permissions it needs.
It isn't a required check. When a page starts including another repository file, add that file to both path lists in the workflow, so changing it rebuilds the site.
name: Documentation
on:
# Pull requests build the site, so a broken link, a missing page or a missing snippet fails a check before it
# merges. Pushes to master build and deploy, so docs.tldrq.com follows the app, which Vercel deploys from master.
# Both run only when something the site is built from changes: its own files, and every repository file a page
# includes with pymdownx.snippets. Add a file here when a page starts including it.
pull_request:
paths:
- "website/**"
- "supabase/schema.sql"
- "package.json"
- "electron/package.json"
- ".github/dependabot.yml"
- ".github/workflows/ci.yml"
- ".github/workflows/docs.yml"
push:
branches:
- master
paths:
- "website/**"
- "supabase/schema.sql"
- "package.json"
- "electron/package.json"
- ".github/dependabot.yml"
- ".github/workflows/ci.yml"
- ".github/workflows/docs.yml"
workflow_dispatch:
# Least privilege by default; each job asks for what it needs.
permissions:
contents: read
# Deploys share one group and are never cancelled. Pull request runs get a group per ref, so a newer push to a PR
# supersedes its older run, and no PR run can replace a deploy waiting in the queue.
concurrency:
group: ${{ github.event_name == 'pull_request' && format('docs-{0}', github.ref) || 'pages' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Build the site, and upload the Pages artifact when deploying. Kept separate from the deploy job so that
# recovering a failed deploy means re-running only the deploy job, which does not re-upload the artifact
# (actions/deploy-pages fails when a run holds more than one artifact named "github-pages").
#
# It restores nothing from a cache: this workflow publishes the site, and a cache entry written by another run
# could change what gets deployed (zizmor's cache-poisoning audit).
docs:
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
env:
DEPLOY: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
if: env.DEPLOY == 'true'
# Nothing here pushes, so checkout doesn't leave the token in .git/config (zizmor's artipacked audit).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# uv reads website/.python-version and installs that Python itself, then Zensical from website/uv.lock.
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: false
# --strict doesn't report a nav entry whose page is missing, so check the nav first.
- run: uv run --locked python ../check_nav.py
working-directory: website/tldrq
# --strict turns warnings, such as a link to a missing page or anchor, into failures.
- run: uv run --locked zensical build --clean --strict
working-directory: website/tldrq
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
if: env.DEPLOY == 'true'
with:
path: website/tldrq/site
# Deploy the artifact produced by the docs job. Deliberately minimal (no checkout or build) so re-running just
# this job re-deploys the existing artifact. The github-pages environment accepts only master.
deploy:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
needs: docs
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
actions: read
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
id: deployment
Branch protection¶
Two layers protect master:
- Classic branch protection requires the GitGuardian Security Checks, Lint and build, Vercel and
Vercel Preview Comments checks, and requires a branch to be up to date with
masterbefore it merges. It isn't enforced for admins. - A repository ruleset, "master protection", also requires Lint and build, with a bypass for repository admins.
In practice: update a pull request's branch (gh pr update-branch <number>, or Update branch on GitHub) and
wait for the checks before merging. When several PRs are ready, merge them one at a time, updating each in turn.
Dependabot¶
.github/dependabot.yml checks weekly, and every update waits 7 days after its release (cooldown) so a broken
or compromised version has time to be caught; security updates skip the wait.
- npm, root: minor and patch updates arrive together in one PR. Majors of
nextandeslint-config-nextcome together, as do majors ofreact,react-dom,@types/reactand@types/react-dom. Majors are ignored for:@types/node, which tracks the Node major the app runs on (26, as in CI);typescript, because TypeScript 7 drops the JavaScript compiler API that the Next.js build's type-check and typescript-eslint use;eslint, because the plugins bundled ineslint-config-nextcrash on ESLint 10.
- npm,
electron/: one grouped PR. CI doesn't build the desktop app, so check these by hand withnpm start(see Desktop App Internals). - GitHub Actions: one grouped PR, which covers the SHA-pinned actions in
docs.ymltoo.
# Dependabot version updates. Alerts and security updates are enabled separately in the
# repository settings. Each update waits 7 days after its release, so a compromised or
# broken version has time to be caught and pulled before a PR proposes it; security
# updates skip the cooldown. PRs against the root package run the CI lint and build.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
# Minor and patch updates land together in one PR.
npm-minor-and-patch:
patterns: ["*"]
update-types: [minor, patch]
# next and eslint-config-next are pinned to the same exact version, and react-dom
# requires the matching react, so a major moves each family in a single PR instead
# of splitting it into PRs that fail on their own.
next-major:
patterns: ["next", "eslint-config-next"]
update-types: [major]
react-major:
patterns: ["react", "react-dom", "@types/react", "@types/react-dom"]
update-types: [major]
ignore:
# @types/node tracks the Node major the app runs on (26, as in ci.yml). Moving to
# the next Node line is a deliberate change, not a routine bump.
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# TypeScript 7 drops the JS compiler API that the Next.js build type-check and
# typescript-eslint both load. Remove once both support TS 7.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# The plugins bundled in eslint-config-next (eslint-plugin-react) crash on ESLint 10.
# Remove once eslint-config-next supports it.
- dependency-name: "eslint"
update-types: ["version-update:semver-major"]
# The Electron desktop wrapper has its own lockfile and isn't built by CI, so check
# these PRs by hand with `npm start` in electron/.
- package-ecosystem: npm
directory: /electron
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
electron:
patterns: ["*"]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
github-actions:
patterns: ["*"]
Releases¶
tldrq's releases follow one process, visible in its tags and in pull requests such as "Bump version to 0.2.11":
- On a branch, bump
versioninpackage.jsonandpackage-lock.json.npm version 0.2.12 --no-git-tag-versiondoes both. - Add a section for the version to
CHANGELOG.md, which follows Keep a Changelog and Semantic Versioning. - Add the release notes, exactly as they'll be published, to the top of
RELEASE_NOTES.md. - Open a pull request titled "Bump version to 0.2.12", get the checks green, and merge it.
-
Tag the merge commit on
masterwith the version, without avprefix (0.2.12), and create the GitHub release, titled with avprefix (v0.2.12). Its description summarizes the changes and ends with a link to the full diff:For example:
The app shows the version from package.json in its header and on the sign-in page. Vercel deploys the merge, so
the release is live once that deploy finishes. The desktop wrapper's own version, in electron/package.json, isn't
part of this process.