From 35e33021b85427fd24cce550449eb52df8be680b Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Mon, 4 May 2026 10:08:24 -0400 Subject: [PATCH 1/3] test: cloudflare artifacts roundtrip smoketest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realigns the Artifacts test-support adapter with the documented REST API and adds a live smoketest tagged `:cloudflare`. Adapter (`test/support/cloudflare_artifacts.ex`): - `create_repo!/2` now returns the bootstrap `{remote, token, ...}` the API hands back, instead of throwing it away. - `mint_token!` → `create_token!/3` to mirror upstream naming, fixed to `POST /tokens` (the previous `/repos/:name/tokens` 404'd) and to parse `result.plaintext` rather than `result.token`. - Adds `revoke_token!/1`. - Documents the required permissions: `Artifacts Read` + `Artifacts Write`. Smoketest (`test/exgit/cloudflare_artifacts_roundtrip_test.exs`): - Test 1: exgit push → exgit lazy-clone → byte equality. Proves our transport round-trips random content. - Test 2 (`:real_git`): exgit push → real `git clone` + `git fsck`. Proves Cloudflare stores valid git, readable by a third-party client. Both tests opt-in via `mix test --include cloudflare`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cloudflare_artifacts_roundtrip_test.exs | 149 ++++++++++++++++++ test/support/cloudflare_artifacts.ex | 110 ++++++++++--- 2 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 test/exgit/cloudflare_artifacts_roundtrip_test.exs diff --git a/test/exgit/cloudflare_artifacts_roundtrip_test.exs b/test/exgit/cloudflare_artifacts_roundtrip_test.exs new file mode 100644 index 0000000..949c28e --- /dev/null +++ b/test/exgit/cloudflare_artifacts_roundtrip_test.exs @@ -0,0 +1,149 @@ +defmodule Exgit.CloudflareArtifactsRoundtripTest do + @moduledoc """ + Live smoketest against Cloudflare Artifacts. + + Creates an ephemeral repo via the Artifacts REST API, pushes an + exgit-built commit over the git smart-HTTP endpoint using the + repo-scoped token returned by `create_repo`, then verifies the data + two ways: + + 1. **exgit ↔ exgit** — lazy-clone via `Exgit.clone`, read the blob + back, assert byte-equality with the random content we pushed. + Catches push/parse/transport bugs in our own code. + + 2. **exgit ↔ real git** — `git clone` with the real binary, then + `git fsck`. Proves the bytes Cloudflare stored are valid git + (not just exgit-flavoured), and that a third-party client can + read them. + + Tagged `:cloudflare`. Run with `mix test --include cloudflare`. + The real-git verification is also tagged `:real_git`. + + Secrets (loaded by `test_helper.exs`): + + * `CF_API_TOKEN` — Cloudflare API token with `Artifacts Read` and + `Artifacts Write` permissions. + * `CF_ACCOUNT_ID` — owning account. + """ + + use ExUnit.Case, async: false + @moduletag :cloudflare + + alias Exgit.Credentials.Artifacts, as: ArtifactsCreds + alias Exgit.Object.{Blob, Commit, Tree} + alias Exgit.{ObjectStore, RefStore, Repository, Transport} + alias Exgit.Test.{CloudflareArtifacts, RealGit} + + setup_all do + name = CloudflareArtifacts.unique_name("exgit-rt") + {:ok, repo_info} = CloudflareArtifacts.create_repo!(name, default_branch: "main") + on_exit(fn -> _ = CloudflareArtifacts.delete_repo!(name) end) + + %{ + repo_name: name, + remote: repo_info.remote, + token: repo_info.token, + default_branch: repo_info.default_branch + } + end + + defp transport(remote, token) do + Transport.HTTP.new(remote, auth: ArtifactsCreds.auth(token)) + end + + defp unique_branch do + suffix = :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) + "refs/heads/exgit-rt-#{System.system_time(:millisecond)}-#{suffix}" + end + + # Build a single-file commit. Returns {repo, commit_sha, content}. + defp build_commit(branch, filename, content) do + store = ObjectStore.Memory.new() + {:ok, blob_sha, store} = ObjectStore.put(store, Blob.new(content)) + {:ok, tree_sha, store} = ObjectStore.put(store, Tree.new([{"100644", filename, blob_sha}])) + + commit = + Commit.new( + tree: tree_sha, + parents: [], + author: "Exgit Smoketest 1700000000 +0000", + committer: "Exgit Smoketest 1700000000 +0000", + message: "cloudflare artifacts smoketest\n" + ) + + {:ok, commit_sha, store} = ObjectStore.put(store, commit) + {:ok, ref_store} = RefStore.write(RefStore.Memory.new(), branch, commit_sha, []) + + repo = %Repository{ + object_store: store, + ref_store: ref_store, + config: Exgit.Config.new(), + path: nil + } + + {repo, commit_sha} + end + + defp tmp_dir!(prefix) do + base = Path.join(System.tmp_dir!(), "#{prefix}_#{System.unique_integer([:positive])}") + File.rm_rf!(base) + base + end + + test "exgit push → exgit clone roundtrips a random blob", ctx do + branch = unique_branch() + content = :crypto.strong_rand_bytes(4096) + {repo, commit_sha} = build_commit(branch, "fixture.bin", content) + + t = transport(ctx.remote, ctx.token) + + assert {:ok, _} = Exgit.push(repo, t, refspecs: [branch]) + + {:ok, clone} = Exgit.clone(t, lazy: true) + assert {:ok, ^commit_sha} = RefStore.resolve(clone.ref_store, branch) + + assert {:ok, {_mode, fetched_blob}, _clone} = + Exgit.FS.read_path(clone, branch, "fixture.bin") + + assert fetched_blob.data == content + end + + @tag :real_git + test "exgit-pushed commit is readable by real git clone + fsck", ctx do + branch = unique_branch() + branch_name = String.trim_leading(branch, "refs/heads/") + content = "Pushed by exgit at #{System.system_time(:millisecond)}\n" + {repo, _commit_sha} = build_commit(branch, "hello.txt", content) + + assert {:ok, _} = Exgit.push(repo, transport(ctx.remote, ctx.token), refspecs: [branch]) + + clone_dir = tmp_dir!("exgit_cf_clone") + + try do + {out, status} = + System.cmd( + "git", + [ + "-c", + "http.extraheader=Authorization: Bearer #{ctx.token}", + "clone", + "--branch", + branch_name, + "--depth", + "1", + ctx.remote, + clone_dir + ], + stderr_to_stdout: true + ) + + assert status == 0, "git clone failed:\n#{out}" + assert File.read!(Path.join(clone_dir, "hello.txt")) == content + + {out, status} = RealGit.git!(clone_dir, ["fsck", "--full"], allow_error: true) + assert status == 0, "git fsck failed:\n#{out}" + after + File.rm_rf!(clone_dir) + end + end +end diff --git a/test/support/cloudflare_artifacts.ex b/test/support/cloudflare_artifacts.ex index e2651db..40981df 100644 --- a/test/support/cloudflare_artifacts.ex +++ b/test/support/cloudflare_artifacts.ex @@ -1,16 +1,27 @@ defmodule Exgit.Test.CloudflareArtifacts do @moduledoc """ Minimal client for the Cloudflare Artifacts REST API, used by the - roundtrip smoketest to create an ephemeral repo, mint a write token, - and clean up afterwards. + roundtrip smoketest to create an ephemeral repo, create repo-scoped + tokens, and clean up afterwards. + + Two token types are involved: + + * The Cloudflare API token (`CF_API_TOKEN`, prefix `cfat_`) — used + to authenticate **REST control-plane** calls below. + * Repo tokens (prefix `art_v1_`) — returned by `create_repo!/2` and + `create_token!/3`. Used to authenticate **git wire** operations + against the remote URL. + + Function names mirror the upstream API operations (`Create a repo`, + `Delete a repo`, `Create a token`, `Revoke a token`). Secrets come from environment variables: - * `CF_API_TOKEN` — a Cloudflare API token with Artifacts Edit. + * `CF_API_TOKEN` — Cloudflare API token with `Artifacts Read` and + `Artifacts Write` permissions. * `CF_ACCOUNT_ID` — the account ID that owns the namespace. - All functions raise on non-2xx responses so test failures surface - immediately. + All functions raise on unexpected non-2xx responses. """ @namespace "default" @@ -24,29 +35,57 @@ defmodule Exgit.Test.CloudflareArtifacts do "https://api.cloudflare.com/client/v4/accounts/#{account_id()}/artifacts/namespaces/#{@namespace}" end - def git_url(repo_name) do - "https://#{account_id()}.artifacts.cloudflare.net/git/#{@namespace}/#{repo_name}.git" - end + @doc """ + Create a repo (`POST /repos`). Returns + `{:ok, %{remote: url, token: art_v1_..., ...}}` on success — the + bootstrap token is repo-scoped (write) and is the only time the API + returns a token alongside `create`. For additional tokens use + `create_token!/3`. + + Returns `:already_exists` on 409 (no token returned; caller must + `create_token!`). + """ + def create_repo!(name, opts \\ []) do + body = + %{name: name} + |> maybe_put(:description, Keyword.get(opts, :description)) + |> maybe_put(:default_branch, Keyword.get(opts, :default_branch)) + |> maybe_put(:read_only, Keyword.get(opts, :read_only)) - @doc "Create a repo. Idempotent on 409 (already exists)." - def create_repo!(name) do resp = Req.request!( method: :post, url: base_url() <> "/repos", headers: api_headers(), - json: %{name: name}, + json: body, retry: false ) case resp.status do - s when s in 200..299 -> :ok - 409 -> :already_exists - _ -> raise "create_repo failed: status=#{resp.status} body=#{inspect(resp.body)}" + s when s in 200..299 -> + result = Map.get(resp.body, "result", %{}) + + {:ok, + %{ + id: Map.fetch!(result, "id"), + name: Map.fetch!(result, "name"), + default_branch: Map.fetch!(result, "default_branch"), + remote: Map.fetch!(result, "remote"), + token: Map.fetch!(result, "token") + }} + + 409 -> + :already_exists + + _ -> + raise "create_repo failed: status=#{resp.status} body=#{inspect(resp.body)}" end end - @doc "Delete the repo (irreversible)." + @doc """ + Delete a repo (`DELETE /repos/:name`). The API returns `202 Accepted` + on success. Returns `:ok` on 2xx, `:gone` on 404. + """ def delete_repo!(name) do resp = Req.request!( @@ -63,14 +102,18 @@ defmodule Exgit.Test.CloudflareArtifacts do end end - @doc "Mint a repo-scoped token. `scope` is :read or :write." - def mint_token!(repo_name, scope) when scope in [:read, :write] do + @doc """ + Create a repo-scoped git token (`POST /tokens`). Default scope is + `:write`, default ttl is 3600s. + """ + def create_token!(repo_name, scope \\ :write, ttl \\ 3600) + when scope in [:read, :write] and is_integer(ttl) and ttl > 0 do resp = Req.request!( method: :post, - url: base_url() <> "/repos/#{repo_name}/tokens", + url: base_url() <> "/tokens", headers: api_headers(), - json: %{scope: to_string(scope)}, + json: %{repo: repo_name, scope: to_string(scope), ttl: ttl}, retry: false ) @@ -79,14 +122,34 @@ defmodule Exgit.Test.CloudflareArtifacts do token = resp.body |> Map.get("result", %{}) - |> Map.get("token") + |> Map.get("plaintext") if is_binary(token) and token != "", do: token, - else: raise("mint_token: missing token in response: #{inspect(resp.body)}") + else: raise("create_token: missing plaintext in response: #{inspect(resp.body)}") _ -> - raise "mint_token failed: status=#{resp.status} body=#{inspect(resp.body)}" + raise "create_token failed: status=#{resp.status} body=#{inspect(resp.body)}" + end + end + + @doc """ + Revoke a token by id (`DELETE /tokens/:id`). Returns `:ok` on 2xx, + `:gone` on 404. + """ + def revoke_token!(token_id) do + resp = + Req.request!( + method: :delete, + url: base_url() <> "/tokens/#{token_id}", + headers: api_headers(), + retry: false + ) + + case resp.status do + s when s in 200..299 -> :ok + 404 -> :gone + _ -> raise "revoke_token failed: status=#{resp.status} body=#{inspect(resp.body)}" end end @@ -104,6 +167,9 @@ defmodule Exgit.Test.CloudflareArtifacts do # --- Internal --- + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp account_id do System.get_env("CF_ACCOUNT_ID") || raise "CF_ACCOUNT_ID not set" end From 2a2fa3b5664b1959f34e5397f6d2a44984829e6c Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Wed, 6 May 2026 14:48:27 -0400 Subject: [PATCH 2/3] test: switch CF smoketest to long-lived repo + injected secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the REST control plane (create_repo / delete_repo) from the test-support adapter. The smoketest now runs against a persistent repo, with both the wire URL and token injected via env: * CF_ARTIFACT_REMOTE — full git wire URL * CF_ARTIFACT_TOKEN — long-lived repo-scoped token (art_v1_) Both are loaded from .env locally and from CI secrets. Hardcoding neither the URL (account ID) nor the token in source. Each test still uses a unique branch name to avoid stomping across runs against the shared repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cloudflare_artifacts_roundtrip_test.exs | 48 ++--- test/support/cloudflare_artifacts.ex | 189 ++---------------- 2 files changed, 33 insertions(+), 204 deletions(-) diff --git a/test/exgit/cloudflare_artifacts_roundtrip_test.exs b/test/exgit/cloudflare_artifacts_roundtrip_test.exs index 949c28e..5acc83d 100644 --- a/test/exgit/cloudflare_artifacts_roundtrip_test.exs +++ b/test/exgit/cloudflare_artifacts_roundtrip_test.exs @@ -2,9 +2,9 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do @moduledoc """ Live smoketest against Cloudflare Artifacts. - Creates an ephemeral repo via the Artifacts REST API, pushes an - exgit-built commit over the git smart-HTTP endpoint using the - repo-scoped token returned by `create_repo`, then verifies the data + Pushes an exgit-built commit over the git smart-HTTP endpoint to a + long-lived persistent repo (URL + token injected via + `CF_ARTIFACT_REMOTE` and `CF_ARTIFACT_TOKEN`), then verifies the data two ways: 1. **exgit ↔ exgit** — lazy-clone via `Exgit.clone`, read the blob @@ -16,14 +16,11 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do (not just exgit-flavoured), and that a third-party client can read them. + Each test uses a unique branch name; the underlying repo is shared + across runs. + Tagged `:cloudflare`. Run with `mix test --include cloudflare`. The real-git verification is also tagged `:real_git`. - - Secrets (loaded by `test_helper.exs`): - - * `CF_API_TOKEN` — Cloudflare API token with `Artifacts Read` and - `Artifacts Write` permissions. - * `CF_ACCOUNT_ID` — owning account. """ use ExUnit.Case, async: false @@ -34,21 +31,10 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do alias Exgit.{ObjectStore, RefStore, Repository, Transport} alias Exgit.Test.{CloudflareArtifacts, RealGit} - setup_all do - name = CloudflareArtifacts.unique_name("exgit-rt") - {:ok, repo_info} = CloudflareArtifacts.create_repo!(name, default_branch: "main") - on_exit(fn -> _ = CloudflareArtifacts.delete_repo!(name) end) - - %{ - repo_name: name, - remote: repo_info.remote, - token: repo_info.token, - default_branch: repo_info.default_branch - } - end - - defp transport(remote, token) do - Transport.HTTP.new(remote, auth: ArtifactsCreds.auth(token)) + defp transport do + Transport.HTTP.new(CloudflareArtifacts.remote(), + auth: ArtifactsCreds.auth(CloudflareArtifacts.token()) + ) end defp unique_branch do @@ -56,7 +42,7 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do "refs/heads/exgit-rt-#{System.system_time(:millisecond)}-#{suffix}" end - # Build a single-file commit. Returns {repo, commit_sha, content}. + # Build a single-file commit. Returns {repo, commit_sha}. defp build_commit(branch, filename, content) do store = ObjectStore.Memory.new() {:ok, blob_sha, store} = ObjectStore.put(store, Blob.new(content)) @@ -90,12 +76,12 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do base end - test "exgit push → exgit clone roundtrips a random blob", ctx do + test "exgit push → exgit clone roundtrips a random blob" do branch = unique_branch() content = :crypto.strong_rand_bytes(4096) {repo, commit_sha} = build_commit(branch, "fixture.bin", content) - t = transport(ctx.remote, ctx.token) + t = transport() assert {:ok, _} = Exgit.push(repo, t, refspecs: [branch]) @@ -109,13 +95,13 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do end @tag :real_git - test "exgit-pushed commit is readable by real git clone + fsck", ctx do + test "exgit-pushed commit is readable by real git clone + fsck" do branch = unique_branch() branch_name = String.trim_leading(branch, "refs/heads/") content = "Pushed by exgit at #{System.system_time(:millisecond)}\n" {repo, _commit_sha} = build_commit(branch, "hello.txt", content) - assert {:ok, _} = Exgit.push(repo, transport(ctx.remote, ctx.token), refspecs: [branch]) + assert {:ok, _} = Exgit.push(repo, transport(), refspecs: [branch]) clone_dir = tmp_dir!("exgit_cf_clone") @@ -125,13 +111,13 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do "git", [ "-c", - "http.extraheader=Authorization: Bearer #{ctx.token}", + "http.extraheader=Authorization: Bearer #{CloudflareArtifacts.token()}", "clone", "--branch", branch_name, "--depth", "1", - ctx.remote, + CloudflareArtifacts.remote(), clone_dir ], stderr_to_stdout: true diff --git a/test/support/cloudflare_artifacts.ex b/test/support/cloudflare_artifacts.ex index 40981df..4693cbb 100644 --- a/test/support/cloudflare_artifacts.ex +++ b/test/support/cloudflare_artifacts.ex @@ -1,187 +1,30 @@ defmodule Exgit.Test.CloudflareArtifacts do @moduledoc """ - Minimal client for the Cloudflare Artifacts REST API, used by the - roundtrip smoketest to create an ephemeral repo, create repo-scoped - tokens, and clean up afterwards. + Test-support shim for the Cloudflare Artifacts smoketest. - Two token types are involved: + The smoketest targets a long-lived persistent repo and authenticates + git wire operations with a repo-scoped token (prefix `art_v1_`). + Both the remote URL and token are injected via env vars — locally + from `.env`, in CI from secrets: - * The Cloudflare API token (`CF_API_TOKEN`, prefix `cfat_`) — used - to authenticate **REST control-plane** calls below. - * Repo tokens (prefix `art_v1_`) — returned by `create_repo!/2` and - `create_token!/3`. Used to authenticate **git wire** operations - against the remote URL. + * `CF_ARTIFACT_REMOTE` — full git wire URL for the test repo + (`https://.artifacts.cloudflare.net/git//.git`). + * `CF_ARTIFACT_TOKEN` — long-lived repo-scoped token for that repo. - Function names mirror the upstream API operations (`Create a repo`, - `Delete a repo`, `Create a token`, `Revoke a token`). - - Secrets come from environment variables: - - * `CF_API_TOKEN` — Cloudflare API token with `Artifacts Read` and - `Artifacts Write` permissions. - * `CF_ACCOUNT_ID` — the account ID that owns the namespace. - - All functions raise on unexpected non-2xx responses. + Because the repo is persistent across runs, tests must use unique + branch names to avoid stomping each other. """ - @namespace "default" - def available? do - System.get_env("CF_API_TOKEN") not in [nil, ""] and - System.get_env("CF_ACCOUNT_ID") not in [nil, ""] - end - - def base_url do - "https://api.cloudflare.com/client/v4/accounts/#{account_id()}/artifacts/namespaces/#{@namespace}" - end - - @doc """ - Create a repo (`POST /repos`). Returns - `{:ok, %{remote: url, token: art_v1_..., ...}}` on success — the - bootstrap token is repo-scoped (write) and is the only time the API - returns a token alongside `create`. For additional tokens use - `create_token!/3`. - - Returns `:already_exists` on 409 (no token returned; caller must - `create_token!`). - """ - def create_repo!(name, opts \\ []) do - body = - %{name: name} - |> maybe_put(:description, Keyword.get(opts, :description)) - |> maybe_put(:default_branch, Keyword.get(opts, :default_branch)) - |> maybe_put(:read_only, Keyword.get(opts, :read_only)) - - resp = - Req.request!( - method: :post, - url: base_url() <> "/repos", - headers: api_headers(), - json: body, - retry: false - ) - - case resp.status do - s when s in 200..299 -> - result = Map.get(resp.body, "result", %{}) - - {:ok, - %{ - id: Map.fetch!(result, "id"), - name: Map.fetch!(result, "name"), - default_branch: Map.fetch!(result, "default_branch"), - remote: Map.fetch!(result, "remote"), - token: Map.fetch!(result, "token") - }} - - 409 -> - :already_exists - - _ -> - raise "create_repo failed: status=#{resp.status} body=#{inspect(resp.body)}" - end - end - - @doc """ - Delete a repo (`DELETE /repos/:name`). The API returns `202 Accepted` - on success. Returns `:ok` on 2xx, `:gone` on 404. - """ - def delete_repo!(name) do - resp = - Req.request!( - method: :delete, - url: base_url() <> "/repos/#{name}", - headers: api_headers(), - retry: false - ) - - case resp.status do - s when s in 200..299 -> :ok - 404 -> :gone - _ -> raise "delete_repo failed: status=#{resp.status} body=#{inspect(resp.body)}" - end - end - - @doc """ - Create a repo-scoped git token (`POST /tokens`). Default scope is - `:write`, default ttl is 3600s. - """ - def create_token!(repo_name, scope \\ :write, ttl \\ 3600) - when scope in [:read, :write] and is_integer(ttl) and ttl > 0 do - resp = - Req.request!( - method: :post, - url: base_url() <> "/tokens", - headers: api_headers(), - json: %{repo: repo_name, scope: to_string(scope), ttl: ttl}, - retry: false - ) - - case resp.status do - s when s in 200..299 -> - token = - resp.body - |> Map.get("result", %{}) - |> Map.get("plaintext") - - if is_binary(token) and token != "", - do: token, - else: raise("create_token: missing plaintext in response: #{inspect(resp.body)}") - - _ -> - raise "create_token failed: status=#{resp.status} body=#{inspect(resp.body)}" - end - end - - @doc """ - Revoke a token by id (`DELETE /tokens/:id`). Returns `:ok` on 2xx, - `:gone` on 404. - """ - def revoke_token!(token_id) do - resp = - Req.request!( - method: :delete, - url: base_url() <> "/tokens/#{token_id}", - headers: api_headers(), - retry: false - ) - - case resp.status do - s when s in 200..299 -> :ok - 404 -> :gone - _ -> raise "revoke_token failed: status=#{resp.status} body=#{inspect(resp.body)}" - end - end - - @doc """ - Generate a unique repo name for a test run. Combines a prefix with - the millisecond timestamp and a short random suffix. - """ - def unique_name(prefix \\ "exgit-test") do - suffix = - :crypto.strong_rand_bytes(4) - |> Base.encode16(case: :lower) - - "#{prefix}-#{System.system_time(:millisecond)}-#{suffix}" - end - - # --- Internal --- - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp account_id do - System.get_env("CF_ACCOUNT_ID") || raise "CF_ACCOUNT_ID not set" + System.get_env("CF_ARTIFACT_REMOTE") not in [nil, ""] and + System.get_env("CF_ARTIFACT_TOKEN") not in [nil, ""] end - defp api_token do - System.get_env("CF_API_TOKEN") || raise "CF_API_TOKEN not set" + def remote do + System.get_env("CF_ARTIFACT_REMOTE") || raise "CF_ARTIFACT_REMOTE not set" end - defp api_headers do - [ - {"authorization", "Bearer " <> api_token()}, - {"content-type", "application/json"} - ] + def token do + System.get_env("CF_ARTIFACT_TOKEN") || raise "CF_ARTIFACT_TOKEN not set" end end From f9c4f403b14b051723b7cbb38208d5d2debe7d48 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Wed, 6 May 2026 15:02:06 -0400 Subject: [PATCH 3/3] ci: dedicated CF Artifacts smoketest step + drop accidental-run tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the live-network cloudflare smoketest: 1. Drop `:real_git` from the second test. ExUnit's tag logic treats include as overriding exclude, so `mix test --include real_git` (used by the extended-tiers step) was pulling in the cloudflare test even though `:cloudflare` is on the default exclude list. The test then immediately raised "CF_ARTIFACT_REMOTE not set" because no secrets were injected into that step. The `:cloudflare` module tag is a sufficient gate; opting in implies `git` on PATH. 2. Add a dedicated CI step that runs `mix test --only cloudflare` with `CF_ARTIFACT_REMOTE` and `CF_ARTIFACT_TOKEN` injected from secrets. Gated push-to-main + primary matrix only, mirroring how `:github_private` is handled — these secrets aren't available on fork PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 11 +++++++++++ test/exgit/cloudflare_artifacts_roundtrip_test.exs | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dd662d..de17234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,3 +148,14 @@ jobs: GITHUB_PRIVATE_TOKEN: ${{ secrets.GITHUB_PRIVATE_TOKEN }} run: mix test --warnings-as-errors --only github_private continue-on-error: true + + - name: Run Cloudflare Artifacts smoketest (live network, secrets) + # Push-to-main only — needs a long-lived repo-scoped artifact + # token that isn't available on fork-based PR runs. Exercises + # the full push → clone → fsck round-trip against a persistent + # `ci` repo on Cloudflare Artifacts. + if: matrix.primary && github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + CF_ARTIFACT_REMOTE: ${{ secrets.CF_ARTIFACT_REMOTE }} + CF_ARTIFACT_TOKEN: ${{ secrets.CF_ARTIFACT_TOKEN }} + run: mix test --warnings-as-errors --only cloudflare diff --git a/test/exgit/cloudflare_artifacts_roundtrip_test.exs b/test/exgit/cloudflare_artifacts_roundtrip_test.exs index 5acc83d..9c0b2bd 100644 --- a/test/exgit/cloudflare_artifacts_roundtrip_test.exs +++ b/test/exgit/cloudflare_artifacts_roundtrip_test.exs @@ -20,7 +20,8 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do across runs. Tagged `:cloudflare`. Run with `mix test --include cloudflare`. - The real-git verification is also tagged `:real_git`. + Test 2 shells out to the `git` binary; opting into `:cloudflare` + implies `git` is on PATH. """ use ExUnit.Case, async: false @@ -94,7 +95,6 @@ defmodule Exgit.CloudflareArtifactsRoundtripTest do assert fetched_blob.data == content end - @tag :real_git test "exgit-pushed commit is readable by real git clone + fsck" do branch = unique_branch() branch_name = String.trim_leading(branch, "refs/heads/")