Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 4 additions & 37 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -13,60 +13,27 @@
# Binary: never convert, never diff as text.
*.png binary
*.mp3 binary
*.ogg binary
*.ico binary
*.exe binary
*.db binary

# POSIX tools read these byte-for-byte. A shell script checked out with CRLF hands bash a
# shebang ending in a carriage return; it fails as "bad interpreter", which reads like a
# missing binary rather than a line-ending bug. Ubuntu CI runs tools/baseline/*.sh (plan 01-04).
# missing binary rather than a line-ending bug. Ubuntu CI runs any .sh under tools/.
*.sh text eol=lf
.nvmrc text eol=lf

# Shebang scripts: a CRLF `#!` line breaks Unix `env` and vite's hashbang strip, so vitest can't import them.
tools/**/*.mjs text eol=lf

# CUSTODY-09 baseline artifacts are written by tools/baseline/inventory.sh, a POSIX shell
# script, and are re-generated and diffed in Phase 8 to prove behaviour parity. Under the
# tree-wide `eol=crlf` rule they check out CRLF while the generator writes LF, which leaves
# `git status` permanently reporting them as modified even though `git diff` is empty - the
# exact "noise" the determinism requirement exists to avoid. Pin them to LF so the checked-out
# bytes and the generated bytes are the same on Windows and on the Ubuntu CI runner.
baselines/** text eol=lf

# ...but the PIXEL baselines are PNGs, and `baselines/** text eol=lf` above is an EXPLICIT `text`,
# not `text=auto`, so it forces end-of-line conversion with NO binary detection and OVERRIDES the
# `*.png binary` rule near the top of this file - later rules win. Measured, not theorised: a
# 16-byte file containing three CRLF pairs was staged under the rules above and stored as a
# 13-byte blob. Every screenshot would be silently corrupted on commit, and nothing downstream
# would notice - the file is still present, still non-zero, and the artifact test checks presence
# and pairing, not decodability. It would surface in Phase 8, when v1.2.1 can no longer be run to
# re-capture. Restore binary handling for the one artifact class under baselines/ that is not text.
baselines/**/*.png binary

# Same reasoning as baselines/** above, for the generated SQL fixtures (plan 01-04, extended
# by plan 01-07). tests/fixtures/*.sql is written by tools/baseline/archive-real-db.mjs and by
# the fixture generator, both of which emit LF. Under the tree-wide `eol=crlf` rule the file
# checks out CRLF, so every regeneration leaves `git status` reporting a byte-identical file as
# modified - noise around the one artifact whose regeneration must be unambiguous (D-05: plan
# 01-07 diffs this extract against its own fixture to decide whether a second legacy variant
# exists). Pin to LF so the checked-out bytes and the generated bytes match on Windows and on
# the Ubuntu CI runner alike.
# The SQL fixtures are hashed and replayed by the migration tests, so their bytes must not depend on the checkout.
tests/fixtures/*.sql text eol=lf

# Migration SQL is SHA-256 pinned (D-09), so its bytes must not depend on the checkout.
src/lib/db/migrations/** text eol=lf

# Vendored capture assets (plan 01-05). These are not source: they are byte-exact copies of what
# cdn.tailwindcss.com and fonts.googleapis.com served on a particular day, and their SHA-256 is
# recorded in baselines/v1.2.1/MANIFEST.md so that a Phase 8 re-capture is diffed against a fixed
# target rather than a moving one. Under the tree-wide `eol=crlf` rule, tailwind.js and the two
# stylesheets would be fetched as LF, stored as LF and CHECKED OUT AS CRLF - so every digest in the
# manifest would fail to verify on a fresh clone, for a file nobody had touched.
# `-text` disables end-of-line conversion in both directions while leaving the files diffable.
tools/baseline/vendor/** -text

# Fonts are binary. `text=auto` would very probably detect that on its own; saying so explicitly
# costs one line and removes the "very probably" from an artifact whose corruption would be
# invisible until a baseline render came out wrong.
# invisible until a render came out wrong.
*.woff2 binary
95 changes: 37 additions & 58 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Package

# BUILD-10 / D-17: build the installers the way they will actually ship - Windows and macOS, x64
# and arm64, four artifacts - launch the two the runners can execute, and prove what got packaged.
# and arm64, plus Linux x64 - launch the three the runners can execute, and prove what got packaged.
#
# Exactly three triggers, and the two that are missing are missing on purpose.
# pull_request nothing merges without four packaged artifacts and two smoke launches.
Expand Down Expand Up @@ -89,6 +89,20 @@ jobs:
unpacked_dir: dist/mac-arm64
smoke: true
smoke_skip_reason: ''
# Linux ships x64 only: an AppImage and a .deb from one build, so this leg expects two files
# (artifact_glob is space-separated). ubuntu-latest is x64, so the smoke launch is real here.
- os: ubuntu-latest
arch: x64
script: build:linux
artifact_glob: dist/*-x86_64.AppImage dist/*-amd64.deb
artifact_count: 2
# upload-artifact wants one pattern per line, where the shell glob above is space-separated.
upload_path: |
dist/*-x86_64.AppImage
dist/*-amd64.deb
unpacked_dir: dist/linux-unpacked
smoke: true
smoke_skip_reason: ''
# bash on both operating systems (Git Bash on Windows), so every step below reads its inputs
# from env the same way and the glob expansion behaves identically on all four legs.
defaults:
Expand Down Expand Up @@ -157,22 +171,25 @@ jobs:
- name: Assert this leg produced exactly one plausible installer
env:
ARTIFACT_GLOB: ${{ matrix.artifact_glob }}
ARTIFACT_COUNT: ${{ matrix.artifact_count || 1 }}
MIN_ARTIFACT_BYTES: '20000000'
run: |
shopt -s nullglob
# Unquoted on purpose: the glob has to expand. nullglob makes no match an empty array.
matches=( $ARTIFACT_GLOB )
if [ "${#matches[@]}" -ne 1 ]; then
echo "::error::expected exactly one file matching $ARTIFACT_GLOB, found ${#matches[@]}: ${matches[*]:-none}"
if [ "${#matches[@]}" -ne "$ARTIFACT_COUNT" ]; then
echo "::error::expected exactly $ARTIFACT_COUNT file(s) matching $ARTIFACT_GLOB, found ${#matches[@]}: ${matches[*]:-none}"
ls -la dist || true
exit 1
fi
bytes=$(wc -c < "${matches[0]}" | tr -d ' ')
if [ "$bytes" -lt "$MIN_ARTIFACT_BYTES" ]; then
echo "::error::${matches[0]} is $bytes bytes, below the $MIN_ARTIFACT_BYTES-byte floor - not a plausible installer"
exit 1
fi
echo "ARTIFACT_OK ${matches[0]} $bytes bytes"
for match in "${matches[@]}"; do
bytes=$(wc -c < "$match" | tr -d ' ')
if [ "$bytes" -lt "$MIN_ARTIFACT_BYTES" ]; then
echo "::error::$match is $bytes bytes, below the $MIN_ARTIFACT_BYTES-byte floor - not a plausible installer"
exit 1
fi
echo "ARTIFACT_OK $match $bytes bytes"
done

# BUILD-08, on the artifact rather than on electron-builder.yml: every packaged path is read
# from the archive's own header and checked against the allowlist, and the region unpacked
Expand All @@ -194,57 +211,19 @@ jobs:
# and a child environment scrubbed of ELECTRON_RUN_AS_NODE and NODE_OPTIONS. None of that may
# be relaxed to make a runner happy.
- name: Launch the packaged app and open a database (BUILD-06)
if: matrix.smoke
if: matrix.smoke && runner.os != 'Linux'
run: node tools/smoke-packaged.mjs

# Phase 8 criterion 2's second gate, and 08-REVIEW-TIMER WR-07 / 08-REVIEW-SCREENS WR-05: until this step
# existed, capture-v2.mjs and diff-computed.mjs were referenced by no script, no test and no workflow -
# so a register that documents itself as exiting non-zero on any unexplained difference exited non-zero at
# nobody, and the committed 29-difference report could not go stale in a way anything would notice. It
# runs here because it photographs the PACKAGED app, which only this job has.
#
# Windows only: baselines/v1.2.1/computed was captured on Windows, and font rasterisation and the default
# font stack differ enough on macOS to make a cross-platform diff a reading rather than a gate.
- name: Diff the shipped UI against the Phase 1 computed-style baselines (criterion 2)
if: matrix.smoke && runner.os == 'Windows'
run: npm run parity:check

# Phase 10 criteria 1-4: five window sizes x three Windows display scales, four routes in each of the
# fifteen cells, plus the modals at the 600px floor, the Pomodoro panel and the fire canvas's frame loop
# across a real unmount. It drives the packaged app, which only this job has.
#
# Windows only, for the same two reasons parity:check is: the criterion is about WINDOWS display scaling,
# and the committed report under baselines/v2/ is a staleness guard that would have to hold on two
# platforms at once to run on both.
- name: Check all fifteen responsive cells against the packaged app (criteria 1-4)
if: matrix.smoke && runner.os == 'Windows'
run: npm run matrix:check

# Phase 10 criterion 7: Chromium's network stack offline, every http/https/ws request cancelled and
# recorded in main, then all four routes - fonts, glyph widths, filled variants, the bundled sound, and
# zero CSP violations captured rather than assumed. Windows only for the staleness-guard reason above.
- name: Render every route with the network off (criterion 7)
if: matrix.smoke && runner.os == 'Windows'
run: npm run offline:check

# Phase 10 criterion 6, reproduced in CI: a populated v1.2.1 database migrated by the packaged app, read
# back through the app's own bridge - every session, company, setting and the streak.
#
# The archived copy of a real user's database is the other half of this criterion and is NOT here: it is
# owner-gated (tests/db-archive-proof.test.ts sits behind an approval environment variable for a reason -
# it is one person's tracked time and it does not belong on a hosted runner).
#
# And IPC-07, which the Phase 5 verifier left open because no test started a SECOND packaged process
# against a held lock: the binary is launched twice over one profile and the second is required to quit.
#
# Windows only. V2-QUAL-03 makes runtime acceptance Windows-only this milestone, and a macOS failure here
# could not be investigated - there is no macOS hardware. The two macOS legs still build, assert and
# allowlist-check, which is exactly what criterion 9 claims and no more.
- name: Install over v1.2.1 and launch twice from one profile (criterion 6, IPC-07)
if: matrix.smoke && runner.os == 'Windows'
# Linux needs a display, and Ubuntu 24.04 runners block the unprivileged user namespaces Chromium's
# sandbox uses (AppArmor), which would fail the launch for a reason that has nothing to do with the
# package. The sandbox stays ON: the sysctl is lifted for this runner instead of passing --no-sandbox,
# because a smoke launch that disables the sandbox proves less than the app users will run.
- name: Launch the packaged app and open a database, under a virtual display (BUILD-06)
if: matrix.smoke && runner.os == 'Linux'
run: |
npm run upgrade:check
npm run instance:check
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo apt-get install -y --no-install-recommends xvfb
xvfb-run --auto-servernum node tools/smoke-packaged.mjs

# A skip that prints its reason is a different thing from a check that silently did nothing.
# These two artifacts are built, size-checked and allowlist-checked, and never executed.
Expand All @@ -262,5 +241,5 @@ jobs:
with:
# Named after operating system and architecture, so release.yml can ask for all four by name.
name: ${{ matrix.os }}-${{ matrix.arch }}-build
path: ${{ matrix.artifact_glob }}
path: ${{ matrix.upload_path || matrix.artifact_glob }}
if-no-files-found: error # a green build that produced nothing is a failure
17 changes: 12 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,24 @@ jobs:
name: macos-latest-arm64-build
path: dist/

# Linux ships as an AppImage and a .deb from one x64 leg, which package.yml uploads as one artifact.
- name: Download Linux x64 packages
uses: actions/download-artifact@v4
with:
name: ubuntu-latest-x64-build
path: dist/

# BUILD-10, enforced at the moment it matters most. fail_on_unmatched_files below catches an
# empty glob, but it cannot tell that three of four architectures showed up - dist/* matches
# happily either way. So count what arrived: exactly one non-empty file per expected
# installer, and nothing else in dist/, because every file in dist/ is attached to the release.
# The suffixes follow the artifactName templates in electron-builder.yml.
- name: Refuse to publish unless all four installers arrived
- name: Refuse to publish unless every installer arrived
shell: bash
run: |
shopt -s nullglob
failed=0
for suffix in x64-setup.exe arm64-setup.exe x64.dmg arm64.dmg; do
for suffix in x64-setup.exe arm64-setup.exe x64.dmg arm64.dmg x86_64.AppImage amd64.deb; do
matches=( dist/*-"$suffix" )
if [ "${#matches[@]}" -ne 1 ]; then
echo "::error::expected exactly one installer ending in -$suffix, found ${#matches[@]}: ${matches[*]:-none}"
Expand All @@ -106,16 +113,16 @@ jobs:
fi
done
everything=( dist/* )
if [ "${#everything[@]}" -ne 4 ]; then
echo "::error::dist/ holds ${#everything[@]} entries, expected exactly the four installers - every file in dist/ is attached to the release"
if [ "${#everything[@]}" -ne 6 ]; then
echo "::error::dist/ holds ${#everything[@]} entries, expected exactly the six installers - every file in dist/ is attached to the release"
ls -la dist/ || true
failed=1
fi
if [ "$failed" -ne 0 ]; then
echo "::error::Refusing to create the release: it would ship without every platform and architecture (BUILD-10)."
exit 1
fi
echo "FOUR_ARTIFACTS_OK"
echo "ALL_ARTIFACTS_OK"

# A draft release does not create its tag until a person publishes it - and a tag pushed by a
# person starts this workflow again, which would rebuild for twenty minutes and then set the
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
# errors; the renormalise assertion catches the actual D-13 regression - a blob
# committed with line endings that disagree with .gitattributes, which surfaces
# later as "whole file changed" on a file nobody edited.
# v121-sql.ts is excluded for the same reason as baselines/: it transcribes v1.2.1's SQL and its
# v121-sql.ts is excluded because it transcribes v1.2.1's SQL and its
# trailing spaces are the contract, because sqlite_master stores CREATE text verbatim. Nothing is
# lost by excluding it - db-v121-transcription.test.ts pins the whole file byte-for-byte, which is
# a stronger guard than this check.
Expand All @@ -86,7 +86,7 @@ jobs:
exit 1
fi
echo "Whitespace check over $(git rev-parse --short "$base")..$(git rev-parse --short HEAD) ($EVENT_NAME)"
git diff --check --ignore-cr-at-eol "$base" HEAD -- . ':!baselines/**' ':!tools/baseline/vendor/**' ':!tests/helpers/v121-sql.ts'
git diff --check --ignore-cr-at-eol "$base" HEAD -- . ':!tests/helpers/v121-sql.ts'
git add --renormalize .
if ! git diff --cached --quiet; then
echo "::error::Committed line endings disagree with .gitattributes in:"
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ A change has to pass all of these. They are what CI runs, and you can run every
| Audit | `npm audit` | Zero vulnerabilities. |

CI additionally runs `npm test` in three timezones (UTC, America/New_York, Europe/Istanbul), builds
all four installers, and on the Windows leg runs `parity:check`, `matrix:check`, `offline:check`,
`upgrade:check` and `instance:check`.
every installer (Windows, macOS and Linux), and launches the packaged app with `smoke:packaged` on the legs
the runner can execute.

Some of the suite is *structural* — tests that read the source and fail on a misplaced import, an
unlinted file, a stale guard or a duplicated asset. If one of those fails, it is telling you about a
Expand Down
Loading
Loading