Compare commits

...
26 Commits
Author SHA1 Message Date
Kevin Codex e9da517fde Merge pull request #240 from Gitlawb/release-please--branches--main--components--gitlawb-node
chore(main): release 0.7.0
2026-07-23 08:27:35 +08:00
Kevin Codex 9ca94c4d47 Merge pull request #241 from Gitlawb/ci/gitlawb-core-dep-purity
ci(core): add dependency-purity allowlist gate for gitlawb-core
2026-07-23 07:06:43 +08:00
beardthelion c0ad500b55 ci(core): allowlist curve25519-dalek-derive
Proc-macro helper for curve25519-dalek (already allowlisted). Newer cargo
versions list it under `cargo tree --edges normal` while older ones do not;
the allowlist is a superset so it passes on both. Caught by core-deps-purity
on the first CI run of this branch.
2026-07-22 12:36:25 -05:00
beardthelion e9d6aadd58 ci(core): add dependency-purity allowlist gate for gitlawb-core
gitlawb-core is embedded by gl, git-remote-gitlawb, and the node daemon,
so it must stay lean. Add a CI gate that fails if its normal (non-dev,
non-build) transitive dependency tree gains any crate not on an explicit
allowlist.

The allowlist is exhaustive-by-construction: a new heavy dependency reds
CI whether or not anyone thought to ban it, which a denylist cannot do.
The checker resolves like the build/test jobs (no --locked, since the
committed lockfile can lag the manifests) and snapshots/restores
Cargo.lock so a local run leaves no working-tree side effects.

- ci/gitlawb-core-allowed-deps.txt: the 83 current normal deps, plus the
  regeneration command and rationale in the header
- scripts/check-gitlawb-core-deps.sh: recompute and diff; hard-fail on a
  non-allowlisted crate, note-only on a stale (removed) allowlist entry
- pr-checks.yml: new hard-fail core-deps-purity job, matching the pinned
  action SHAs and style of the sibling jobs
2026-07-22 12:04:34 -05:00
Kevin Codex b484a24955 Merge pull request #237 from Gitlawb/fix/issue-228-windows-ci
ci: add a non-blocking Windows test lane for the shipped CLIs
2026-07-22 23:28:53 +08:00
github-actions[bot] 32006b26a2 chore(main): release 0.7.0 2026-07-22 15:28:32 +00:00
Kevin Codex b75aa2f1da Merge pull request #236 from Gitlawb/fix/gl-acceptance-findings
Fix/gl acceptance findings
2026-07-22 23:28:09 +08:00
Kevin Codex c681af70db fix(gl): anchor cert --verify to a trusted issuer; pin canonical payload form
Settles the review's design question: a valid signature alone proves
internal consistency — the payload was signed by whatever key the
certificate itself names — so a hostile source could self-sign and
pass. --verify now also requires the issuing node_did to match a
trusted anchor: --expect-node when given, else the DID of the node
being queried; unanchorable (node info unreachable, no --expect-node)
fails closed. The displayed verdict wording says what it proves.

Also from review:
- A frozen-literal test pins gl's payload serialization to the node's
  canonical byte form, so serialization drift (new field, a
  preserve_order feature unifying across the workspace) fails a test
  instead of silently rendering every real certificate INVALID.
- The printed unalias remediation uses the idempotent-safe
  'unalias gl 2>/dev/null' form everywhere (doctor, install.sh, brew
  caveats), matching what doctor already suggested.
- Acknowledged, deliberately left: bash-side alias detection and
  127/8-range loopback matching (noted as out of scope for a
  warning-level heuristic).
2026-07-22 21:43:44 +08:00
Kevin Codex 3fa38dce4e fix(gl): address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias
- cert show: the node-DID comparison is contextual; a node-info fetch
  error now prints a note instead of failing the command after the
  certificate and verdict were already shown.
- doctor: loopback detection parses the URL host exactly — substring
  matching misread localhost.example and localhost-in-path URLs.
- init: a detached HEAD gets its own hint (switch to a branch first)
  instead of a guessed 'git push gitlawb main'.
- status: every fetch AND push URL of each remote is inspected — a
  gitlawb:// push URL behind an https fetch URL now counts.
- doctor/install.sh: unalias detection is command-aware — comments,
  'unalias global', and gl-only-in-a-trailing-comment no longer
  suppress the warning (BSD-grep-compatible pattern; ordering vs
  oh-my-zsh load documented as accepted slack). Eight new test cases.
2026-07-22 21:17:38 +08:00
t c71bba5cd1 ci: add a non-blocking Windows test lane for the shipped CLIs (#228)
We ship `gl` and `git-remote-gitlawb` to Windows users via release.yml but run
no Windows tests: pr-checks.yml is ubuntu-only, and the single windows-latest in
the repo is the release build step, which never runs `cargo test`. So changes to
the shipped Windows CLIs (e.g. the multi-round fetch fix in #192) reach Windows
users with zero test runs on Windows.

Add a `test-windows` job that runs `cargo test -p gl -p git-remote-gitlawb` on
windows-latest with `continue-on-error: true`, mirroring the informational beta
lane: a Windows-only failure is visible on the PR but does not block merge. The
two crates are the client CLIs and depend on no database, so the job needs no
Postgres service (service containers do not run on windows-latest regardless);
git is preinstalled on the runner for the real_git_fetch harness.

This makes the #[cfg(windows)] regressions from #192 load-bearing instead of
never-run. Un-gating the currently #[cfg(unix)] harness tests is the deferred
follow-on in #228.
2026-07-22 08:01:06 -05:00
Kevin Codex e3df51d76c fix: warn about the oh-my-zsh gl alias at install time
install.sh detects the common shadowing setup (omz git plugin, no
unalias in ~/.zshrc) and prints the fix right after installing; the
generated Homebrew formula gains a caveats block saying the same, so
brew install/upgrade surfaces it too. Catching this at install time
beats letting the first gl invocation print git's error.
2026-07-22 20:51:23 +08:00
Kevin Codex 0765c42fef feat(gl): doctor warns when a shell alias shadows the gl binary
oh-my-zsh's default git plugin ships gl='git pull', silently shadowing
gl in every interactive zsh — the symptom is the baffling 'fatal: not a
git repository'. Aliases beat PATH, so no installer can fix it; doctor
now detects the setup (explicit alias gl= in ~/.zshrc, or the omz git
plugin without an unalias) and prescribes the one-line fix. Best-effort
heuristic, warning-level, covered by four unit tests.
2026-07-22 20:51:23 +08:00
Kevin Codex 94c61f7942 fix(gl): doctor treats a reachable local node as configuration, not failure
A localhost GITLAWB_NODE red-flagged every self-hosted and dev-harness
setup even when the node was healthy; the connectivity check already
fails loudly when the configured node is unreachable. The env check now
passes with an explanatory note.
2026-07-22 20:37:33 +08:00
Kevin Codex b0a2ed8308 feat(gl): actually verify certificate signatures in gl cert show
show printed the signing payload and told the user to verify offline
themselves. It now rebuilds the node's canonical payload (field order
mirrors gitlawb-node cert issuance), derives the Ed25519 key from the
certificate's node DID, verifies, and prints a VALID/INVALID verdict;
--verify makes the exit code reflect it for scripting. Round-trip +
tamper + malformed-signature unit tests included.
2026-07-22 20:37:33 +08:00
Kevin Codex cb42a4830d fix(gl): make gl init's push guidance match the repo's actual state
- git init -b main (plain-init fallback for git <2.28) so a fresh repo
  can't land on master while every hint says main.
- The closing hint checks HEAD: with no commits it prints the
  add/commit step first — a bare 'git push gitlawb main' at that point
  fails with 'src refspec main does not match any', the exact trap the
  acceptance test walked into. The current branch name is used in the
  hint either way.
2026-07-22 20:37:33 +08:00
Kevin Codex 2d88937fba fix(gl): detect gitlawb remotes beyond origin in gl status
gl init names its remote 'gitlawb', but detect_gitlawb_remote() only
ever read origin — so gl status reported 'not in a gitlawb repo' in the
very repo gl init had just set up. Scan all remotes, preferring origin
(clones) then gitlawb, then any other gitlawb:// URL. (#231 acceptance
test finding)
2026-07-22 20:37:33 +08:00
Kevin Codex e14c2d3f3f Merge pull request #230 from Gitlawb/ci/merge-race-guards
ci: guard against semantic merge races breaking main
2026-07-22 16:35:22 +08:00
Kevin Codex 4465beb0c2 ci: make the red-main alert a state tracker, not an event echo
Address review: the alerter reflected CI events rather than main's
current state, which could both cry wolf and stay silent.

- Freshness gate: a run only opens or closes anything when its commit is
  still main's tip — re-running an old red run after a fix has landed no
  longer opens a false stop-ship issue (and a stale green can't close a
  current one).
- Trigger covers timed_out and startup_failure — both are red mains that
  previously alerted nothing. cancelled stays excluded as a human abort.
- Recovery: a green run on the tip comments and closes open ci-main-red
  issues, turning the alert into a current-state signal.
- queue: max — the single-slot pending queue let a later completion
  displace a pending red alert under burst merges.
- Issue lookup filters out pull requests carrying the label.
- github-script converged on v8.0.0 (SHA verified against the tag) in
  pr-triage too.
2026-07-22 16:20:15 +08:00
Kevin Codex 82efd60229 ci: serialize main-red-alert runs to prevent duplicate issues
Two near-simultaneous red-main events could both pass the open-issue
lookup and each create a ci-main-red issue (or 422 racing createLabel).
A workflow concurrency group with cancel-in-progress: false serializes
them; the default single-slot pending queue is enough since a superseded
alert's state is re-read by the newest run anyway.
2026-07-22 16:18:38 +08:00
Kevin Codex e8f193a5df ci: guard against semantic merge races breaking main
Two individually-green PRs can break main in combination when the second
merges on checks that ran against a stale base (#113 + #145 compiled fine
apart, but #145 added a field to ReceivedRefUpdate that #113's new test
initializer never set — E0063 on merged main, fixed by #229).

- pr-checks: add merge_group trigger so the suite can gate a GitHub merge
  queue, which re-validates every PR against the true merged result.
- main-red-alert: new workflow that opens/updates a labeled issue whenever
  a push-to-main PR Checks run fails, so a red main is loud instead of
  silent (release-please PRs inherit main breakage, as #182 showed).
2026-07-22 16:18:38 +08:00
Kevin Codex 07d76ba556 Merge pull request #235 from Gitlawb/ci/docker-native-arm
ci: build release Docker images natively per-arch, merge with a manifest
2026-07-22 15:37:45 +08:00
Kevin Codex 05e434d906 ci: lowercase the ghcr image reference for the digest push
buildx's --output name= does no lowercasing (unlike metadata-action),
so ghcr.io/Gitlawb/node is an invalid reference and the per-arch digest
push would fail outright. Compute the lowercase image once in the
resolve step and reuse it, matching the manifest job.
2026-07-22 15:23:42 +08:00
Kevin Codex 7593540f62 ci: build release Docker images natively per-arch, merge with a manifest
The v0.6.0 release run's multi-arch Docker build wedged for 3+ hours
emulating the arm64 Rust release build under QEMU, and — because the
workflow concurrency group is cancel-in-progress:false — every queued
release run sat blocked behind it.

- docker is now a matrix: amd64 on ubuntu-latest, arm64 on
  ubuntu-24.04-arm — both native, mirroring release-binaries. Each leg
  smoke-tests the image (--version must report the released version)
  and pushes by digest only.
- docker-manifest stitches the digests into the :X.Y.Z, :X.Y and
  :latest tags with buildx imagetools, so a half-finished matrix can
  never publish a partially-tagged image.
- workflow_dispatch grows docker_backfill_tag (and npm_backfill_tag
  becomes optional): each channel backfills independently for an
  already-cut release — needed to publish the missing v0.6.0 image.
2026-07-22 13:13:37 +08:00
Kevin Codex ccc2cf46f2 Merge pull request #233 from Gitlawb/ci/npm-trusted-publishing
ci: switch npm publishing to trusted publishing (OIDC) + manual backfill
2026-07-22 12:28:33 +08:00
Kevin Codex 1fddc81b7c ci: skip release-please on manual npm backfill dispatch
A backfill run must be a narrow npm-only operation. Without an event
guard, workflow_dispatch also ran release-please with contents+PR write
permissions and could create or advance a release PR as a side effect.
npm-publish is unaffected: its !cancelled() condition tolerates the
skipped need, and all other jobs already gate on release_created.
2026-07-22 12:10:25 +08:00
Kevin Codex 62d73c4165 ci: switch npm publishing to trusted publishing (OIDC) + manual backfill
The npm-publish job silently skipped every release since 0.4.x because
NPM_TOKEN was never configured — the guard made the job green while
@gitlawb/gl sat at 0.3.7 on the registry. Replace token auth entirely:

- npm Trusted Publishing: the job's GitHub OIDC identity (id-token: write)
  is exchanged with the registry; no long-lived secret to configure, leak,
  or rotate. Provenance is attested automatically. All five @gitlawb
  packages exist on the registry now, so OIDC no longer has the
  first-publish bootstrap problem that forced token auth in #127.
- Fail loud: the secret guard is gone. If a package's trusted publisher
  isn't configured on npmjs.com, the publish errors visibly instead of
  green-skipping.
- setup-node 24 + npm >=11.5.1 (OIDC exchange support; older npm falls
  back to absent token auth with a confusing error).
- workflow_dispatch backfill: publish an already-cut GitHub release
  (e.g. v0.6.0) to npm by tag — binaries come from the existing release
  assets, so only the npm-publish job runs.
2026-07-22 11:50:44 +08:00
18 changed files with 1128 additions and 110 deletions

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
# issue when a push-to-main run of PR Checks ends red, and closes it when a
# subsequent run on main's tip goes green.
#
# Why: two individually-green PRs can break main in combination when the
# second merges on checks that ran against a stale base (semantic merge
# race — #113 + #145 broke test compilation, found only via #229). A red main
# is otherwise silent, and release-please PRs inherit the breakage.
#
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
# commit is no longer main's tip are ignored (a re-run of an old red run
# after a fix has landed must not cry wolf), and a green run on the tip
# closes any open alert.
name: Main red alert
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
branches: [main]
permissions:
issues: write
contents: read # getBranch tip check
# Serialize runs: concurrent completions would race the label/issue lookups
# into duplicates (or 422 on createLabel). queue: max keeps every pending
# completion instead of letting a later (possibly green-skipped) arrival
# displace a pending red alert under burst merges; it is only incompatible
# with cancel-in-progress: true, which we don't use.
concurrency:
group: main-red-alert
cancel-in-progress: false
queue: max
jobs:
track:
name: Track main CI state
# timed_out (the test job runs 45 min against Postgres) and
# startup_failure (malformed workflow) are red too — only cancelled is
# excluded as a deliberate human abort. success runs are needed to close
# the alert once main recovers.
if: >-
github.event.workflow_run.event == 'push' &&
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reconcile the broken-main issue with main's current state
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const label = 'ci-main-red';
// Freshness gate: only the run for main's current tip may open or
// close anything. Re-runs of superseded commits are events about
// the past, not statements about main's state.
const { data: branch } = await github.rest.repos.getBranch({
...context.repo, branch: 'main',
});
if (branch.commit.sha !== run.head_sha) {
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
return;
}
// listForRepo returns PRs carrying the label too — those must
// never be mistaken for the tracking issue.
const open = (await github.rest.issues.listForRepo({
...context.repo, state: 'open', labels: label, per_page: 100,
})).data.filter(item => !item.pull_request);
if (run.conclusion === 'success') {
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
for (const issue of open) {
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number, body,
});
await github.rest.issues.update({
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
});
}
return;
}
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
const body = [
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
'',
`> ${headline}`,
'',
'Likely a semantic merge race: each merged PR was green on a stale base.',
'Fix main before merging anything else — release-please PRs inherit this breakage.',
'',
"This issue closes automatically when a run on main's tip goes green.",
].join('\n');
try {
await github.rest.issues.getLabel({ ...context.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
...context.repo,
name: label,
color: 'b60205',
description: 'CI is failing on main',
});
}
if (open.length > 0) {
await github.rest.issues.createComment({
...context.repo, issue_number: open[0].number, body,
});
} else {
await github.rest.issues.create({
...context.repo,
title: 'CI is red on main',
body,
labels: [label],
});
}
+64
View File
@@ -2,6 +2,10 @@ name: PR Checks
on:
pull_request:
# Run in the merge queue so a PR is re-validated against the true merged
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
push:
branches:
- main
@@ -212,3 +216,63 @@ jobs:
- name: Smoke test --version
run: docker run --rm gitlawb-node:ci-test --version
test-windows:
name: test (windows, non-blocking)
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
# test coverage, so run their suites here to make Windows-only regressions
# VISIBLE on the PR without gating merge (#228). These two are the client
# CLIs and depend on no database, so no Postgres service is needed (service
# containers do not run on windows-latest regardless). `git` is preinstalled
# on windows-latest, which the real_git_fetch harness relies on.
continue-on-error: true
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
# dependency reds CI whether or not anyone thought to ban it, which a denylist
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: core-deps-purity
- name: Check gitlawb-core dependency allowlist
run: bash scripts/check-gitlawb-core-deps.sh
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Label and guide
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const pr = context.payload.pull_request;
+223 -52
View File
@@ -4,6 +4,20 @@ on:
push:
branches:
- main
# Manual backfill: republish an already-cut release to a channel that
# missed it. Fill in only the channel(s) you want — an empty input leaves
# that channel's jobs skipped. npm reuses the existing release's binary
# assets; docker rebuilds the image from the tag and re-tags the manifest.
workflow_dispatch:
inputs:
npm_backfill_tag:
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
required: false
type: string
docker_backfill_tag:
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
required: false
type: string
concurrency:
group: release-${{ github.ref }}
@@ -14,7 +28,10 @@ permissions: {}
jobs:
release-please:
if: ${{ github.repository == 'Gitlawb/node' }}
# Skipped on manual dispatch: a backfill must never create or advance a
# release PR as a side effect (npm-publish tolerates the skipped need via
# !cancelled(), and every other job gates on release_created == 'true').
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
@@ -31,24 +48,61 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
# workflow concurrency group, blocked every queued release run behind it.
docker:
name: Build & Push Docker Image
name: Build & Push Docker Image (${{ matrix.arch }})
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-please is skipped on dispatch, hence !cancelled()).
if: >-
${{ !cancelled() && (
needs.release-please.outputs.release_created == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
) }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
os: ubuntu-latest
platform: linux/amd64
- arch: arm64
os: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.os }}
permissions:
contents: read
packages: write
steps:
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# ghcr requires a lowercase repository path, and unlike metadata-action,
# buildx's `--output name=` does no lowercasing — a mixed-case owner
# makes the digest push fail with "invalid reference format".
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@@ -59,45 +113,130 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally (smoke)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: ${{ matrix.platform }}
load: true
tags: gitlawb-node:smoke
cache-from: type=gha
cache-from: type=gha,scope=docker-${{ matrix.arch }}
# Native runner on both arches, so the built image can always execute.
# Assert the released version so a stale artifact fails here, not in prod.
- name: Smoke test
run: docker run --rm gitlawb-node:smoke --version
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
out="$(docker run --rm gitlawb-node:smoke --version)"
echo "$out"
grep -qF "$VERSION" <<<"$out" || {
echo "::error::image --version did not report expected version $VERSION (got: $out)"
exit 1
}
- name: Build and push (multi-arch)
# Push by digest only — tags are applied once by docker-manifest so a
# half-finished matrix can never publish a partially-tagged image.
# provenance:false keeps each push a plain single-arch manifest, which is
# what imagetools create expects to merge.
- name: Build and push by digest
id: push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
- name: Export digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: docker-digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-manifest:
name: Publish Docker manifest
needs: [release-please, docker]
if: ${{ !cancelled() && needs.docker.result == 'success' }}
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: docker-digest-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
# ghcr requires a lowercase repository path.
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
MAJOR_MINOR="${VERSION%.*}"
digests=""
for f in /tmp/digests/*; do
digests="$digests $IMAGE@sha256:$(basename "$f")"
done
# shellcheck disable=SC2086
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:$MAJOR_MINOR" \
-t "$IMAGE:latest" \
$digests
docker buildx imagetools inspect "$IMAGE:$VERSION"
- name: Release summary
env:
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
{
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo "## Released $TAG"
echo
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
} >> "$GITHUB_STEP_SUMMARY"
release-binaries:
@@ -248,37 +387,62 @@ jobs:
npm-publish:
name: Publish to npm
needs: [release-please, release-binaries]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
# @gitlawb package must have this repo + workflow (release.yml) configured
# as its trusted publisher on npmjs.com, or publish fails loudly here —
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
# never reached npm.
if: >-
${{ !cancelled() && (
(needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '')
) }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance
id-token: write # npm trusted publishing (OIDC) + provenance
steps:
- name: Guard on secret
id: guard
- name: Resolve release tag
id: rel
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::warning::NPM_TOKEN is not set — skipping npm publish."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
# package has a trusted publisher configured; older npm silently falls
# back to (absent) token auth and fails confusingly.
- name: Set up Node 24 + OIDC-capable npm
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "24"
- name: Ensure npm supports trusted publishing
run: |
set -euo pipefail
npm install -g npm@^11.5.1
npm --version
- name: Lay in release binaries
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
set -euo pipefail
# npm platform package -> Rust target triple (unix only; Windows is not
@@ -305,9 +469,8 @@ jobs:
done
- name: Set versions
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
VERSION: ${{ needs.release-please.outputs.version }}
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
node -e '
@@ -327,13 +490,12 @@ jobs:
'
- name: Publish
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
# No token: npm exchanges this job's GitHub OIDC identity with the
# registry (trusted publishing); provenance is attested automatically.
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
# Skip versions already on the registry so a rerun after a partial publish
# is idempotent instead of erroring on the first existing package.
@@ -429,6 +591,15 @@ jobs:
bin.install "git-remote-gitlawb"
end
def caveats
<<~CAVEATS
oh-my-zsh's git plugin aliases gl='git pull', which shadows this
binary in interactive shells. If \`gl\` prints "fatal: not a git
repository", run:
echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc
CAVEATS
end
test do
assert_match version.to_s, shell_output("#{bin}/gl --version")
end
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.6.0"
".": "0.7.0"
}
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
### Features
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
### Bug Fixes
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
+112
View File
@@ -0,0 +1,112 @@
# Dependency allowlist for the `gitlawb-core` crate.
#
# gitlawb-core is the shared-primitives crate every consumer embeds (the `gl`
# CLI, git-remote-gitlawb, the node daemon). It must stay lean so those
# consumers do not inherit daemon-weight dependencies. This file is the source
# of truth for the crates gitlawb-core's NORMAL (non-dev, non-build) transitive
# dependency closure is allowed to contain. CI (`scripts/check-gitlawb-core-deps.sh`,
# wired into the `core-deps-purity` job in .github/workflows/pr-checks.yml)
# fails if gitlawb-core's tree contains any crate not listed here.
#
# The allowlist is exhaustive-by-construction: a NEW dependency reds CI whether
# or not anyone thought to ban it, which a denylist cannot do.
#
# Measured in-workspace, so feature unification across the workspace can, in
# rare cases, surface a crate here that gitlawb-core would not pull standalone.
# An unexpected new entry is a prompt to check what pulled it in, not noise to
# rubber-stamp.
#
# Regenerate (from repo root, after an INTENTIONAL dependency change):
# cargo tree -p gitlawb-core --edges normal --prefix none \
# | sed -E 's/ v[0-9].*$//' \
# | grep -v '^gitlawb-core$' \
# | sort -u
# then paste the result below this header block. (The checker snapshots and
# restores Cargo.lock; a manual regen may refresh it — `git checkout Cargo.lock`
# afterward if you did not intend that.)
#
# Lines starting with `#` and blank lines are ignored by the checker.
aead
anyhow
base-x
base256emoji
base64
base64ct
block-buffer
cfg-if
chacha20
chacha20poly1305
chrono
cid
cipher
const-oid
const-str
core2
cpufeatures
crypto-common
crypto_box
crypto_secretbox
curve25519-dalek
curve25519-dalek-derive
data-encoding
data-encoding-macro
data-encoding-macro-internal
der
digest
ed25519
ed25519-dalek
equivalent
generic-array
getrandom
hashbrown
hex
iana-time-zone
indexmap
inout
itoa
libc
match-lookup
memchr
multibase
multihash
multihash-codetable
multihash-derive
multihash-derive-impl
num-traits
opaque-debug
pem-rfc7468
pkcs8
poly1305
ppv-lite86
proc-macro-crate
proc-macro2
quote
rand
rand_chacha
rand_core
salsa20
serde
serde_core
serde_derive
serde_json
sha2
signature
spki
subtle
syn
synstructure
thiserror
thiserror-impl
toml_datetime
toml_edit
toml_parser
typenum
unicode-ident
universal-hash
unsigned-varint
uuid
winnow
zerocopy
zeroize
zeroize_derive
zmij
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "git-remote-gitlawb"
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
version = "0.6.0" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version = "0.6.0" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.6.0" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version = "0.6.0" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+2 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gl"
description = "The gitlawb CLI — identity management, node control, MCP server"
version = "0.6.0" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
icaptcha-client = { path = "../icaptcha-client" }
base64 = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+216 -27
View File
@@ -41,6 +41,16 @@ pub enum CertCmd {
node: String,
#[arg(long)]
dir: Option<PathBuf>,
/// Exit non-zero unless the Ed25519 signature verifies AND the
/// issuing node matches the queried node (or --expect-node)
#[arg(long)]
verify: bool,
/// Expected issuing node DID for --verify. A valid signature alone
/// only proves the cert is internally consistent — signed by whatever
/// key it names — so --verify also anchors the issuer to a DID you
/// trust: this value when given, else the queried node's DID.
#[arg(long, requires = "verify")]
expect_node: Option<String>,
},
}
@@ -52,7 +62,9 @@ pub async fn run(args: CertArgs) -> Result<()> {
id,
node,
dir,
} => cmd_show(repo, id, node, dir).await,
verify,
expect_node,
} => cmd_show(repo, id, node, dir, verify, expect_node).await,
}
}
@@ -114,7 +126,14 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
Ok(())
}
async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_show(
repo: String,
id: String,
node: String,
dir: Option<PathBuf>,
require_valid: bool,
expect_node: Option<String>,
) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = signed_client(&node, dir.as_deref());
@@ -148,39 +167,124 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
println!(" Signature: {signature}");
println!();
// Reconstruct the signing payload and verify
// Fetch the node's current public key to verify
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let current_node_did = info["did"].as_str().unwrap_or("");
// Verify the Ed25519 signature: rebuild the exact canonical payload the
// node signed (see gitlawb-node/src/cert.rs::issue_ref_certificate) and
// check it against the public key embedded in the certificate's node DID.
// This proves the cert is internally authentic — signed by the key it
// names; the node-DID comparison below covers *which* node that is.
let repo_id = cert["repo_id"].as_str().unwrap_or("");
let verdict = verify_signature(
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
);
println!("Signature verification:");
println!(" Signing payload would be:");
println!(" {{\"repo_id\": ..., \"ref\": \"{ref_name}\", \"old\": \"{old_sha}\",");
println!(" \"new\": \"{new_sha}\", \"pusher\": \"{pusher}\",");
println!(" \"node\": \"{node_did}\", \"ts\": \"{issued_at}\"}}");
println!();
if current_node_did == node_did {
println!(" Node DID matches current node. Signature is an Ed25519/base64url value.");
println!(" To verify offline, use the node's Ed25519 public key derived from:");
println!(" did:key → {node_did}");
} else {
println!(" WARNING: Certificate node DID ({node_did}) does not match");
println!(" current node DID ({current_node_did}).");
println!(" This certificate was issued by a different node.");
match &verdict {
Ok(()) => {
println!(
" VALID — Ed25519 signature verified against the key the certificate names ({node_did})"
);
}
Err(reason) => {
println!(" INVALID — {reason}");
}
}
println!();
println!(" Signature (base64url): {signature}");
// Contextual only — the verdict above stands on its own, so a node-info
// hiccup here must not turn a successfully displayed certificate into an
// error exit.
let current_node_did = match client.get("/").await {
Ok(resp) => resp
.json::<Value>()
.await
.ok()
.and_then(|info| info["did"].as_str().map(str::to_string)),
Err(_) => None,
};
match current_node_did.as_deref() {
Some(current) if current == node_did => {
println!(" Issuing node DID matches the node being queried.");
}
Some(current) => {
println!(" WARNING: Certificate node DID ({node_did}) does not match");
println!(" current node DID ({current}).");
println!(" This certificate was issued by a different node.");
}
None => {
println!(" NOTE: could not fetch current node info — skipping node-DID comparison.");
}
}
if require_valid {
if let Err(reason) = verdict {
anyhow::bail!("certificate signature did not verify: {reason}");
}
// A valid signature proves internal consistency only: the payload was
// signed by whatever key the certificate itself names. A hostile
// source can mint a keypair, put its DID in node_did, and self-sign.
// --verify therefore also anchors the issuer to a trusted DID:
// --expect-node when given, else the DID of the node being queried.
let expected = expect_node.as_deref().or(current_node_did.as_deref());
match expected {
Some(expected) if expected == node_did => {}
Some(expected) => anyhow::bail!(
"certificate is signed by {node_did}, but the expected issuer is {expected} — \
a valid signature alone proves internal consistency, not a trusted issuer"
),
None => anyhow::bail!(
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
),
}
}
Ok(())
}
/// Rebuild the node's canonical signing payload (field order must match
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
/// certificate's Ed25519 signature against the key embedded in `node_did`.
#[allow(clippy::too_many_arguments)]
fn verify_signature(
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher: &str,
node_did: &str,
issued_at: &str,
signature_b64: &str,
) -> std::result::Result<(), String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use std::str::FromStr;
let payload = serde_json::json!({
"repo_id": repo_id,
"ref": ref_name,
"old": old_sha,
"new": new_sha,
"pusher": pusher,
"node": node_did,
"ts": issued_at,
});
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?;
let did =
gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?;
let verifying_key = did
.to_verifying_key()
.map_err(|e| format!("cannot derive a public key from {node_did}: {e}"))?;
let sig_vec = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|e| format!("signature is not valid base64url: {e}"))?;
let sig_bytes: [u8; 64] = sig_vec
.try_into()
.map_err(|_| "signature is not 64 bytes".to_string())?;
gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes)
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
}
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
if id.len() >= 36 {
return Ok(id.to_string());
@@ -209,3 +313,88 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
_ => anyhow::bail!("certificate prefix {id} matches multiple certificates"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Pins gl's payload reconstruction to the frozen canonical byte form the
/// node signs (default serde_json maps = alphabetically ordered keys). If
/// serialization drifts — a field added, or a preserve_order feature
/// landing anywhere in the workspace (feature unification flips every
/// crate at once) — this literal stops matching and the test fails,
/// instead of every real certificate silently rendering INVALID.
#[test]
fn payload_serialization_matches_frozen_canonical_form() {
let payload = serde_json::json!({
"repo_id": "repo-1",
"ref": "refs/heads/main",
"old": "oldsha",
"new": "newsha",
"pusher": "did:key:z6MkPusher",
"node": "did:key:z6MkNode",
"ts": "2026-07-22T00:00:00+00:00",
});
let frozen = concat!(
r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#,
r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#,
r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#,
);
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
}
/// Signing exactly as the node does must round-trip through
/// verify_signature; any field tampering must fail it.
#[test]
fn verify_signature_round_trip_and_tamper() {
let kp = gitlawb_core::identity::Keypair::generate();
let node_did = kp.did().as_str().to_string();
let payload = serde_json::json!({
"repo_id": "repo-1",
"ref": "refs/heads/main",
"old": "0".repeat(40),
"new": "a".repeat(40),
"pusher": "did:key:z6MkPusher",
"node": node_did,
"ts": "2026-07-22T00:00:00+00:00",
});
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
let ok = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
&sig,
);
assert!(ok.is_ok(), "expected valid signature, got: {ok:?}");
let tampered = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"b".repeat(40), // new_sha changed after signing
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
&sig,
);
assert!(tampered.is_err(), "tampered payload must not verify");
let garbage = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
"not-base64url!!!",
);
assert!(garbage.is_err(), "malformed signature must not verify");
}
}
+177 -7
View File
@@ -142,18 +142,20 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
match std::env::var("GITLAWB_NODE") {
Ok(v) if !v.is_empty() && !v.contains("127.0.0.1") && !v.contains("localhost") => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => {
checks.push(Check::fail(
// A loopback host is a legitimate setup (self-hosted node, dev
// harness) — the connectivity check below fails loudly if it is not
// actually reachable, so don't red-flag the configuration itself.
Ok(v) if is_loopback_url(&v) => {
checks.push(Check::pass(
"GITLAWB_NODE",
format!(
"set to local address ({v}) — git push/clone will fail against remote nodes"
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
),
"export GITLAWB_NODE=https://node.gitlawb.com",
));
}
Ok(v) if !v.is_empty() => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
_ => {
checks.push(Check::fail(
"GITLAWB_NODE",
@@ -243,6 +245,15 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
));
}
// ── 5b. shell alias shadowing ─────────────────────────────────────────
// oh-my-zsh's default `git` plugin aliases gl='git pull', which silently
// shadows this binary in every interactive zsh — the classic symptom is
// `gl` printing "fatal: not a git repository". Aliases beat PATH, so no
// install method can fix it; the user's rc file has to unalias.
if let Some(check) = check_shell_alias_shadowing(dirs::home_dir()) {
checks.push(check);
}
// ── 6. git ────────────────────────────────────────────────────────────
match std::process::Command::new("git")
.arg("--version")
@@ -309,6 +320,83 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
/// Check if a binary name exists anywhere on PATH.
/// True when the rc file contains a real `unalias` command naming `gl` —
/// not a comment, and not a longer word like `unalias global`. Ordering
/// relative to oh-my-zsh loading is not modeled (an unalias placed before the
/// plugin loads would be ineffective); acceptable slack for a warning check.
fn rc_unaliases_gl(rc: &str) -> bool {
rc.lines().any(|line| {
let line = line.trim_start();
if line.starts_with('#') {
return false;
}
line.split([';', '&', '|']).any(|cmd| {
// A trailing comment must not count: `unalias foo # gl`
let cmd = cmd.split('#').next().unwrap_or("");
let mut tokens = cmd.split_whitespace();
tokens.next() == Some("unalias") && tokens.any(|t| t == "gl")
})
})
}
/// True when the URL's host is exactly a loopback address. Substring checks
/// misclassify hosts like `localhost.example` or URLs with "localhost" in the
/// path, so parse and compare the actual host.
fn is_loopback_url(value: &str) -> bool {
reqwest::Url::parse(value)
.ok()
.and_then(|u| {
u.host_str()
.map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]" || h == "::1")
})
.unwrap_or(false)
}
/// Detect interactive-shell setups where an alias shadows `gl` (aliases beat
/// PATH, so no install method can fix this). Best-effort heuristic over
/// ~/.zshrc: an explicit `alias gl=`, or oh-my-zsh's `git` plugin (which
/// ships gl='git pull'). Returns None when nothing suspicious is found or the
/// rc file already contains an `unalias gl`.
fn check_shell_alias_shadowing(home: Option<PathBuf>) -> Option<Check> {
let home = home?;
let rc = std::fs::read_to_string(home.join(".zshrc")).ok()?;
if rc_unaliases_gl(&rc) {
return None;
}
let explicit_alias = rc.lines().any(|l| l.trim_start().starts_with("alias gl="));
// Single-line `plugins=(git ...)` is the overwhelmingly common form; a
// multi-line plugins array slips past this heuristic, which is acceptable
// for a warning-level check.
let omz_git_plugin = home.join(".oh-my-zsh/plugins/git").exists()
&& rc.lines().any(|l| {
let l = l.trim_start();
l.starts_with("plugins=")
&& l.trim_start_matches("plugins=(")
.trim_end_matches(')')
.split_whitespace()
.any(|p| p == "git")
});
if explicit_alias || omz_git_plugin {
let source = if explicit_alias {
"~/.zshrc defines `alias gl=`"
} else {
"oh-my-zsh's git plugin aliases gl='git pull'"
};
Some(Check::warn(
"shell alias",
format!(
"{source} — interactive shells run that instead of this binary \
(symptom: `gl` prints \"fatal: not a git repository\")"
),
"echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc (must come after oh-my-zsh loads)",
))
} else {
None
}
}
fn which_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
@@ -393,6 +481,88 @@ fn is_newer(latest: &str, current: &str) -> bool {
mod tests {
use super::*;
fn fake_home(zshrc: Option<&str>, with_omz_git: bool) -> tempfile::TempDir {
let home = tempfile::TempDir::new().unwrap();
if let Some(rc) = zshrc {
std::fs::write(home.path().join(".zshrc"), rc).unwrap();
}
if with_omz_git {
std::fs::create_dir_all(home.path().join(".oh-my-zsh/plugins/git")).unwrap();
}
home
}
#[test]
fn alias_shadowing_flags_omz_git_plugin() {
let home = fake_home(Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\n"), true);
let check = check_shell_alias_shadowing(Some(home.path().to_path_buf()));
assert!(check.is_some(), "omz git plugin without unalias must warn");
}
#[test]
fn alias_shadowing_flags_explicit_alias() {
let home = fake_home(Some("alias gl='git pull'\n"), false);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some());
}
#[test]
fn alias_shadowing_silent_when_unaliased() {
let home = fake_home(
Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\nunalias gl 2>/dev/null\n"),
true,
);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
}
#[test]
fn alias_shadowing_ignores_fake_unaliases() {
// None of these actually free `gl` — the warning must survive them.
for rc in [
"plugins=(git)\n# unalias gl\n", // commented out
"plugins=(git)\nunalias global\n", // longer word
"plugins=(git)\nunalias foo # gl\n", // gl only in a comment
"plugins=(git)\necho unalias-gl-later\n", // not an unalias command
] {
let home = fake_home(Some(rc), true);
assert!(
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some(),
"must still warn for rc: {rc:?}"
);
}
// Real unalias forms that do free it — all must silence the warning.
for rc in [
"plugins=(git)\nunalias gl\n",
"plugins=(git)\nunalias gl 2>/dev/null\n",
"plugins=(git)\ntrue; unalias gl\n",
"plugins=(git)\nunalias glog gl gst\n",
] {
let home = fake_home(Some(rc), true);
assert!(
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none(),
"must be silent for rc: {rc:?}"
);
}
}
#[test]
fn loopback_url_detection_is_host_exact() {
assert!(is_loopback_url("http://127.0.0.1:7545"));
assert!(is_loopback_url("http://localhost:7545"));
assert!(is_loopback_url("http://[::1]:7545"));
assert!(!is_loopback_url("https://localhost.example"));
assert!(!is_loopback_url("https://node.gitlawb.com/localhost"));
assert!(!is_loopback_url("not a url"));
}
#[test]
fn alias_shadowing_silent_without_signals() {
let home = fake_home(Some("plugins=(z)\nexport EDITOR=vim\n"), false);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
let no_rc = fake_home(None, false);
assert!(check_shell_alias_shadowing(Some(no_rc.path().to_path_buf())).is_none());
assert!(check_shell_alias_shadowing(None).is_none());
}
#[test]
fn test_is_newer_minor_bump() {
assert!(is_newer("0.2.0", "0.1.0"));
+52 -4
View File
@@ -38,13 +38,23 @@ pub async fn run(args: InitArgs) -> Result<()> {
let git_dir = cwd.join(".git");
if !git_dir.exists() {
println!("Initializing git repository...");
// -b main: the push flow targets `main`; without it a fresh repo uses
// the user's init.defaultBranch (often `master`). Older git (<2.28)
// lacks the flag, so fall back to a plain init.
let status = std::process::Command::new("git")
.args(["init"])
.args(["init", "-b", "main"])
.current_dir(&cwd)
.status()
.context("failed to run git init")?;
if !status.success() {
anyhow::bail!("git init failed");
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&cwd)
.status()
.context("failed to run git init")?;
if !status.success() {
anyhow::bail!("git init failed");
}
}
} else {
println!("Git repository detected.");
@@ -165,9 +175,47 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
}
// The hint must match the repo's actual state: with no commits yet, a bare
// `git push` fails with "src refspec ... does not match any" — exactly the
// trap a zero-to-push command must not set.
let has_commits = std::process::Command::new("git")
.args(["rev-parse", "--verify", "HEAD"])
.current_dir(&cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
// symbolic-ref succeeds on a branch (including an unborn one) and fails
// on a detached HEAD — where guessing "main" could push the wrong ref.
let branch = std::process::Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&cwd)
.stderr(std::process::Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
println!();
println!("Ready! Push with:");
println!(" git push gitlawb main");
match (branch, has_commits) {
(Some(branch), true) => {
println!("Ready! Push with:");
println!(" git push gitlawb {branch}");
}
(Some(branch), false) => {
println!("Ready! Nothing is committed yet — commit, then push:");
println!(" git add -A && git commit -m \"initial commit\"");
println!(" git push gitlawb {branch}");
}
(None, _) => {
println!("Ready! HEAD is detached — create or switch to a branch, then push:");
println!(" git switch -c main");
println!(" git push gitlawb main");
}
}
Ok(())
}
+52 -13
View File
@@ -59,7 +59,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
println!(" repo {short_did}/{repo}");
}
None => {
println!(" repo (not in a gitlawb repo — no gitlawb:// origin)");
println!(" repo (not in a gitlawb repo — no gitlawb:// remote)");
}
}
@@ -143,29 +143,68 @@ fn trust_bar(score: f64) -> String {
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}
/// Parse `gitlawb://<did>/<repo>` from the git origin remote.
/// Find a `gitlawb://<did>/<repo>` remote in the current repo.
///
/// Checks `origin` first (clones), then `gitlawb` (the name `gl init` adds),
/// then any other remote with a gitlawb:// URL.
fn detect_gitlawb_remote() -> Option<(String, String)> {
let out = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.args(["remote"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() {
return None;
let names: Vec<String> = String::from_utf8(out.stdout)
.ok()?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
let preferred = ["origin", "gitlawb"];
let ordered = preferred
.iter()
.filter(|p| names.iter().any(|n| n == *p))
.map(|p| p.to_string())
.chain(
names
.iter()
.filter(|n| !preferred.contains(&n.as_str()))
.cloned(),
);
for name in ordered {
// A remote can carry several URLs, and the gitlawb:// one may be a
// push URL behind an https fetch URL — check every fetch and push URL.
for extra in [&["--all"][..], &["--push", "--all"][..]] {
let mut args = vec!["remote", "get-url"];
args.extend_from_slice(extra);
args.push(&name);
let Ok(out) = std::process::Command::new("git")
.args(&args)
.stderr(std::process::Stdio::null())
.output()
else {
continue;
};
if !out.status.success() {
continue;
}
let Ok(urls) = String::from_utf8(out.stdout) else {
continue;
};
if let Some(parsed) = urls.lines().find_map(parse_gitlawb_url) {
return Some(parsed);
}
}
}
Some((did, repo))
None
}
/// Parse a gitlawb:// URL string into (did, repo) — extracted for testing.
#[cfg(test)]
/// Parse a gitlawb:// URL string into (did, repo).
fn parse_gitlawb_url(url: &str) -> Option<(String, String)> {
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
+14
View File
@@ -193,5 +193,19 @@ else
echo " gl quickstart"
fi
# oh-my-zsh's default git plugin aliases gl='git pull', which silently shadows
# the gl binary in every interactive zsh (aliases beat PATH). Detect the common
# setup and say so now, at install time, instead of letting the first `gl`
# print git's baffling "fatal: not a git repository".
if [ -f "$HOME/.zshrc" ] && [ -d "$HOME/.oh-my-zsh/plugins/git" ] \
&& grep -Eq '^[[:space:]]*plugins=\(([^)]*[[:space:]])?git([[:space:]][^)]*)?\)' "$HOME/.zshrc" \
&& ! grep -Eq '^([^#]*[[:space:];&|])?unalias[[:space:]]+([^;&|#]*[[:space:]])?gl([[:space:]]|$)' "$HOME/.zshrc"; then
echo ""
echo "NOTE: oh-my-zsh's git plugin aliases gl='git pull', which will shadow"
echo "the gl command in interactive shells. To use gl by name, run:"
echo ""
echo " echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc"
fi
echo ""
echo "Docs: https://docs.gitlawb.com"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# Dependency-purity gate for the `gitlawb-core` crate.
#
# gitlawb-core is embedded by every consumer (the gl CLI, git-remote-gitlawb,
# the node daemon), so it must stay lean. This script recomputes gitlawb-core's
# NORMAL (non-dev, non-build) transitive dependency set and fails if it contains
# any crate not present in ci/gitlawb-core-allowed-deps.txt.
#
# Hard-fail direction: a crate present now but NOT allowlisted. That is the case
# the gate exists to catch (core silently gaining a heavy dependency).
# Informational only: an allowlisted crate no longer present (stale entry) — a
# legitimate dependency removal should not red CI, so it is reported, not failed.
#
# Runnable from anywhere in the repo.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
ALLOW="$ROOT/ci/gitlawb-core-allowed-deps.txt"
if [ ! -f "$ALLOW" ]; then
echo "ERROR: allowlist not found at $ALLOW" >&2
exit 1
fi
# `cargo tree` resolves like the build/test jobs (no --locked): the committed
# Cargo.lock can lag the manifests, and --locked would red this gate for
# lock-staleness reasons unrelated to gitlawb-core's dependencies. Resolving can
# refresh Cargo.lock as a side effect, so snapshot and restore it — this check
# must never leave the working tree dirty when run locally.
lock_backup="$(mktemp)"
cp "$ROOT/Cargo.lock" "$lock_backup"
restore_lock() { cp "$lock_backup" "$ROOT/Cargo.lock"; rm -f "$lock_backup"; }
trap restore_lock EXIT
# Current normal-dependency closure of gitlawb-core, one crate name per line.
# Must match the regen command documented in the allowlist header exactly.
current="$(
cargo tree -p gitlawb-core --edges normal --prefix none --manifest-path "$ROOT/Cargo.toml" \
| sed -E 's/ v[0-9].*$//' \
| grep -v '^gitlawb-core$' \
| sort -u
)"
# Allowlist with comments and blank lines stripped.
allowed="$(grep -vE '^[[:space:]]*(#|$)' "$ALLOW" | sort -u)"
# comm needs sorted input; both sides are sorted above.
offenders="$(comm -23 <(printf '%s\n' "$current") <(printf '%s\n' "$allowed"))"
stale="$(comm -13 <(printf '%s\n' "$current") <(printf '%s\n' "$allowed"))"
if [ -n "$stale" ]; then
echo "NOTE: allowlisted crates no longer in gitlawb-core's dependency tree"
echo " (safe to prune from ci/gitlawb-core-allowed-deps.txt):"
printf ' %s\n' $stale
echo
fi
if [ -n "$offenders" ]; then
{
echo "ERROR: gitlawb-core gained dependencies not on the allowlist:"
printf ' %s\n' $offenders
echo
echo "gitlawb-core must stay embeddable and lean. If a new dependency is"
echo "intentional, add it to ci/gitlawb-core-allowed-deps.txt (regen command"
echo "is in that file's header). Otherwise, drop the dependency."
} >&2
exit 1
fi
echo "gitlawb-core dependency purity: OK ($(printf '%s\n' "$current" | wc -l | tr -d ' ') normal deps, all allowlisted)."