Skip to content

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.

.github/workflows/ci.yml
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 master that change website/** or a repository file a page includes (the schema, the two package.json files, the workflows and the Dependabot config), and on manual runs (workflow_dispatch).
  • The docs job checks that the nav and the pages match (check_nav.py, since --strict doesn't catch a nav entry whose page is missing), then builds with zensical build --clean --strict, which fails on broken links, missing anchors and missing included files. On a push to master or a manual run it also uploads the site as the Pages artifact.
  • The deploy job runs only for those deploying events and publishes the artifact to the github-pages environment, which accepts deployments from master only. 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.

.github/workflows/docs.yml
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 master before 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 next and eslint-config-next come together, as do majors of react, react-dom, @types/react and @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 in eslint-config-next crash on ESLint 10.
  • npm, electron/: one grouped PR. CI doesn't build the desktop app, so check these by hand with npm start (see Desktop App Internals).
  • GitHub Actions: one grouped PR, which covers the SHA-pinned actions in docs.yml too.
.github/dependabot.yml
# 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":

  1. On a branch, bump version in package.json and package-lock.json. npm version 0.2.12 --no-git-tag-version does both.
  2. Add a section for the version to CHANGELOG.md, which follows Keep a Changelog and Semantic Versioning.
  3. Add the release notes, exactly as they'll be published, to the top of RELEASE_NOTES.md.
  4. Open a pull request titled "Bump version to 0.2.12", get the checks green, and merge it.
  5. Tag the merge commit on master with the version, without a v prefix (0.2.12), and create the GitHub release, titled with a v prefix (v0.2.12). Its description summarizes the changes and ends with a link to the full diff:

    **Full Changelog**: https://github.com/pambrose/tldrq/compare/0.2.11...0.2.12
    

    For example:

    gh release create 0.2.12 --target master --title v0.2.12 --notes-file notes.md
    

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.