diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d08c163e..851f0385 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -6,6 +6,13 @@ on: # result before it lands. Guards against semantic merge races where two # individually-green PRs break main in combination (#113 + #145 → #229). merge_group: + # Release-branch heads are created and pushed by GITHUB_TOKEN (release-please + # and the lock-sync job), and GitHub's recursion guard means those pushes + # trigger no workflow runs. workflow_dispatch is the documented carve-out that + # GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at + # the release branch to attach check runs to its head. Read-only workflow + # (contents: read), so the dispatch surface adds no privilege. + workflow_dispatch: push: branches: - main @@ -46,7 +53,7 @@ jobs: run: cargo fmt --all -- --check - name: cargo clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings test: name: test (${{ matrix.toolchain }}) @@ -94,7 +101,7 @@ jobs: - name: cargo test env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test - run: cargo test --workspace + run: cargo test --locked --workspace build-release: name: build --release @@ -117,7 +124,7 @@ jobs: key: release - name: cargo build --release - run: cargo build --release --workspace + run: cargo build --locked --release --workspace audit: name: cargo audit @@ -199,7 +206,7 @@ jobs: key: msrv - name: cargo check on MSRV - run: cargo check --workspace --all-targets + run: cargo check --locked --workspace --all-targets docker-build: name: Docker build smoke test @@ -246,7 +253,7 @@ jobs: key: windows - name: cargo test (shipped Windows crates) - run: cargo test -p gl -p git-remote-gitlawb + run: cargo test --locked -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbc7f852..750737c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,7 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} + pr: ${{ steps.release.outputs.pr }} steps: - name: Run release-please id: release @@ -48,6 +49,143 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + # The release PR bumps the workspace crate versions in the Cargo.tomls but + # release-please cannot update Cargo.lock, so without this job every release + # merge ships a tag whose lockfile disagrees with its manifests (a --locked + # build from the tag fails, and pr-checks builds --locked). Sync the lock on + # the release branch so the tagged tree is internally consistent. + # + # Runs on every push-triggered release run, not only when release-please + # reports the PR: the `pr` output is set only on runs that CREATE or UPDATE + # the release PR, so gating on it would leave an already-open release PR + # (opened before this job existed, or untouched by a non-releasable push) + # permanently without the sync and without check runs. The resolve step + # discovers any open release-please PR itself and no-ops when there is none. + sync-release-lock: + name: Sync Cargo.lock on the release PR + needs: release-please + if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + # actions: write lets the final step dispatch pr-checks on the release + # branch. Note this grant is repo-wide dispatch rights (GitHub cannot + # scope it to one workflow); acceptable here because reaching this job's + # steps already requires write to main. Needed because every push to the + # release branch (release-please's and the lock sync below) is made with + # GITHUB_TOKEN, which triggers no workflow runs, so without an explicit + # dispatch the release PR's head carries no check runs and the --locked + # gate never validates it. + actions: write + steps: + # Prefer the fresh `pr` output when this run just created/updated the + # release PR; otherwise fall back to querying for an open release-please + # branch (prefix match: the branch carries `--components--` suffixes). + - name: Resolve the open release PR branch + id: resolve + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }} + run: | + branch="$BRANCH_FROM_OUTPUT" + if [ -z "$branch" ]; then + # The branch name alone is not a trust signal. `gh pr list` includes + # fork PRs, and a fork's headRefName is bare (no owner prefix), so a + # prefix match by itself lets any fork nominate the branch this job + # checks out, commits to, and dispatches checks on. Require the head + # repo to be THIS repository and the author to be the release-please + # app, so neither a fork nor a same-repo human branch named + # `release-please--*` can be selected. Then take the highest PR + # number: `[0]` depended on gh's default ordering, which is not a + # documented guarantee, and picking a stable one keeps a second + # (component) release PR from making the choice flap between runs. + branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \ + --json headRefName,headRepositoryOwner,number,author \ + --jq '[ .[] + | select(.headRepositoryOwner.login == env.OWNER) + | select(.author.login == "app/github-actions") + | select(.headRefName | startswith("release-please--")) + ] | max_by(.number).headRefName // empty') + fi + if [ -z "$branch" ]; then + echo "no open release PR; nothing to sync" + fi + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + - name: Checkout release branch + if: ${{ steps.resolve.outputs.branch != '' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ steps.resolve.outputs.branch }} + + # Resolve on a declared toolchain instead of whatever Rust the runner + # image happens to ship, so the lock this job writes comes off the same + # stable channel the other cargo jobs use. Not a claim of identical + # resolution: `stable` still moves between runs, and the MSRV gate + # checks this same lock on 1.91 (pr-checks.yml). It only removes the + # runner image as an undeclared input. + - name: Install Rust toolchain + if: ${{ steps.resolve.outputs.branch != '' }} + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: stable + + - name: Sync workspace crate versions into Cargo.lock + if: ${{ steps.resolve.outputs.branch != '' }} + run: cargo update --workspace + + - name: Commit and push when the lock changed + if: ${{ steps.resolve.outputs.branch != '' }} + run: | + if git diff --quiet Cargo.lock; then + echo "Cargo.lock already in sync" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Cargo.lock + git commit -m "chore: sync Cargo.lock with the release version bump" + git push + + # Every push to the release branch is made with GITHUB_TOKEN (release- + # please's own pushes and the lock sync above), and events created by + # GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented + # exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release + # branch to attach check runs to its current head. Unconditional (also on + # the already-in-sync path) because the branch's ORIGINAL head has the + # same no-runs problem. Loud on failure by design: a silent fallback + # would recreate the checkless-head problem, and a job rerun is safe + # (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and + # dispatches nothing. Runs after the push ack, so the dispatched ref tip + # is the sync commit. Merge-queue note: this validates the branch tip; + # the enforced merge queue's merge_group run still re-validates the true + # merged result before landing. + - name: Dispatch PR checks on the release branch + if: ${{ steps.resolve.outputs.branch != '' }} + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.resolve.outputs.branch }} + run: | + # Every dispatch failure fails the job, including the 422 a release + # branch cut before pr-checks gained its workflow_dispatch trigger + # returns ("does not have a workflow_dispatch trigger", reported + # against the dispatched ref's copy of the file). An earlier revision + # tolerated exactly that 422 on the theory that release-please would + # rewrite the branch and heal it, but a rewrite only happens when a + # later releasable commit lands: an open release PR nobody touches is + # never rewritten. Tolerating it therefore left the lock-sync commit + # this job had just pushed sitting on the release head with no + # --locked validation, which is the precise state this workflow exists + # to prevent. The tolerance is also no longer needed: once this lands, + # every release branch is cut from a main that already carries the + # trigger, so the only branch that could 422 is one open at merge time + # (there is none), and a job rerun after a manual rebase is safe (the + # sync no-ops, the dispatch retries). + gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \ + -f "ref=$BRANCH" + # 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 @@ -308,7 +446,7 @@ jobs: set -euo pipefail args="" for b in $BINS; do args="$args -p $b"; done - cargo build --release --target ${{ matrix.target }} $args + cargo build --release --locked --target ${{ matrix.target }} $args # Run each packaged binary's --version so a broken release artifact fails the # build instead of shipping. Every target builds on a matching-arch runner, diff --git a/Cargo.lock b/Cargo.lock index 5dd2903d..75cb5550 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,7 +3300,7 @@ dependencies = [ [[package]] name = "git-remote-gitlawb" -version = "0.5.1" +version = "0.7.0" dependencies = [ "anyhow", "gitlawb-core", @@ -3312,7 +3312,7 @@ dependencies = [ [[package]] name = "gitlawb-attest" -version = "0.5.1" +version = "0.7.0" dependencies = [ "base64", "ed25519-dalek", @@ -3329,7 +3329,7 @@ dependencies = [ [[package]] name = "gitlawb-core" -version = "0.5.1" +version = "0.7.0" dependencies = [ "anyhow", "base64", @@ -3356,7 +3356,7 @@ dependencies = [ [[package]] name = "gitlawb-node" -version = "0.5.1" +version = "0.7.0" dependencies = [ "alloy", "anyhow", @@ -3412,10 +3412,11 @@ dependencies = [ [[package]] name = "gl" -version = "0.5.1" +version = "0.7.0" dependencies = [ "alloy", "anyhow", + "base64", "chrono", "clap", "dirs", diff --git a/Dockerfile b/Dockerfile index 55ef0c15..e5bbed8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crate echo 'fn main() {}' > crates/gl/src/main.rs && \ echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \ echo '' > crates/gitlawb-core/src/lib.rs && \ - cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true + cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb || true # Now copy real sources and build for real. # Force-bump mtimes so cargo's fingerprint check rebuilds — without this, @@ -34,7 +34,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \ rm -rf target/release/.fingerprint/gitlawb-node-* \ target/release/.fingerprint/gl-* \ target/release/.fingerprint/git-remote-gitlawb-* && \ - cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \ + cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \ strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb # ── Runtime stage ─────────────────────────────────────────────────────────── diff --git a/Dockerfile.bins b/Dockerfile.bins index c2110b17..924c402e 100644 --- a/Dockerfile.bins +++ b/Dockerfile.bins @@ -23,7 +23,7 @@ COPY crates/ crates/ # Configure cross-linker for aarch64 ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc -RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb +RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb FROM debian:bookworm-slim ARG TARGET diff --git a/scripts/build-bins.sh b/scripts/build-bins.sh index 06911e17..a8783b5e 100755 --- a/scripts/build-bins.sh +++ b/scripts/build-bins.sh @@ -46,7 +46,7 @@ build_darwin_arm64() { echo "==> Building darwin-arm64 (native cargo)..." cd "$ROOT" export PATH="$HOME/.cargo/bin:$PATH" - cargo build --release -p gl -p git-remote-gitlawb + cargo build --release --locked -p gl -p git-remote-gitlawb cp target/release/gl "$OUT/gl-darwin-arm64" cp target/release/git-remote-gitlawb "$OUT/git-remote-gitlawb-darwin-arm64" chmod +x "$OUT/gl-darwin-arm64" "$OUT/git-remote-gitlawb-darwin-arm64" diff --git a/scripts/check-gitlawb-core-deps.sh b/scripts/check-gitlawb-core-deps.sh index e9c114ca..5580dd6a 100755 --- a/scripts/check-gitlawb-core-deps.sh +++ b/scripts/check-gitlawb-core-deps.sh @@ -23,11 +23,15 @@ if [ ! -f "$ALLOW" ]; then 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. +# `cargo tree` resolves WITHOUT --locked on purpose, unlike the blocking cargo +# jobs in pr-checks. This gate answers one question, whether gitlawb-core's +# dependency closure gained something unallowlisted, and a stale Cargo.lock +# would red it for a reason that has nothing to do with that closure. Lock +# staleness is already caught by the --locked cargo jobs, so it does not need a +# second gate here. +# +# 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"; }