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
13 changes: 13 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ jobs:
with:
tag-name: ${{ needs.release-please.outputs.tag-name }}

# Only stable releases get binaries. A prerelease publishes to npm without
# cutting a GitHub release, so there would be nothing to attach them to. The
# build itself lives in `sea.yml`, which pull requests run too.
publish-binaries:
name: Attach binaries
needs: release-please
if: needs.release-please.outputs.release-created == 'true'
permissions:
contents: write
uses: ./.github/workflows/sea.yml
with:
tag-name: ${{ needs.release-please.outputs.tag-name }}

publish-prerelease:
name: Publish prerelease
# Skip the version-bump merges release-please pushes onto main — those are
Expand Down
105 changes: 105 additions & 0 deletions .github/workflows/sea.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

# The single executables, built one way for two callers: a pull request that
# touches the build proves every platform still produces a working binary, and
# `release-please.yml` calls this with a tag to attach them to the release. One
# file, so the check and the release cannot drift apart.

name: Single executable

on:
# Not a required check, so a path filter is safe here — unlike `CI / Gate`,
# a skipped run blocks nothing.
pull_request:
branches:
- main
- dev
paths:
- src/**
- scripts/sea-build.ts
- vite.config.ts
- vite.sea.config.ts
- package.json
- pnpm-lock.yaml
- .nvmrc
- .github/workflows/sea.yml
workflow_call:
inputs:
tag-name:
description: Release tag to build from and attach the archives to.
required: true
type: string

permissions:
contents: read

jobs:
# One job per platform, and it has to be that way: a single executable is the
# target platform's own node binary with a blob injected into it, so there is
# nothing to cross-compile from. `macos-15-intel` is the Intel label that
# replaced the retired `macos-13`.
build:
name: ${{ matrix.target }}
permissions:
# `gh release upload` writes into the release; a pull request never
# reaches that step, and a caller can only grant what it holds.
contents: write
strategy:
# Every platform reports, rather than the first failure hiding the rest.
fail-fast: false
matrix:
include:
- target: darwin-arm64
runner: macos-latest
- target: darwin-x64
runner: macos-15-intel
- target: linux-x64
runner: ubuntu-latest
- target: linux-arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7
with:
# On release, the tag rather than the branch tip: the artifact has to
# carry the version that was released, and `--version` is inlined at
# build time. Empty on a pull request, which checks out its own head.
ref: ${{ inputs.tag-name }}

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile

# `build` first: it empties `dist` to write the npm artifact, which would
# take the binary with it the other way round. `sea:build` runs the binary
# before packaging it, so a broken one fails here.
- run: pnpm build
- run: pnpm sea:build

# What the README tells people to run, run as written: the archive name it
# derives from `uname` must be the one this platform produced, the
# checksum must verify, and the extracted binary must work with no node
# on the PATH.
- name: Install it the way the README does
working-directory: dist/release
run: |
NAME="gitignore-sync_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz"
shasum -a 256 -c "${NAME}.sha256"
mkdir unpacked
tar -xzf "${NAME}" -C unpacked
env -i PATH=/usr/bin:/bin ./unpacked/gitignore-sync info
env -i PATH=/usr/bin:/bin ./unpacked/gitignore-sync list > /dev/null

- name: Attach the archive and its checksum
if: inputs.tag-name
env:
GH_TOKEN: ${{ github.token }}
run: >-
gh release upload "${{ inputs.tag-name }}"
dist/release/*.tar.gz dist/release/*.tar.gz.sha256
--clobber
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl
| `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` + `build` — the CI gate |
| `pnpm check:policy` | Proves the two agent policy files ban the same commands |
| `pnpm templates:lock`| Re-writes `tests/templates.lock.json` after adding a template version |
| `pnpm sea:build` | After `pnpm build`: a single executable for this platform → `dist/release/` |
| `pnpm check:fix` | Auto-fix lint + format |
| `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI |
| `pnpm taze` | Interactive dependency upgrade check |
Expand Down Expand Up @@ -109,7 +110,7 @@ templates → Record<stack, Template[]> // data, versioned with the binar

`parse`, `render` and `reconcile` touch **no filesystem**. That puts the whole hard part — marker parsing, free zone, rescuing stray lines, dedup — behind a string-in/string-out seam. **Test that seam with fixture pairs under `tests/fixtures/` (`<name>.in` / `<name>.out`), never with temporary directories.** Adding a pair is the cheapest way to pin a behaviour; `tests/fixtures.test.ts` picks it up automatically and asserts idempotency on top.

`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation.
`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release, and a downloaded binary reports itself as a `single executable`. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation.

`edit`, `add` and `remove` are thin verbs over one header edit — they rewrite the `# stacks:` line and re-render, nothing more. They exist because discovering "open the file, change one line, run sync" from an error message is not a workflow.

Expand Down Expand Up @@ -232,12 +233,14 @@ Four template rules that are not style:
- **`agents` carries working files, not configuration.** Across the estate **126 files** under `.claude/`, `.codex/` and `.opencode/` are committed — `settings.json`, skills, `default.rules`, agent definitions. Only `settings.local.json` and `worktrees/` describe one machine, and only those two are in `agents@v1`. `agents@v2` adds `.codex/config.toml`: Laravel Boost writes it with absolute paths of the machine it ran on, and both repos that commit one (`app`, `gildstone`) committed exactly that — the file, not the directory, so `.codex/rules/` stays committable. `.claude/worktrees/` is included because it accumulates for real and the one repo that ignores it today does so in `.git/info/exclude` — private knowledge that belongs where the team can see it.
- **`node_modules` belongs to the `node` stack, not to `core`** — a pure Go repo then correctly does not get it. That it lands almost everywhere in practice is a consequence of the estate, not a reason to blur the model.

### Two surfaces, one tool
### Three surfaces, one tool

`action.yml` in this repo is a **composite** action wrapping `npx @kirchdev/gitignore-sync check`, because the exit code is the whole result — it adds a job summary and a `status` output, nothing else. `kirchDev/coverage-report` bundles its CLI into `dist/index.js` instead, and should: that action writes sticky comments and check runs of its own. Copying its shape here would ship the same code twice and give two things to keep in step.

Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task list from `package.json`, so adding `gitignore-sync check` to a repo's `check` script is picked up with no workflow change at all.

The third surface is for machines without Node: every stable release carries a **Node single executable** per platform — `darwin` and `linux`, `arm64` and `x64` — built by `sea.yml` — on every pull request that touches the build, for all four platforms, and called by `release-please.yml` to attach them to the release, so the check and the release cannot drift ([ADR-0010](docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md)). It is the same bundle with node inside it, not a second runtime: `bun build --compile` would have been one command, but it would ship a binary running on a runtime the test suite never runs on. Three things in it are not cosmetic: the entry is CommonJS (`vite.sea.config.ts`), because the embedder runs the main script as CJS; it must stay one file, because the embedder's `require` loads builtins only; and darwin needs `--macho-segment-name NODE_SEA` plus an ad-hoc re-sign. `scripts/sea-build.ts` checks all of it by running the binary before packaging. Archive names carry no version, so `releases/latest/download/…` always resolves. Windows gets no binary; npm is the path there.

## Architecture / conventions

- **Node 24, pnpm 11.** Pinned via `.nvmrc`, `engines`, and `packageManager`. Not Bun — the sibling `forgemap` declares `engines.node >= 24` and this repo follows it. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset.
Expand All @@ -246,11 +249,11 @@ Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task
- **Published as `@kirchdev/gitignore-sync`**, scoped like `@kirchdev/coverage-report`. The `bin` entries stay unscoped (`gitignore-sync`, `gis`) and so does release-please's `package-name` — the scope belongs to the registry, not to what a person types or what a tag is called.
- **`pnpm link --global .` edits `pnpm-workspace.yaml`**, adding an `overrides:` block pointing at the local checkout. That file is committed, so the block must never be: it would make every clone try to link a package that exists on one machine. `pnpm unlink` reverts it; `pnpm link` is only for trying the CLI locally.
- **The CLI's `--version` is injected at build time** by vite's `define` from `package.json`'s `version`, which release-please owns. Never hand-copy a version literal.
- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge.
- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. `postject`, which injects the single executable's blob, is a dev dependency only: the binary carries nothing of it.
- **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`.
- **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked.
- **release-please** with `release-type: node`, `include-v-in-tag: true`. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`.
- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly.
- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `github/codeql-action/{init,analyze}@v4` — GitHub's own actions, pinned to major versions. A third-party action is pinned to a commit SHA with its version as a comment (`pnpm/action-setup@<sha> # v6.0.10`), exactly like the `kirchDev/workflows` refs: CodeQL flags a movable tag on one, since its owner can repoint it. Dependabot bumps both forms monthly.
- **Bitwarden secret ids in workflows are vault references, not credentials.** The actual secret is `BWS_ACCESS_TOKEN`, a GitHub secret that never appears in a workflow file. Each owner authenticates as a different machine account and reads only its own `-ci` mirror, so this repo — a `kirchDev` repo — must carry the `kirchDev` id (`df8b447a-ffd5-4009-9cc3-b49b014f6978`), not `TitusKirch`'s. A mismatched id resolves for nobody, silently, until someone dispatches the workflow.

## AI & skills
Expand Down
Loading
Loading