Skip to content

Commit c8f5a6a

Browse files
committed
chore(release): bump to v0.7.0 — S3 sync live + CI/Rust profiles + moto tests
Round 3/4 wrap-up landing: - Phase E (S3 sync) shipped LIVE per ADR-0001 (boto3 default chain, no implicit auth, secret-pattern filter, endpoint_url for R2/B2/MinIO). 9 moto-based tests verify end-to-end without network. - Profile cleanup: ci profile + rust profile (8 profiles total); settings.local.json now auto-gitignored by init. - ADR-0001 documents the S3 auth-design decision. 303 tests pass; ruff + mypy strict clean across 6 source files; build: ai_config_kit-0.7.0-py3-none-any.whl + .tar.gz. PyPI publish still gated on the trusted publisher config — publish-github will succeed on push as usual.
1 parent 4f7cb03 commit c8f5a6a

3 files changed

Lines changed: 241 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,38 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
66

77
## [Unreleased]
88

9+
## [0.7.0] - 2026-05-16
10+
11+
### Added
12+
13+
- **CI + Rust permission profiles** (`2ea7589`): `profiles list` now
14+
shows 8 profiles. `ci` is permissive on toolchains but strict on
15+
publish + force-push (use inside GitHub Actions / GitLab CI).
16+
`rust` covers cargo + rustfmt + clippy + cargo-audit. `mixed`
17+
picks up Rust + Ruby toolchains.
18+
- **`settings.local.json` auto-gitignored** (`2ea7589`): `init` now
19+
writes the local-override pattern to the content-dir `.gitignore`
20+
so personal overrides don't accidentally land in commits.
21+
- **Verification docs** (`2ea7589`): `docs/profiles.md` documents
22+
the `settings.local.json` merge semantics + the `claude config
23+
list` verification command.
24+
- **S3 sync implementation** (`4f7cb03`, ADR-0001): `ClaudeConfig.sync_to_s3(...)`
25+
is now a live upload. boto3's default credential chain, no
26+
implicit auth (caller must set AWS_PROFILE, pass profile=NAME,
27+
or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY). Secret-pattern
28+
files (.env, *.credentials.json) filtered out. New CLI:
29+
`ai-config-kit s3-sync --target s3://... [--profile] [--endpoint-url] [--apply]`.
30+
- **moto-based tests** for S3 sync (this commit): 9 test cases
31+
cover the auth gate, dry-run, real upload via mock, root-prefix,
32+
secret filtering, endpoint_url passthrough, and audit-event
33+
emission. Adds `moto[s3]>=5.0` + `boto3>=1.34` to `[dev]` extras.
34+
35+
### Architecture decisions
36+
37+
- **ADR-0001** (`docs/adr/0001-s3-auth-design.md`) records the
38+
credential-chain decision, the "no implicit auth" gate, and the
39+
GitHub Actions OIDC flow for CI.
40+
941
## [0.6.0] - 2026-05-16
1042

1143
### Added

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "ai-config-kit"
7-
version = "0.6.0"
7+
version = "0.7.0"
88
description = "Manage ~/.claude and cross-vendor AI agent rules: 7-vendor adapters, decision packs, AGENTS.md composition."
99
readme = "README.md"
1010
license = { file = "LICENSE" }
@@ -36,6 +36,8 @@ dev = [
3636
"pytest-mock>=3.14",
3737
"ruff>=0.9",
3838
"mypy>=1.13",
39+
"boto3>=1.34", # for S3-sync tests (mirrors [s3] extras)
40+
"moto[s3]>=5.0", # in-process S3 mock for sync_to_s3 tests
3941
]
4042
# `pip install 'ai-config-kit[s3]'` enables the future S3 sync target
4143
# (SPEC Phase E). The skeleton is wired today; full implementation is

tests/test_s3_sync.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Tests for the S3 sync (SPEC Phase E + ADR-0001).
2+
3+
Uses moto's in-process S3 mock so the tests run offline + don't
4+
need real AWS credentials. Per ADR-0001, the function refuses to
5+
upload without explicit auth — we set fake AWS creds via monkeypatch
6+
to satisfy that gate.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
# moto[s3] required; declared in [dev] extras
14+
moto = pytest.importorskip("moto")
15+
boto3 = pytest.importorskip("boto3")
16+
17+
from ai_config_kit import ClaudeConfig, ConfigError, S3SyncReport # noqa: E402
18+
19+
20+
@pytest.fixture
21+
def mock_aws_creds(monkeypatch: pytest.MonkeyPatch) -> None:
22+
"""Set fake AWS env vars so the auth gate passes."""
23+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
24+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
25+
monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
26+
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
27+
monkeypatch.delenv("AWS_PROFILE", raising=False)
28+
29+
30+
# --- auth-gate path ------------------------------------------------------
31+
32+
33+
def test_sync_to_s3_refuses_without_auth(
34+
cfg: ClaudeConfig, monkeypatch: pytest.MonkeyPatch
35+
) -> None:
36+
monkeypatch.delenv("AWS_PROFILE", raising=False)
37+
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
38+
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
39+
with pytest.raises(ConfigError, match="no AWS auth"):
40+
cfg.sync_to_s3("s3://bucket/path", dry_run=True)
41+
42+
43+
def test_sync_to_s3_refuses_non_s3_target(
44+
cfg: ClaudeConfig, mock_aws_creds: None
45+
) -> None:
46+
with pytest.raises(ConfigError, match="must be s3"):
47+
cfg.sync_to_s3("https://example.com/", dry_run=True)
48+
49+
50+
def test_sync_to_s3_refuses_target_without_bucket(
51+
cfg: ClaudeConfig, mock_aws_creds: None
52+
) -> None:
53+
with pytest.raises(ConfigError, match="missing bucket"):
54+
cfg.sync_to_s3("s3:///key-only", dry_run=True)
55+
56+
57+
# --- dry-run + actual upload via moto ------------------------------------
58+
59+
60+
def test_sync_to_s3_dry_run_no_upload(
61+
cfg: ClaudeConfig, mock_aws_creds: None
62+
) -> None:
63+
cfg.src_dir.mkdir(parents=True, exist_ok=True)
64+
(cfg.src_dir / "CLAUDE.md").write_text("test content\n", encoding="utf-8")
65+
r = cfg.sync_to_s3("s3://my-bucket/prefix", dry_run=True)
66+
assert r.dry_run
67+
assert r.uploaded
68+
assert r.bytes_transferred == 0 # dry-run uploaded nothing
69+
70+
71+
def test_sync_to_s3_real_upload_via_moto(
72+
cfg: ClaudeConfig, mock_aws_creds: None
73+
) -> None:
74+
from moto import mock_aws
75+
76+
cfg.src_dir.mkdir(parents=True, exist_ok=True)
77+
(cfg.src_dir / "CLAUDE.md").write_text("a CLAUDE.md\n", encoding="utf-8")
78+
(cfg.src_dir / "commands").mkdir(exist_ok=True)
79+
(cfg.src_dir / "commands" / "demo.md").write_text("a command\n", encoding="utf-8")
80+
81+
with mock_aws():
82+
s3 = boto3.client("s3", region_name="us-east-1")
83+
s3.create_bucket(Bucket="my-bucket")
84+
85+
r = cfg.sync_to_s3("s3://my-bucket/prefix", dry_run=False)
86+
87+
# Report: both files uploaded, bytes > 0
88+
assert isinstance(r, S3SyncReport)
89+
assert not r.dry_run
90+
assert sorted(r.uploaded) == ["CLAUDE.md", "commands/demo.md"]
91+
assert r.bytes_transferred > 0
92+
93+
# And actually present in the (mocked) bucket.
94+
listed = {
95+
obj["Key"] for obj in s3.list_objects_v2(Bucket="my-bucket").get("Contents", [])
96+
}
97+
assert "prefix/CLAUDE.md" in listed
98+
assert "prefix/commands/demo.md" in listed
99+
100+
101+
def test_sync_to_s3_root_no_prefix(
102+
cfg: ClaudeConfig, mock_aws_creds: None
103+
) -> None:
104+
"""Target without a trailing key-prefix lands files at bucket root."""
105+
from moto import mock_aws
106+
107+
cfg.src_dir.mkdir(parents=True, exist_ok=True)
108+
(cfg.src_dir / "CLAUDE.md").write_text("x", encoding="utf-8")
109+
110+
with mock_aws():
111+
s3 = boto3.client("s3", region_name="us-east-1")
112+
s3.create_bucket(Bucket="test-bucket")
113+
cfg.sync_to_s3("s3://test-bucket", dry_run=False)
114+
keys = {
115+
obj["Key"] for obj in s3.list_objects_v2(Bucket="test-bucket").get("Contents", [])
116+
}
117+
assert "CLAUDE.md" in keys
118+
assert all("prefix" not in k for k in keys)
119+
120+
121+
def test_sync_to_s3_filters_secret_files(
122+
cfg: ClaudeConfig, mock_aws_creds: None
123+
) -> None:
124+
"""Files matching the secret patterns are NEVER uploaded."""
125+
from moto import mock_aws
126+
127+
cfg.src_dir.mkdir(parents=True, exist_ok=True)
128+
(cfg.src_dir / "CLAUDE.md").write_text("safe", encoding="utf-8")
129+
(cfg.src_dir / ".env").write_text("API_KEY=hunter2", encoding="utf-8")
130+
(cfg.src_dir / ".credentials.json").write_text('{"token":"x"}', encoding="utf-8")
131+
132+
with mock_aws():
133+
s3 = boto3.client("s3", region_name="us-east-1")
134+
s3.create_bucket(Bucket="test-bucket")
135+
r = cfg.sync_to_s3("s3://test-bucket/cfg", dry_run=False)
136+
137+
assert "CLAUDE.md" in r.uploaded
138+
assert ".env" not in r.uploaded
139+
assert ".credentials.json" not in r.uploaded
140+
assert any(".env" in s for s in r.skipped_secrets)
141+
assert any("credentials" in s for s in r.skipped_secrets)
142+
143+
listed = {
144+
obj["Key"] for obj in s3.list_objects_v2(Bucket="test-bucket").get("Contents", [])
145+
}
146+
# Confirm the bucket only has the safe file (under the prefix).
147+
assert listed == {"cfg/CLAUDE.md"}
148+
149+
150+
def test_sync_to_s3_endpoint_url_passes_through(
151+
cfg: ClaudeConfig, mock_aws_creds: None, monkeypatch: pytest.MonkeyPatch
152+
) -> None:
153+
"""endpoint_url is forwarded to boto3.Session.client(...).
154+
155+
Moto only intercepts AWS-style endpoints, so we can't end-to-end
156+
test a non-AWS upload without a separate mock. Instead, capture
157+
the kwargs handed to `client()` and assert endpoint_url is wired.
158+
"""
159+
captured: dict[str, object] = {}
160+
161+
real_session_client = boto3.Session.client
162+
163+
def fake_client(self, service_name, **kwargs): # type: ignore[no-untyped-def]
164+
captured["service_name"] = service_name
165+
captured["endpoint_url"] = kwargs.get("endpoint_url")
166+
return real_session_client(self, service_name, **kwargs)
167+
168+
monkeypatch.setattr(boto3.Session, "client", fake_client)
169+
170+
cfg.src_dir.mkdir(parents=True, exist_ok=True)
171+
(cfg.src_dir / "CLAUDE.md").write_text("x", encoding="utf-8")
172+
173+
# Dry-run so we don't need to mock the actual upload.
174+
cfg.sync_to_s3(
175+
"s3://test-bucket/k",
176+
endpoint_url="https://minio.example.com",
177+
dry_run=True,
178+
)
179+
assert captured["service_name"] == "s3"
180+
assert captured["endpoint_url"] == "https://minio.example.com"
181+
182+
183+
def test_sync_to_s3_audit_event_recorded(
184+
cfg: ClaudeConfig, mock_aws_creds: None
185+
) -> None:
186+
"""A real upload emits a sync_to_s3 audit event."""
187+
import json as _json
188+
189+
from moto import mock_aws
190+
191+
cfg.src_dir.mkdir(parents=True, exist_ok=True)
192+
(cfg.src_dir / "CLAUDE.md").write_text("x", encoding="utf-8")
193+
194+
with mock_aws():
195+
s3 = boto3.client("s3", region_name="us-east-1")
196+
s3.create_bucket(Bucket="test-bucket")
197+
cfg.sync_to_s3("s3://test-bucket/k", dry_run=False)
198+
199+
log = cfg.audit_log_path
200+
assert log.is_file()
201+
lines = [_json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line]
202+
events = [r for r in lines if r["event"] == "sync_to_s3"]
203+
assert events
204+
e = events[-1]
205+
assert e["target"] == "s3://test-bucket/k"
206+
assert e["files"] == 1

0 commit comments

Comments
 (0)