mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9da517fde | ||
|
|
9ca94c4d47 | ||
|
|
c0ad500b55 | ||
|
|
e9d6aadd58 | ||
|
|
b484a24955 | ||
|
|
32006b26a2 | ||
|
|
b75aa2f1da | ||
|
|
c681af70db | ||
|
|
3fa38dce4e | ||
|
|
c71bba5cd1 | ||
|
|
e3df51d76c | ||
|
|
0765c42fef | ||
|
|
94c61f7942 | ||
|
|
b0a2ed8308 | ||
|
|
cb42a4830d | ||
|
|
2d88937fba | ||
|
|
e14c2d3f3f | ||
|
|
4465beb0c2 | ||
|
|
82efd60229 | ||
|
|
e8f193a5df | ||
|
|
07d76ba556 | ||
|
|
05e434d906 | ||
|
|
7593540f62 | ||
|
|
ccc2cf46f2 | ||
|
|
1fddc81b7c | ||
|
|
62d73c4165 |
No files matched your search
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "0.6.0"
|
||||
".": "0.7.0"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,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,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,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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
Executable
+71
@@ -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)."
|
||||
Reference in new issue
Block a user