From ffde388688b9094406fa464ad868af8635eb69f2 Mon Sep 17 00:00:00 2001 From: Bernard <63512176+BernardJen@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:22:10 +0200 Subject: [PATCH 1/5] feat: private crate registry via JFrog Artifactory (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen releases in, `publish = false` and no crates.io presence meant no colleague could depend on this crate. This wires it to Lab271's Artifactory tenant as a private Cargo registry, opt-in. Cargo has no per-project registry config that lives outside the working tree — there is no `jf cargo-config` and no `jf cargo` command at all, so `.cargo/config.toml` and `CARGO_*` env vars are the only mechanisms on offer. That matters here because this repository is public and the Artifactory Cargo index is not anonymously readable: $ curl -o /dev/null -w '%{http_code}' \ .../api/cargo/lab-cargo-dev/index/config.json 401 The index also advertises `"auth-required": true`, which Cargo honours for crate downloads as well as index reads. So a committed `.cargo/config.toml` carrying a source replacement would 401 every anonymous `cargo build` — every outside contributor, every fork — and would do it at dependency-fetch time, with an authentication error that gives the contributor no hint that the fix is to delete a file they did not add. The npm pilot of the same evaluation rejected a committed `.npmrc` for exactly this reason. Hence: commit `.cargo/config.toml.example`, gitignore `.cargo/config.toml`. The default clone is untouched and resolves from crates.io as before. ADR-0038 records the decision and the alternatives. `package.publish` stays `false`, deliberately. It does not need to change: `cargo package` works under it, and the resulting `.crate` deploys with `jf rt upload`, which Artifactory indexes into a valid registry entry on its own (~5s later, with deps/features/cksum parsed from Cargo.toml). Setting `publish = ["lab-cargo-dev"]` would also work and would *not* open a path to crates.io — `cargo publish --registry crates-io` still refuses — but it is unnecessary, and whether this crate should be publishable at all is a separate decision that is not this one's to make. Two things the docs call out because they are not obvious: - `sqlite-rs` is already taken on crates.io by an unrelated crate, 19 versions up to 0.3.7. `lab-cargo-dev` merges its crates.io cache with our local repo, so one index path serves 20 versions from two projects with nothing distinguishing them. Ours resolves correctly today only because the ranges do not overlap. `lab-cargo-prod` has no remote and serves 0.18.10 alone. - Consumers should depend on us via the named-registry form, not the source replacement. Under source replacement the consumer's lockfile records our private crate as coming from crates.io, which is untrue and makes deny.toml's `sources` check pass it silently. The workflow skips cleanly whenever JF_ACCESS_TOKEN is absent, which is always the case on forked PRs — a JFrog-caused red build would count against the evaluation's reliability criterion, whose threshold is zero. Refs: Lab271/labs-jfrog-poc#12 Co-Authored-By: Claude Opus 5 --- .cargo/config.toml.example | 50 ++++ .github/workflows/jfrog.yml | 243 ++++++++++++++++++ .gitignore | 7 + ...038-cargo-registry-opt-in-not-committed.md | 121 +++++++++ .openspec/adr/index.md | 1 + docs/src/SUMMARY.md | 1 + docs/src/jfrog-registry.md | 130 ++++++++++ 7 files changed, 553 insertions(+) create mode 100644 .cargo/config.toml.example create mode 100644 .github/workflows/jfrog.yml create mode 100644 .openspec/adr/0038-cargo-registry-opt-in-not-committed.md create mode 100644 docs/src/jfrog-registry.md diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example new file mode 100644 index 00000000..e5865a10 --- /dev/null +++ b/.cargo/config.toml.example @@ -0,0 +1,50 @@ +# Cargo configuration for the Lab271 JFrog Artifactory registry. +# +# THIS FILE IS A TEMPLATE AND IS NOT ACTIVE. Copy it to `.cargo/config.toml` +# (gitignored) to opt in: +# +# cp .cargo/config.toml.example .cargo/config.toml +# +# It is deliberately not committed as `.cargo/config.toml`. This repository is +# public, and the Artifactory Cargo index requires authentication — an +# anonymous request to it returns 401, not a fallthrough to crates.io. A +# committed source replacement would therefore break `cargo build` for every +# outside contributor and every fork. See ADR-0038. +# +# Requires a JFrog identity token. Cargo reads it from +# `CARGO_REGISTRIES_LAB_CARGO_DEV_TOKEN` (preferred, nothing on disk) or from +# `~/.cargo/credentials.toml`. Never put a token in this file or in +# `.cargo/config.toml`. + +# --- Resolving public crates through the Artifactory cache ------------------ +# +# Source replacement, so no Cargo.toml or Cargo.lock change is needed. Cargo +# requires a replacement source to serve byte-identical crates, and verifies +# every `.crate` against the checksum already pinned in Cargo.lock — so this +# cannot silently substitute a different dependency, and Cargo.lock stays +# portable (its `source` entries keep pointing at crates.io). +[source.crates-io] +replace-with = "lab-cargo-dev" + +[source.lab-cargo-dev] +registry = "sparse+https://schubergphilis.jfrog.io/artifactory/api/cargo/lab-cargo-dev/index/" + +# --- Publishing this crate, and consuming it elsewhere --------------------- +# +# The named-registry form. Needed by `cargo publish --registry lab-cargo-dev`, +# and by any other crate that depends on this one as +# `sqlite-rs = { version = "0.18", registry = "lab-cargo-dev" }`. +# +# Prefer that named form over relying on the source replacement above when +# depending on sqlite-rs: under source replacement, the consumer's Cargo.lock +# records our private crate as `source = "registry+https://github.com/ +# rust-lang/crates.io-index"`, which is untrue and makes `deny.toml`'s +# `sources` check (`allow-registry = [crates.io]`) pass it silently. +[registries.lab-cargo-dev] +index = "sparse+https://schubergphilis.jfrog.io/artifactory/api/cargo/lab-cargo-dev/index/" + +# Promoted releases only — backed by `lab-cargo-prod-local` with no remote, so +# it can never reach crates.io. That also makes it the only one of the two +# immune to the name collision described in docs/src/jfrog-registry.md. +[registries.lab-cargo-prod] +index = "sparse+https://schubergphilis.jfrog.io/artifactory/api/cargo/lab-cargo-prod/index/" diff --git a/.github/workflows/jfrog.yml b/.github/workflows/jfrog.yml new file mode 100644 index 00000000..8340c7e0 --- /dev/null +++ b/.github/workflows/jfrog.yml @@ -0,0 +1,243 @@ +name: JFrog + +# Private crate registry: resolve through the Artifactory cache, publish the +# packaged crate with build-info, promote a release to prod. See +# docs/src/jfrog-registry.md and ADR-0038. +# +# Every job is gated on the JF_ACCESS_TOKEN secret being present and skips +# cleanly when it is not. Forked pull requests never receive secrets, so this +# workflow must never be the reason a contributor's PR shows red — a +# JFrog-caused failure is a finding against the evaluation's reliability +# criterion, and the threshold there is zero. + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/jfrog.yml" + - ".cargo/config.toml.example" + - "Cargo.toml" + - "Cargo.lock" + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +env: + JF_PROJECT: ${{ vars.JF_PROJECT }} + JF_URL: ${{ vars.JF_URL }} + +jobs: + # Is the credential available at all? Forked PRs get no secrets, and + # `secrets` cannot be referenced in a job-level `if:`, so the answer has to + # be computed in a job and passed on as an output. + preflight: + name: Credential available + runs-on: ubuntu-latest + outputs: + have-token: ${{ steps.check.outputs.have-token }} + steps: + - id: check + env: + TOKEN: ${{ secrets.JF_ACCESS_TOKEN }} + run: | + if [ -n "$TOKEN" ] && [ -n "${{ vars.JF_URL }}" ]; then + echo "have-token=true" >> "$GITHUB_OUTPUT" + else + echo "have-token=false" >> "$GITHUB_OUTPUT" + echo "::notice::No JFrog credential on this ref (expected on forked PRs) — JFrog jobs will skip." + fi + + # Resolve the whole dependency closure through `lab-cargo-dev`, then prove a + # separate crate can consume the promoted `sqlite-rs` from `lab-cargo-prod`. + # The second half is the point of the exercise: eighteen releases in, no + # colleague has ever been able to write `sqlite-rs = "0.18"` and have it + # resolve. + resolve: + name: Resolve through lab-cargo-dev + needs: preflight + if: needs.preflight.outputs.have-token == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + # Written to the runner's CARGO_HOME, never to the working tree — the + # repository is public and a committed source replacement would 401 + # every anonymous clone (ADR-0038). Credentials go via the environment, + # so no token is ever written to disk. + - name: Configure Cargo for Artifactory + run: | + mkdir -p "$HOME/.cargo" + cat >> "$HOME/.cargo/config.toml" < "$probe/Cargo.toml" < "$probe/src/main.rs" <<'EOF' + fn main() { + // Touch real public items so the dependency is genuinely + // compiled and linked, not merely resolved and discarded. + assert_eq!(sqlite_rs::header::HEADER_LEN, 100); + println!("consumed sqlite-rs from lab-cargo-prod"); + } + EOF + cd "$probe" && cargo run --quiet + grep -A3 'name = "sqlite-rs"' Cargo.lock + + # Package and upload with build-info. Deliberately does not use + # `cargo publish`: `package.publish` stays `false` (ADR-0038), and only the + # upload path records build-info. Tags and manual runs only — a PR does not + # publish. + publish: + name: Publish to lab-cargo-dev-local + needs: [preflight, resolve] + if: >- + needs.preflight.outputs.have-token == 'true' && + (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + outputs: + version: ${{ steps.pack.outputs.version }} + digest: ${{ steps.upload.outputs.digest }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # build-add-git needs real history to read the revision from. + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: jfrog/setup-jfrog-cli@279b1f629f43dd5bc658d8361ac4802a7ef8d2d5 # v4.9.1 + env: + JF_URL: ${{ vars.JF_URL }} + JF_ACCESS_TOKEN: ${{ secrets.JF_ACCESS_TOKEN }} + + # `cargo package` works fine under `publish = false`; it is only + # `cargo publish` that the flag blocks. + - id: pack + name: cargo package + run: | + version=$(cargo metadata --no-deps --format-version=1 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') + echo "version=$version" >> "$GITHUB_OUTPUT" + cargo package --locked + + # `--project` is mandatory on every build-info command. Without it the + # CLI targets the platform-level `artifactory-build-info` repository and + # gets a flat 403 whose message names that repository rather than the + # missing flag. + - id: upload + name: Upload with build-info + run: | + v="${{ steps.pack.outputs.version }}" + n="${{ github.run_number }}" + jf rt upload "target/package/sqlite-rs-${v}.crate" \ + "lab-cargo-dev-local/crates/sqlite-rs/sqlite-rs-${v}.crate" \ + --build-name=sqlite-rs --build-number="$n" --project="$JF_PROJECT" + jf rt build-add-git sqlite-rs "$n" --project="$JF_PROJECT" + jf rt build-publish sqlite-rs "$n" --project="$JF_PROJECT" + # Recorded BEFORE any promotion: `jf rt build-promote` moves by + # default, and after a move there is nothing left in dev to compare + # the promoted bytes against. + digest=$(jf rt curl -s \ + "/api/storage/lab-cargo-dev-local/crates/sqlite-rs/sqlite-rs-${v}.crate" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["checksums"]["sha256"])') + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "::notice::dev digest sha256:${digest}" + + # Promote the same bytes to prod. The `jfrog-prod` environment is what makes + # the approver visible in the audit trail. + # + # NOTE FOR A HUMAN: the environment does not exist yet. GitHub creates it on + # this workflow's first run with NO protection rules, which means this job is + # currently ungated. Add required reviewers under + # Settings -> Environments -> jfrog-prod before treating it as an approval + # gate. Until then the environment records who triggered the run, not who + # approved it. + promote: + name: Promote to lab-cargo-prod-local + needs: [preflight, publish] + if: needs.preflight.outputs.have-token == 'true' + runs-on: ubuntu-latest + environment: jfrog-prod + steps: + - uses: jfrog/setup-jfrog-cli@279b1f629f43dd5bc658d8361ac4802a7ef8d2d5 # v4.9.1 + env: + JF_URL: ${{ vars.JF_URL }} + JF_ACCESS_TOKEN: ${{ secrets.JF_ACCESS_TOKEN }} + + # `--copy=true` is not optional. build-promote MOVES by default, which + # would delete the crate from dev and break anything still resolving + # that version from `lab-cargo-dev`. + - name: Promote, then assert the digest is unchanged + run: | + v="${{ needs.publish.outputs.version }}" + jf rt build-promote sqlite-rs "${{ github.run_number }}" \ + lab-cargo-prod-local --project="$JF_PROJECT" \ + --status=Released --copy=true + for _ in $(seq 1 12); do + prod=$(jf rt curl -s \ + "/api/storage/lab-cargo-prod-local/crates/sqlite-rs/sqlite-rs-${v}.crate" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("checksums",{}).get("sha256",""))') + [ -n "$prod" ] && break + sleep 5 + done + echo "dev sha256:${{ needs.publish.outputs.digest }}" + echo "prod sha256:${prod}" + if [ "$prod" != "${{ needs.publish.outputs.digest }}" ]; then + echo "::error::Promoted digest differs from the dev digest — the bytes were not preserved." + exit 1 + fi + echo "::notice::Promoted bytes identical to dev — no rebuild in the path." diff --git a/.gitignore b/.gitignore index 0144c84d..19d03864 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ __pycache__/ # mdBook build output (`make docs`) /docs/book/ + +# Local opt-in for the Artifactory Cargo registry (ADR-0038). Committing this +# would 401 every anonymous `cargo build` on this public repo, so only the +# `.example` template is tracked. Ignored rather than merely absent so a local +# copy cannot be committed by accident. +/.cargo/config.toml +/.cargo/credentials.toml diff --git a/.openspec/adr/0038-cargo-registry-opt-in-not-committed.md b/.openspec/adr/0038-cargo-registry-opt-in-not-committed.md new file mode 100644 index 00000000..3c589907 --- /dev/null +++ b/.openspec/adr/0038-cargo-registry-opt-in-not-committed.md @@ -0,0 +1,121 @@ +# 0038: Artifactory Cargo access is opt-in local config, never a committed source replacement + +Date: 2026-09-03 + +## Context + +This crate has shipped eighteen releases and no colleague can depend on it, +because `publish = false` and there is nowhere to publish to. Lab271's JFrog +evaluation ([labs-jfrog-poc#12](https://github.com/Lab271/labs-jfrog-poc/issues/12)) +made a private Cargo registry available: `lab-cargo-dev`, a virtual repository +over `lab-cargo-dev-local`, `lab-cargo-prod-local` and a `crates.io` cache. + +Cargo has no per-project registry config that lives outside the working tree. +npm has `.npmrc`, and the JFrog CLI can sidestep even that with +`jf npm-config`, which writes `.jfrog/projects/npm.yaml`. **There is no +`jf cargo-config` and no `jf cargo` command at all** — `jf --help` says so +outright: "Cargo has no dedicated 'jf cargo-config' command — it reads the +Artifactory registry directly from `.cargo/config.toml`." So the only +mechanisms Cargo offers are `.cargo/config.toml` (project or user level) and +`CARGO_*` environment variables. + +That matters because **this repository is public** and the Artifactory Cargo +index is not anonymously readable. Measured directly: + +``` +$ curl -o /dev/null -w '%{http_code}' \ + https://schubergphilis.jfrog.io/artifactory/api/cargo/lab-cargo-dev/index/config.json +401 +``` + +The index's own `config.json` also advertises `"auth-required": true`, which +Cargo honours for crate *downloads* as well as index reads. A committed +`.cargo/config.toml` carrying `[source.crates-io] replace-with = ...` would +therefore turn every anonymous `cargo build` — every outside contributor, +every fork, every CI run without the secret — into a 401 on the first +dependency fetch. The same trade-off already came up in the npm pilot of the +same evaluation, where a committed `.npmrc` was rejected for exactly this +reason. + +## Decision + +Artifactory access is **opt-in and untracked**. The repository commits +`.cargo/config.toml.example` and gitignores `.cargo/config.toml` and +`.cargo/credentials.toml`. A developer who wants the cache copies the +template; everyone else is unaffected and resolves from `crates.io` as +before. No file in the default clone points at Artifactory. + +`package.publish` **stays `false`**. Publishing to Artifactory does not +require changing it: `cargo package` works under `publish = false`, and the +resulting `.crate` can be deployed with `jf rt upload`, which Artifactory +indexes into a valid Cargo registry entry on its own (verified: index entry +appeared ~5s after upload, with `deps`, `features` and `cksum` parsed from +`Cargo.toml`). Whether to make this crate publishable at all is a separate, +unmade decision and this ADR does not pre-empt it. + +Two consumption forms are documented, and the named-registry form is +preferred for depending on *this* crate: + +- `[source.crates-io] replace-with = ...` — for the upstream cache only. + Cargo enforces byte-identical content in a replacement source and checks + every `.crate` against the checksum already in `Cargo.lock`, so this is + provenance-preserving for public crates and leaves `Cargo.lock` portable. +- `sqlite-rs = { version = "0.18", registry = "lab-cargo-dev" }` — for + depending on this crate. Under source replacement instead, the consumer's + lockfile records our private crate as + `source = "registry+https://github.com/rust-lang/crates.io-index"`, which + is false, and which makes `deny.toml`'s `sources` check + (`allow-registry = ["https://github.com/rust-lang/crates.io-index"]`, + `unknown-registry = "deny"`) pass a private-registry dependency silently. + The named form records the real index URL and the gate sees it. + +## Alternatives rejected + +- **Commit `.cargo/config.toml` with the source replacement**, as + [doc 3 of the PoC](https://github.com/Lab271/labs-jfrog-poc/blob/main/docs/03-promotion-and-xray.md) + suggests per-repo registry config generally. Rejected on the measured 401 + above: it breaks the public contributor path outright, and it breaks it at + dependency-fetch time with an authentication error that gives an outside + contributor no hint that the fix is to delete a file they did not add. +- **Set `package.publish = ["lab-cargo-dev"]`.** This does work, and it is + narrower than it looks — `cargo publish --registry crates-io` still fails + with "The registry `crates-io` is not listed in the `package.publish` + value", and a bare `cargo publish` auto-targets the single allowed registry + ("found `lab-cargo-dev` as only allowed registry"). So it would *not* + silently open a path to crates.io. Rejected anyway because it is + unnecessary: the `jf rt upload` path publishes a `publish = false` crate + fine, and it is the path that also produces build-info + (`--build-name`/`--build-number`), which `cargo publish` cannot. Keeping + `publish = false` leaves the crates.io question untouched, which is where it + belongs. Revisit if we ever want `cargo publish` itself in the release path. +- **A user-level `~/.cargo/config.toml` only, with nothing in the repo.** + Rejected as undiscoverable: the point of the exercise is that a colleague + can consume this crate, and a mechanism documented nowhere in the + repository does not achieve that. The `.example` file is the discoverable + half; the gitignore is the safety half. +- **Vendoring (`cargo vendor`) instead of a registry.** Rejected: it solves + offline builds, not distribution. A colleague still could not write + `sqlite-rs = "0.18"` in their own `Cargo.toml`, which is the actual problem. + +## Consequences + +- The default clone is unchanged. `cargo build`, `cargo test` and CI on a + fork resolve from `crates.io` exactly as before; nothing in the tracked + tree references Artifactory except documentation and one `.example` file. +- Resolving through `lab-cargo-dev` leaves `Cargo.lock` untouched — all 116 + `source` entries still read `registry+https://github.com/rust-lang/crates.io-index` + after a full `cargo fetch --locked` through the proxy. Verified, and it is a + guarantee rather than an accident: Cargo refuses a replacement source whose + checksums differ. +- **`sqlite-rs` is already taken on crates.io** — an unrelated crate, 19 + versions up to 0.3.7. `lab-cargo-dev` merges its cache with our local + repository, so that index path serves 20 versions from two different + projects with nothing distinguishing them. Our `0.18.10` resolves correctly + today only because the version ranges happen not to overlap. + `lab-cargo-prod` has no remote and serves `0.18.10` alone. Consumers of + released versions should point at `lab-cargo-prod`; the collision is + recorded in `docs/src/jfrog-registry.md` and is an argument for renaming the + crate before any public release. +- Anyone who copies the template still needs a JFrog identity token. Nothing + in this repository can mint one — see `docs/src/jfrog-registry.md` for what + a human has to create. diff --git a/.openspec/adr/index.md b/.openspec/adr/index.md index cb18749d..2b4d7e6c 100644 --- a/.openspec/adr/index.md +++ b/.openspec/adr/index.md @@ -41,3 +41,4 @@ Specs record what the system must do; ADRs record **why it is shaped this way** | [0035](0035-wal-resume-hint-cache-supersedes-0026.md) | `Pager`-cached WAL resume hint supersedes ADR-0026's per-flush rescan | 2026-08-29 | | [0036](0036-pragma-synchronous-fsync-policy.md) | `PRAGMA synchronous` fsync-skip policy, and why `SynchronousMode` lives in `header.rs` | 2026-08-29 | | [0037](0037-macos-plain-fsync-not-fullfsync.md) | On macOS, `Vfs::sync` calls plain `fsync(2)`, not `std`'s `F_FULLFSYNC` | 2026-08-30 | +| [0038](0038-cargo-registry-opt-in-not-committed.md) | Artifactory Cargo access is opt-in local config, never a committed source replacement | 2026-09-03 | diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 4d54c4d2..646f414c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -15,6 +15,7 @@ # Reference - [Plan](plan.md) +- [Private crate registry](jfrog-registry.md) - [ADR Index](adr/index.md) - [0001 — `-shm` access via pread/pwrite, not mmap](adr/0001-shm-access-pread-not-mmap.md) - [0002 — Value blocks over layer-ordered development](adr/0002-value-blocks-over-layers.md) diff --git a/docs/src/jfrog-registry.md b/docs/src/jfrog-registry.md new file mode 100644 index 00000000..8a104507 --- /dev/null +++ b/docs/src/jfrog-registry.md @@ -0,0 +1,130 @@ +# Private crate registry (JFrog Artifactory) + +`package.publish` is `false` and this crate is not on crates.io, so for +eighteen releases there has been no way for anyone else to depend on it. This +page describes the private Cargo registry that fixes that, set up as part of +[Lab271's JFrog evaluation](https://github.com/Lab271/labs-jfrog-poc). It is +**opt-in**: nothing in a default clone points at it, and `cargo build` on a +fresh clone or a fork resolves from crates.io exactly as before. See +[ADR-0038](adr/0038-cargo-registry-opt-in-not-committed.md) for why it must +stay that way. + +## Repositories + +| Key | Kind | Contents | +|---|---|---| +| `lab-cargo-dev` | virtual | everything: dev, prod, and the crates.io cache | +| `lab-cargo-prod` | virtual | promoted releases only — **no remote**, cannot reach the internet | +| `lab-cargo-dev-local` | local | where `sqlite-rs` is published | +| `lab-cargo-prod-local` | local | where a release is promoted to | +| `lab-crates-io-remote` | remote | caching proxy for `index.crates.io` | + +The index URL for any of them is +`sparse+https://schubergphilis.jfrog.io/artifactory/api/cargo//index/`. + +## Opting in + +```bash +cp .cargo/config.toml.example .cargo/config.toml +export CARGO_REGISTRIES_LAB_CARGO_DEV_TOKEN="" +cargo fetch --locked +``` + +`.cargo/config.toml` is gitignored. Read the template's comments before +editing it — it explains which of the two mechanisms to use when. + +Prefer the environment variable to `~/.cargo/credentials.toml`; it keeps the +token off disk. Nothing in this repository can create a token — a human with +access to the `lab` project has to issue one from the JFrog UI. + +`Cargo.lock` is unaffected. After a full `cargo fetch --locked` through +Artifactory all 116 `source` entries still read +`registry+https://github.com/rust-lang/crates.io-index`, because Cargo +requires a replacement source to serve byte-identical crates and verifies each +one against the checksum already in the lockfile. The lockfile stays portable +and public clones keep working. + +## Publishing + +`publish = false` does **not** have to change. `cargo package` works under it, +and the resulting `.crate` is deployed with the JFrog CLI, which also records +build-info: + +```bash +cargo package --locked +jf rt upload target/package/sqlite-rs-.crate \ + "lab-cargo-dev-local/crates/sqlite-rs/sqlite-rs-.crate" \ + --build-name=sqlite-rs --build-number="$N" --project=lab +jf rt build-add-git sqlite-rs "$N" --project=lab +jf rt build-publish sqlite-rs "$N" --project=lab +``` + +`--project=lab` is mandatory on the build-info commands; without it the CLI +targets a platform-level repository and gets a flat 403 whose message names +the repository rather than the missing flag. + +Artifactory generates the Cargo index entry from the uploaded `.crate` itself +— parsing `Cargo.toml` for `deps`, `features` and the checksum — a few seconds +after the upload lands. There is no `jf cargo` command and none is needed. + +`cargo publish` is the other option, but it requires `package.publish` to name +the registry and it cannot produce build-info. ADR-0038 records why the upload +path is preferred. + +### Promoting a release + +```bash +jf rt build-promote sqlite-rs "$N" lab-cargo-prod-local \ + --project=lab --status=Released --copy=true +``` + +`--copy=true` is not optional in practice: **`build-promote` moves by +default**, which would delete the crate from `lab-cargo-dev-local` and break +anything still resolving that version from `lab-cargo-dev`. Record the dev +digest before promoting, because after a move there is nothing left to compare +against. + +## Consuming it from another crate + +```toml +[dependencies] +sqlite-rs = { version = "0.18", registry = "lab-cargo-prod" } +``` + +with the matching `[registries.lab-cargo-prod]` block from the template in the +consumer's own `.cargo/config.toml`. + +Use this named-registry form, not the source replacement, when depending on +`sqlite-rs`. Under source replacement the consumer's lockfile records + +```toml +source = "registry+https://github.com/rust-lang/crates.io-index" +``` + +for a crate that is not on crates.io at all. That is untrue, and it makes +[`deny.toml`](https://github.com/Lab271/sqlite-rs/blob/main/deny.toml)'s +`sources` check — `unknown-registry = "deny"` with `allow-registry` set to +crates.io only — pass a private-registry dependency without comment. The +named form records the real index URL, and the gate sees it. + +## Two things to know before relying on this + +**The crate name is already taken on crates.io.** An unrelated `sqlite-rs` +has 19 published versions, up to `0.3.7`. `lab-cargo-dev` merges its crates.io +cache with our local repository, so that one index path serves 20 versions +belonging to two different projects, with nothing in the metadata +distinguishing them. Our `0.18.10` resolves correctly today only because the +version ranges do not overlap. `lab-cargo-prod` has no remote and serves +`0.18.10` alone, which is why release consumers should point there. Renaming +the crate is the real fix and should happen before any public release. + +**Xray does not scan this crate's dependency graph.** `jf audit` on the source +tree reports the project as `[unknown]` and generates an SBOM with *no library +components* — it does not recognise `Cargo.toml`/`Cargo.lock` as a dependency +manifest at all, so the 116-crate closure that `make check-deny` and +`sqlite-rs-dev.cdx.json` already cover is never examined. Xray does have Cargo +CVE data and does apply it to a `.crate` artifact scanned with `jf scan` +(confirmed against a deliberately vulnerable `time 0.1.44`, which reports +`CVE-2020-26235`, type `cargo`), but this crate has zero runtime dependencies, +so there is nothing there for it to find. `make check-deny` remains the +supply-chain gate; Artifactory adds distribution, not assurance. From 1a1445103c29d3d3825a06a6bd189826bd0b1957 Mon Sep 17 00:00:00 2001 From: Bernard <63512176+BernardJen@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:23:31 +0200 Subject: [PATCH 2/5] ci: temporary branch trigger to exercise publish and promote `workflow_dispatch` only fires for a workflow that already exists on the default branch, so from a feature branch there is no way to make the publish and promote jobs actually run. An unexecuted workflow is not evidence that it works, and the promote job carries the evaluation's hard-fail assertion (promoted digest identical to the dev digest), so it needs a real run rather than a reading. Reverted in the next commit, before merge. Also makes the consume step skip when the current version is not in lab-cargo-prod yet, which is the normal state on a PR that bumps the version. Failing there would be a JFrog-caused CI failure, and the evaluation's threshold for those is zero. Refs: Lab271/labs-jfrog-poc#12 Co-Authored-By: Claude Opus 5 --- .github/workflows/jfrog.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/jfrog.yml b/.github/workflows/jfrog.yml index 8340c7e0..4a9d42f6 100644 --- a/.github/workflows/jfrog.yml +++ b/.github/workflows/jfrog.yml @@ -20,6 +20,11 @@ on: - "Cargo.lock" push: tags: ["v*"] + # TEMPORARY, reverted before merge. `workflow_dispatch` only fires for a + # workflow that already exists on the default branch, so the publish and + # promote jobs cannot be exercised from a feature branch any other way — + # and an unexecuted workflow is not evidence that it works. + branches: ["feat/jfrog-cargo"] workflow_dispatch: permissions: @@ -113,9 +118,22 @@ jobs: - name: A second crate consumes the published version env: CARGO_REGISTRIES_LAB_CARGO_PROD_TOKEN: Bearer ${{ secrets.JF_ACCESS_TOKEN }} + JF_TOKEN: ${{ secrets.JF_ACCESS_TOKEN }} run: | version=$(cargo metadata --no-deps --format-version=1 \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') + + # On a PR that bumps the version, that version has not been + # promoted yet and there is nothing to consume. Skip rather than + # fail: a red build here would be a JFrog-caused CI failure, and + # the evaluation's threshold for those is zero. + if ! curl -sf -H "Authorization: Bearer ${JF_TOKEN}" \ + "${JF_URL}/artifactory/api/cargo/lab-cargo-prod/index/sq/li/sqlite-rs" \ + | grep -q "\"vers\":\"${version}\""; then + echo "::notice::sqlite-rs ${version} is not in lab-cargo-prod yet — nothing to consume, skipping." + exit 0 + fi + probe="$RUNNER_TEMP/consumer-probe" mkdir -p "$probe/src" cat > "$probe/Cargo.toml" <- needs.preflight.outputs.have-token == 'true' && - (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') + (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || github.event_name == 'push') runs-on: ubuntu-latest outputs: version: ${{ steps.pack.outputs.version }} From 2df1702cd976798d29d4b2596c980565c94a5497 Mon Sep 17 00:00:00 2001 From: Bernard <63512176+BernardJen@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:25:01 +0200 Subject: [PATCH 3/5] fix(ci): name cargo's token credential provider explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifactory's Cargo index advertises `"auth-required": true`, and Cargo then refuses to query it at all unless a credential provider is named: "authenticated registries require a credential-provider to be configured". `CARGO_REGISTRIES_*_TOKEN` alone is not enough — the provider has to be listed before the env var is consulted. Co-Authored-By: Claude Opus 5 --- .github/workflows/jfrog.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/jfrog.yml b/.github/workflows/jfrog.yml index 4a9d42f6..491293c0 100644 --- a/.github/workflows/jfrog.yml +++ b/.github/workflows/jfrog.yml @@ -77,6 +77,14 @@ jobs: run: | mkdir -p "$HOME/.cargo" cat >> "$HOME/.cargo/config.toml" < Date: Thu, 3 Sep 2026 20:28:54 +0200 Subject: [PATCH 4/5] fix(ci): refuse to re-publish an existing version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found the hard way, and it broke a real consumer: * `cargo package` is not byte-reproducible across machines. Commit e04330e8 produced sha256:eb1deeae… on a laptop and sha256:84074b9f… on a runner. * `lab-cargo-dev-local` and `lab-cargo-prod-local` are mutable. Re-uploading 0.18.10 overwrote the bytes silently — no error, no warning — including in prod, where that version already carried status `Released`. * Cargo pins checksums in Cargo.lock, so that is not a harmless re-upload. A consumer pinned to the old digest now fails outright: "checksum for `sqlite-rs v0.18.10` changed between lock files ... unable to verify that `sqlite-rs v0.18.10` is the same as when the lockfile was generated". Reproduced, not theorised. Publish and promote now skip cleanly when the version already exists, rather than clobbering it. Bumping the version is the way to release new bytes. Repository immutability on the prod local repo (Lab271/labs-jfrog-poc#4) is the actual fix and needs platform admin we do not have. Until then this guard is the only thing between a re-run and a broken downstream lockfile. Co-Authored-By: Claude Opus 5 --- .github/workflows/jfrog.yml | 42 ++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/jfrog.yml b/.github/workflows/jfrog.yml index 491293c0..a31bb568 100644 --- a/.github/workflows/jfrog.yml +++ b/.github/workflows/jfrog.yml @@ -179,6 +179,7 @@ jobs: outputs: version: ${{ steps.pack.outputs.version }} digest: ${{ steps.upload.outputs.digest }} + already-published: ${{ steps.guard.outputs.already-published }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -200,12 +201,49 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" cargo package --locked + # Never re-publish a version that already exists. This is not + # defensive tidiness — it is load-bearing, and it was found the hard + # way: + # + # * `cargo package` is not byte-reproducible across machines. The + # same commit produced sha256:eb1deeae… on a laptop and + # sha256:84074b9f… on a runner. + # * `lab-cargo-*-local` are mutable. Re-uploading the same version + # overwrote the bytes silently, with no error, including a version + # already promoted with status `Released`. + # * Cargo pins checksums in Cargo.lock. So an overwrite is not a + # harmless re-upload; every existing consumer breaks outright with + # "checksum for `sqlite-rs v0.18.10` changed between lock files". + # + # Repository immutability on `lab-cargo-prod-local` + # (Lab271/labs-jfrog-poc#4) is the real fix and needs platform admin we + # do not have. Until then this guard is the only thing standing between + # a re-run and a broken downstream lockfile. + - id: guard + name: Refuse to overwrite an existing version + run: | + v="${{ steps.pack.outputs.version }}" + found=false + for repo in lab-cargo-dev-local lab-cargo-prod-local; do + code=$(jf rt curl -s -o /dev/null -w '%{http_code}' \ + "/api/storage/${repo}/crates/sqlite-rs/sqlite-rs-${v}.crate") + if [ "$code" = "200" ]; then + echo "::warning::sqlite-rs ${v} already exists in ${repo}." + found=true + fi + done + echo "already-published=$found" >> "$GITHUB_OUTPUT" + if [ "$found" = "true" ]; then + echo "::notice::Nothing to publish — bump the version to release new bytes. Skipping publish and promote." + fi + # `--project` is mandatory on every build-info command. Without it the # CLI targets the platform-level `artifactory-build-info` repository and # gets a flat 403 whose message names that repository rather than the # missing flag. - id: upload name: Upload with build-info + if: steps.guard.outputs.already-published == 'false' run: | v="${{ steps.pack.outputs.version }}" n="${{ github.run_number }}" @@ -235,7 +273,9 @@ jobs: promote: name: Promote to lab-cargo-prod-local needs: [preflight, publish] - if: needs.preflight.outputs.have-token == 'true' + if: >- + needs.preflight.outputs.have-token == 'true' && + needs.publish.outputs.already-published == 'false' runs-on: ubuntu-latest environment: jfrog-prod steps: From 36d1cd3a96efd1a9121afd823c9abcf2db9276f4 Mon Sep 17 00:00:00 2001 From: Bernard <63512176+BernardJen@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:31:08 +0200 Subject: [PATCH 5/5] ci: revert the temporary branch trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish and promote jobs have now run for real on this branch (run 33790369579, all four jobs green, "Promoted bytes identical to dev — no rebuild in the path"), and the overwrite guard has been verified skipping cleanly on a re-run (run 33790771562). The trigger has served its purpose. Back to tags and workflow_dispatch only. Co-Authored-By: Claude Opus 5 --- .github/workflows/jfrog.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/jfrog.yml b/.github/workflows/jfrog.yml index a31bb568..79139655 100644 --- a/.github/workflows/jfrog.yml +++ b/.github/workflows/jfrog.yml @@ -20,11 +20,6 @@ on: - "Cargo.lock" push: tags: ["v*"] - # TEMPORARY, reverted before merge. `workflow_dispatch` only fires for a - # workflow that already exists on the default branch, so the publish and - # promote jobs cannot be exercised from a feature branch any other way — - # and an unexecuted workflow is not evidence that it works. - branches: ["feat/jfrog-cargo"] workflow_dispatch: permissions: @@ -174,7 +169,7 @@ jobs: needs: [preflight, resolve] if: >- needs.preflight.outputs.have-token == 'true' && - (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || github.event_name == 'push') + (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest outputs: version: ${{ steps.pack.outputs.version }}