insightfactory-cli 1.1.1__tar.gz → 1.1.2.dev28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/CHANGELOG.md +15 -2
  2. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/CLAUDE.md +10 -6
  3. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/PKG-INFO +23 -11
  4. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/README.md +22 -10
  5. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/pyproject.toml +1 -1
  6. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_release_tools.py +136 -8
  7. insightfactory_cli-1.1.2.dev28/tools/check_release.py +163 -0
  8. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/uv.lock +1 -1
  9. insightfactory_cli-1.1.1/tools/check_release.py +0 -79
  10. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/.github/workflows/ci.yml +0 -0
  11. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/.github/workflows/claude.yml +0 -0
  12. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/.github/workflows/release.yml +0 -0
  13. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/.gitignore +0 -0
  14. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/.python-version +0 -0
  15. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/AGENTS.md +0 -0
  16. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/LICENSE +0 -0
  17. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/__init__.py +0 -0
  18. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/__main__.py +0 -0
  19. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/assets/__init__.py +0 -0
  20. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  21. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/cache.py +0 -0
  22. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/callback_page.py +0 -0
  23. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/cli.py +0 -0
  24. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/colour.py +0 -0
  25. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/__init__.py +0 -0
  26. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/api.py +0 -0
  27. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/config.py +0 -0
  28. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/login.py +0 -0
  29. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/logout.py +0 -0
  30. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/mcp.py +0 -0
  31. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/profiles.py +0 -0
  32. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/set_token.py +0 -0
  33. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/commands/token.py +0 -0
  34. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/config.py +0 -0
  35. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/constants.py +0 -0
  36. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/http.py +0 -0
  37. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/main.py +0 -0
  38. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/oauth.py +0 -0
  39. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/router/__init__.py +0 -0
  40. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/router/catalog.py +0 -0
  41. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/router/log.py +0 -0
  42. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/router/policy.py +0 -0
  43. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/router/server.py +0 -0
  44. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/router/upstream.py +0 -0
  45. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/src/if_cli/runtime.py +0 -0
  46. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/__init__.py +0 -0
  47. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/cache_writer.py +0 -0
  48. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/conftest.py +0 -0
  49. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/helpers.py +0 -0
  50. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/servers.py +0 -0
  51. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_api.py +0 -0
  52. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_api_command.py +0 -0
  53. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_cache.py +0 -0
  54. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_cli.py +0 -0
  55. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_config.py +0 -0
  56. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_config_command.py +0 -0
  57. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_login.py +0 -0
  58. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_mcp_command.py +0 -0
  59. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_oauth.py +0 -0
  60. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_oauth_flow.py +0 -0
  61. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_oauth_force_refresh.py +0 -0
  62. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_profiles.py +0 -0
  63. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_programmatic_api.py +0 -0
  64. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_router_catalog.py +0 -0
  65. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_router_policy.py +0 -0
  66. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_router_server.py +0 -0
  67. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_router_upstream.py +0 -0
  68. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_runtime.py +0 -0
  69. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_set_token.py +0 -0
  70. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tests/test_token.py +0 -0
  71. {insightfactory_cli-1.1.1 → insightfactory_cli-1.1.2.dev28}/tools/release_notes.py +0 -0
@@ -7,8 +7,21 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  The release job reads the section whose heading matches the version in
9
9
  `pyproject.toml` and makes it the body of the GitHub Release. CI holds both ends of
10
- that: a PR into `develop` or `main` must raise the version and add its section here,
11
- and a tag whose version has no section fails rather than releasing an empty page.
10
+ that: every PR into `develop` must add an entry here, and a tag whose version has no
11
+ section fails rather than releasing an empty page. The version only moves when the
12
+ last one has been released, so a cycle's PRs share a section.
13
+
14
+ ## 1.1.2
15
+
16
+ ### Changed
17
+
18
+ - `release-guard` asks every PR into `develop` for a `CHANGELOG.md` entry, and asks
19
+ for a version bump only when `develop` is still on the version `main` is on. One
20
+ bump opens a release cycle and the PRs after it add to the section it opened,
21
+ rather than each minting a version that is never tagged.
22
+ - The guard now measures a version against `main` as well as the base branch. A bump
23
+ has to land above the released version, not merely above the base, and a PR into an
24
+ open cycle has to add a line rather than reword one.
12
25
 
13
26
  ## 1.1.1
14
27
 
@@ -34,9 +34,13 @@ uv run ty check
34
34
  match `pyproject.toml`. Pushing `develop` publishes a `.dev{run_number}`
35
35
  pre-release. Creating the release tag is a deliberate human act — do not create
36
36
  or push one unless asked to cut a release.
37
- - **Every PR into `develop` or `main` raises `version` in `pyproject.toml` and
38
- documents that version in `CHANGELOG.md`.** The `release-guard` job runs
39
- `tools/check_release.py` and fails the PR otherwise. Put user-visible changes in
40
- the Added/Changed/Fixed group that fits; for internal-only work (tests, CI, a
41
- refactor nothing observes) a one-line entry saying so is enough. The section
42
- becomes the body of that version's GitHub Release.
37
+ - **Every PR into `develop` adds a `CHANGELOG.md` entry**, in the Added/Changed/Fixed
38
+ group that fits. For internal-only work (tests, CI, a refactor nothing observes) a
39
+ one-line entry saying so is enough. The section becomes the body of that version's
40
+ GitHub Release.
41
+ - **Raise `version` in `pyproject.toml` only when `develop` is on the same version as
42
+ `main`.** That version is released, so a new entry under it would describe a change
43
+ it does not contain: bump, and open a section for the new version. While `develop`
44
+ is ahead of `main` the cycle is open and PRs add to the section already there. A PR
45
+ into `main` always raises the version. The `release-guard` job runs
46
+ `tools/check_release.py` and fails the PR otherwise.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.1.1
3
+ Version: 1.1.2.dev28
4
4
  Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
5
  Project-URL: Homepage, https://insightfactory.ai
6
6
  Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
@@ -354,20 +354,32 @@ artifact, so its notes belong on that Release. A checkout with no tags falls bac
354
354
  to the tagged version's section alone.
355
355
 
356
356
  `develop` therefore carries the version the next release will use, and the
357
- `release-guard` job holds it there. A PR into `develop` must raise `version` in
358
- `pyproject.toml` above what `develop` already has, and must add that version's
359
- `CHANGELOG.md` section. `tools/check_release.py` runs both checks and is what
360
- fails the PR:
357
+ `release-guard` job holds it there. `tools/check_release.py` runs the checks and
358
+ is what fails the PR:
361
359
 
362
360
  ```bash
363
- BASE=origin/develop python3 tools/check_release.py
361
+ BASE=origin/develop RELEASED=origin/main python3 tools/check_release.py
364
362
  ```
365
363
 
366
- One version per PR, then. The instinct is to let a second change join the
367
- section the first one opened, and the guard refuses that: the version would be
368
- tagged with notes that do not describe everything in it, and nothing stops the
369
- tag going out between the two merges. Bump the patch number again, or fold the
370
- second change into the first PR.
364
+ **Every PR into `develop` adds a `CHANGELOG.md` entry.** That is the whole point
365
+ of the file, and a change with no entry reaches users with no line anywhere
366
+ saying it happened.
367
+
368
+ **The version moves once per release cycle, not once per PR.** The first PR after
369
+ a release raises `version` in `pyproject.toml` and opens that version's section;
370
+ the rest of the cycle adds to the section it opened. The guard asks for the bump
371
+ only while `develop` is still on the version `main` is on, because that version
372
+ is released and its notes describe something your change is not in.
373
+
374
+ The comparison is against `main` rather than against the tag, which matters in
375
+ the window where the release PR has merged but the tag is not pushed yet. What a
376
+ version contains is fixed when it reaches `main`, so a change landing after that
377
+ belongs to the next version, whatever the tags say.
378
+
379
+ A PR into `main` always needs the bump. It carries no new entries, only the
380
+ version they were written under, and `main` exists to be tagged: leaving it on
381
+ `main`'s own version makes it un-taggable, since that version is on PyPI and any
382
+ other tag matches no file.
371
383
 
372
384
  Verify and build live in the reusable workflow
373
385
  [`if_s_insightfactory_cli.yml`](https://github.com/insightfactory-ai/if_sre_github_actions/blob/main/.github/workflows/if_s_insightfactory_cli.yml)
@@ -330,20 +330,32 @@ artifact, so its notes belong on that Release. A checkout with no tags falls bac
330
330
  to the tagged version's section alone.
331
331
 
332
332
  `develop` therefore carries the version the next release will use, and the
333
- `release-guard` job holds it there. A PR into `develop` must raise `version` in
334
- `pyproject.toml` above what `develop` already has, and must add that version's
335
- `CHANGELOG.md` section. `tools/check_release.py` runs both checks and is what
336
- fails the PR:
333
+ `release-guard` job holds it there. `tools/check_release.py` runs the checks and
334
+ is what fails the PR:
337
335
 
338
336
  ```bash
339
- BASE=origin/develop python3 tools/check_release.py
337
+ BASE=origin/develop RELEASED=origin/main python3 tools/check_release.py
340
338
  ```
341
339
 
342
- One version per PR, then. The instinct is to let a second change join the
343
- section the first one opened, and the guard refuses that: the version would be
344
- tagged with notes that do not describe everything in it, and nothing stops the
345
- tag going out between the two merges. Bump the patch number again, or fold the
346
- second change into the first PR.
340
+ **Every PR into `develop` adds a `CHANGELOG.md` entry.** That is the whole point
341
+ of the file, and a change with no entry reaches users with no line anywhere
342
+ saying it happened.
343
+
344
+ **The version moves once per release cycle, not once per PR.** The first PR after
345
+ a release raises `version` in `pyproject.toml` and opens that version's section;
346
+ the rest of the cycle adds to the section it opened. The guard asks for the bump
347
+ only while `develop` is still on the version `main` is on, because that version
348
+ is released and its notes describe something your change is not in.
349
+
350
+ The comparison is against `main` rather than against the tag, which matters in
351
+ the window where the release PR has merged but the tag is not pushed yet. What a
352
+ version contains is fixed when it reaches `main`, so a change landing after that
353
+ belongs to the next version, whatever the tags say.
354
+
355
+ A PR into `main` always needs the bump. It carries no new entries, only the
356
+ version they were written under, and `main` exists to be tagged: leaving it on
357
+ `main`'s own version makes it un-taggable, since that version is on PyPI and any
358
+ other tag matches no file.
347
359
 
348
360
  Verify and build live in the reusable workflow
349
361
  [`if_s_insightfactory_cli.yml`](https://github.com/insightfactory-ai/if_sre_github_actions/blob/main/.github/workflows/if_s_insightfactory_cli.yml)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.1.1"
3
+ version = "1.1.2.dev28"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -296,31 +296,38 @@ def test_main_still_requires_the_changelog_on_the_opt_out(changelog, monkeypatch
296
296
  check_release.main()
297
297
 
298
298
 
299
- @pytest.mark.parametrize(("base", "expected_pass"), [("9.9.8", True), ("9.9.9", False), ("10.0.0", False)])
300
- def test_main_compares_against_the_base_branch(changelog, monkeypatch, base, expected_pass):
299
+ @pytest.mark.parametrize(
300
+ ("base", "error"),
301
+ [("9.9.8", None), ("9.9.9", "has to raise the version"), ("10.0.0", "below origin/main's")],
302
+ )
303
+ def test_main_requires_a_bump_without_released(changelog, monkeypatch, base, error):
304
+ """No RELEASED is a PR into the release branch: the version must always move."""
301
305
  changelog("# Changelog\n\n## 9.9.9\n\n- a thing\n")
302
- monkeypatch.setenv("BASE", "origin/develop")
306
+ monkeypatch.setenv("BASE", "origin/main")
307
+ monkeypatch.delenv("RELEASED", raising=False)
303
308
  monkeypatch.setattr(check_release, "base_version", lambda _: base)
304
- if expected_pass:
309
+ if error is None:
305
310
  assert check_release.main() == 0
306
311
  else:
307
- with pytest.raises(SystemExit, match="under notes written for other work"):
312
+ with pytest.raises(SystemExit, match=error):
308
313
  check_release.main()
309
314
 
310
315
 
311
316
  def test_main_treats_a_short_base_version_as_padded(changelog, monkeypatch):
312
317
  """9.9 and 9.9.0 are the same release, so 9.9.9 is still a bump over 9.9."""
313
318
  changelog("# Changelog\n\n## 9.9.9\n\n- a thing\n")
314
- monkeypatch.setenv("BASE", "origin/develop")
319
+ monkeypatch.setenv("BASE", "origin/main")
320
+ monkeypatch.delenv("RELEASED", raising=False)
315
321
  monkeypatch.setattr(check_release, "base_version", lambda _: "9.9")
316
322
  assert check_release.main() == 0
317
323
 
318
324
 
319
325
  def test_main_refuses_an_equal_short_base_version(changelog, monkeypatch):
320
326
  changelog("# Changelog\n\n## 9.9\n\n- a thing\n", version="9.9")
321
- monkeypatch.setenv("BASE", "origin/develop")
327
+ monkeypatch.setenv("BASE", "origin/main")
328
+ monkeypatch.delenv("RELEASED", raising=False)
322
329
  monkeypatch.setattr(check_release, "base_version", lambda _: "9.9.0")
323
- with pytest.raises(SystemExit, match="under notes written for other work"):
330
+ with pytest.raises(SystemExit, match="has to raise the version"):
324
331
  check_release.main()
325
332
 
326
333
 
@@ -488,3 +495,124 @@ def test_versions_in_changelog_also_refuses_an_unclosed_fence():
488
495
  """Strictness is a property of the module, not of which entry point you call."""
489
496
  with pytest.raises(SystemExit, match="never closed"):
490
497
  release_notes.versions_in_changelog("# Changelog\n\n## 2.0.0\n\n```bash\nx\n")
498
+
499
+
500
+ # ── a bump once a cycle, an entry every PR ────────────────────────────────────
501
+
502
+
503
+ @pytest.fixture
504
+ def refs(monkeypatch):
505
+ """Stand in for the two branches the guard reads, as a PR into develop."""
506
+
507
+ def setup(develop_version: str, main_version: str, develop_changelog: str) -> None:
508
+ monkeypatch.setenv("BASE", "origin/develop")
509
+ monkeypatch.setenv("RELEASED", "origin/main")
510
+ monkeypatch.setattr(
511
+ check_release,
512
+ "base_version",
513
+ lambda ref: develop_version if ref == "origin/develop" else main_version,
514
+ )
515
+ monkeypatch.setattr(
516
+ check_release,
517
+ "file_at",
518
+ lambda ref, path: develop_changelog,
519
+ )
520
+
521
+ return setup
522
+
523
+
524
+ def test_open_cycle_accepts_a_new_entry_without_a_bump(changelog, refs):
525
+ """develop is ahead of main, so 9.9.9's section is still open to add to."""
526
+ changelog("# Changelog\n\n## 9.9.9\n\n- an earlier entry\n- what this PR did\n")
527
+ refs("9.9.9", "9.9.8", "# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
528
+ assert check_release.main() == 0
529
+
530
+
531
+ def test_open_cycle_refuses_an_unchanged_section(changelog, refs):
532
+ """The bump is not required, so the entry is what this PR owes instead."""
533
+ changelog("# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
534
+ refs("9.9.9", "9.9.8", "# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
535
+ with pytest.raises(SystemExit, match="adds nothing to origin/develop's"):
536
+ check_release.main()
537
+
538
+
539
+ def test_released_version_still_requires_a_bump(changelog, refs):
540
+ """develop is on what main is on, so 9.9.9 is released and its notes are fixed."""
541
+ changelog("# Changelog\n\n## 9.9.9\n\n- an earlier entry\n- what this PR did\n")
542
+ refs("9.9.9", "9.9.9", "# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
543
+ with pytest.raises(SystemExit, match="origin/main is on 9.9.9: that version is released"):
544
+ check_release.main()
545
+
546
+
547
+ def test_a_bump_satisfies_the_guard_whatever_the_cycle(changelog, refs):
548
+ """Deliberately jumping the version mid-cycle stays allowed."""
549
+ changelog("# Changelog\n\n## 9.9.9\n\n- a thing\n")
550
+ refs("9.9.8", "9.9.7", "# Changelog\n\n## 9.9.8\n\n- older\n")
551
+ assert check_release.main() == 0
552
+
553
+
554
+ def test_a_bump_off_the_released_version_is_the_cycle_opening(changelog, refs):
555
+ changelog("# Changelog\n\n## 9.9.9\n\n- opens the cycle\n")
556
+ refs("9.9.8", "9.9.8", "# Changelog\n\n## 9.9.8\n\n- released\n")
557
+ assert check_release.main() == 0
558
+
559
+
560
+ def test_a_lowered_version_is_refused(changelog, refs):
561
+ changelog("# Changelog\n\n## 9.9.7\n\n- a thing\n", version="9.9.7")
562
+ refs("9.9.9", "9.9.8", "# Changelog\n\n## 9.9.9\n\n- newer\n")
563
+ with pytest.raises(SystemExit, match="below origin/develop's"):
564
+ check_release.main()
565
+
566
+
567
+ def test_a_version_at_or_below_the_released_one_is_refused(changelog, refs):
568
+ """A bump has to clear main, not just develop: 9.9.9's notes are already published."""
569
+ changelog("# Changelog\n\n## 9.9.9\n\n- a thing\n")
570
+ refs("9.9.8", "9.9.9", "# Changelog\n\n## 9.9.8\n\n- older\n")
571
+ with pytest.raises(SystemExit, match="not above origin/main's 9.9.9"):
572
+ check_release.main()
573
+
574
+
575
+ def test_the_released_comparison_pads_to_the_wider_version(changelog, refs):
576
+ """9.9 and 9.9.0 are the same release, so this is not a bump off it."""
577
+ changelog("# Changelog\n\n## 9.9\n\n- a thing\n", version="9.9")
578
+ refs("9.8", "9.9.0", "# Changelog\n\n## 9.8\n\n- older\n")
579
+ with pytest.raises(SystemExit, match="not above origin/main's 9.9.0"):
580
+ check_release.main()
581
+
582
+
583
+ def test_a_reformatted_section_adds_nothing(changelog, refs):
584
+ """Re-indenting an entry is not writing one."""
585
+ changelog("# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
586
+ refs("9.9.9", "9.9.8", "# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
587
+ with pytest.raises(SystemExit, match="adds nothing to origin/develop's"):
588
+ check_release.main()
589
+
590
+
591
+ def test_deleting_an_entry_is_not_adding_one(changelog, refs):
592
+ changelog("# Changelog\n\n## 9.9.9\n\n- an earlier entry\n")
593
+ refs("9.9.9", "9.9.8", "# Changelog\n\n## 9.9.9\n\n- an earlier entry\n- a second entry\n")
594
+ with pytest.raises(SystemExit, match="adds nothing to origin/develop's"):
595
+ check_release.main()
596
+
597
+
598
+ def test_section_at_reports_an_unreadable_ref(monkeypatch):
599
+ """A git failure is not the same as a missing section, and must not be swallowed."""
600
+
601
+ def boom(ref, path):
602
+ raise SystemExit(f"error: cannot read {path} at {ref}")
603
+
604
+ monkeypatch.setattr(check_release, "file_at", boom)
605
+ with pytest.raises(SystemExit, match="cannot read CHANGELOG.md"):
606
+ check_release.section_at("origin/develop", "9.9.9")
607
+
608
+
609
+ def test_section_at_returns_none_when_the_base_has_no_section(monkeypatch):
610
+ monkeypatch.setattr(check_release, "file_at", lambda ref, path: "# Changelog\n\n## 1.0.0\n\n- old\n")
611
+ assert check_release.section_at("origin/develop", "9.9.9") is None
612
+
613
+
614
+ def test_a_first_entry_for_a_version_the_base_lacks_counts_as_changed(changelog, refs):
615
+ """The section is new on this branch, so it cannot match the base's."""
616
+ changelog("# Changelog\n\n## 9.9.9\n\n- the first entry\n")
617
+ refs("9.9.9", "9.9.8", "# Changelog\n\n## 1.0.0\n\n- unrelated\n")
618
+ assert check_release.main() == 0
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env python3
2
+ """Refuse a change that nobody would be able to find in the release notes.
3
+
4
+ Run by the `release-guard` CI job. Every PR must say what it did; only some have
5
+ to move the version to say it.
6
+
7
+ * The version in pyproject.toml always has a CHANGELOG.md section.
8
+ * A PR whose base is still on the released version has to raise the version and
9
+ open a section of its own. Any version it moves to is above the released one.
10
+ * Any other PR into an open cycle has to add a line to the section already there.
11
+
12
+ A cycle is open while the base branch is ahead of RELEASED, the branch releases are
13
+ tagged from. Anchoring on that branch rather than on the tag matters in the window
14
+ where a release PR has merged but the tag is not pushed yet: the contents of that
15
+ version are fixed at the merge, so a later change belongs to the next one, and
16
+ appending to it would ship a Release describing something it does not contain.
17
+
18
+ BASE names the ref to compare against (`origin/develop`). A push has no base to
19
+ diff, so it passes `BASE=none` and gets the CHANGELOG check alone. BASE unset is an
20
+ error rather than that same skip: these are the checks a human cannot replicate by
21
+ reading the diff, and a renamed or mis-indented `env:` key should fail the job
22
+ rather than quietly turn it into a no-op.
23
+
24
+ RELEASED names the release branch (`origin/main`), and is what makes the bump
25
+ conditional. Left unset, every PR must bump, which is what a PR into the release
26
+ branch itself wants: it carries no new entries, only the version they were written
27
+ under.
28
+
29
+ Stdlib only: this runs on the CI runner's system python3, outside the project venv.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import subprocess
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ sys.path.append(str(Path(__file__).resolve().parent))
40
+
41
+ import release_notes # noqa: E402
42
+ from release_notes import changelog_section, current_version, parse_version, release_parts, version_key # noqa: E402
43
+
44
+ NO_BASE = "none"
45
+
46
+
47
+ def file_at(ref: str, path: str) -> str:
48
+ result = subprocess.run(
49
+ ["git", "show", f"{ref}:{path}"],
50
+ cwd=release_notes.ROOT,
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+ if result.returncode != 0:
55
+ raise SystemExit(
56
+ f"error: cannot read {path} at {ref}: {result.stderr.strip()}\n"
57
+ f"If this is CI, the checkout needs fetch-depth: 0 for {ref} to exist locally."
58
+ )
59
+ return result.stdout
60
+
61
+
62
+ def base_version(base: str) -> str:
63
+ return parse_version(file_at(base, "pyproject.toml"), where=f"pyproject.toml at {base}")
64
+
65
+
66
+ def section_at(ref: str, version: str) -> str | None:
67
+ """That version's CHANGELOG section on `ref`, or None if it has none yet."""
68
+ text = file_at(ref, "CHANGELOG.md")
69
+ try:
70
+ return release_notes.changelog_section(version, text)
71
+ except SystemExit:
72
+ return None
73
+
74
+
75
+ def entry_lines(section: str | None) -> set[str]:
76
+ """The section's entries, as comparable lines.
77
+
78
+ Whitespace is collapsed and headings dropped, so re-indenting or re-wrapping an
79
+ entry does not read as writing one.
80
+ """
81
+ if section is None:
82
+ return set()
83
+ lines = (" ".join(line.split()) for line in section.splitlines())
84
+ return {line for line in lines if line and not line.startswith("#")}
85
+
86
+
87
+ def main() -> int:
88
+ version = current_version()
89
+
90
+ # Raises if the section is missing or empty, with the message to act on.
91
+ section = changelog_section(version)
92
+ print(f"CHANGELOG.md documents {version}")
93
+
94
+ base = os.environ.get("BASE", "").strip()
95
+ if not base:
96
+ raise SystemExit(
97
+ f"error: BASE is unset. Pass the ref to compare against, "
98
+ f"or BASE={NO_BASE} for a run that genuinely has no base branch."
99
+ )
100
+ if base == NO_BASE:
101
+ print(f"BASE={NO_BASE}, skipping the version-bump check")
102
+ return 0
103
+
104
+ previous = base_version(base)
105
+ released_ref = os.environ.get("RELEASED", "").strip()
106
+ released = base_version(released_ref) if released_ref else None
107
+
108
+ widths = [len(release_parts(version)), len(release_parts(previous))]
109
+ if released is not None:
110
+ widths.append(len(release_parts(released)))
111
+ width = max(widths)
112
+
113
+ def key(value: str) -> tuple[int, ...]:
114
+ return version_key(value, width)
115
+
116
+ if key(version) < key(previous):
117
+ raise SystemExit(
118
+ f"error: pyproject.toml is at {version}, below {base}'s {previous}. "
119
+ f"Rebase on {base} rather than lowering the version."
120
+ )
121
+
122
+ if released is not None and key(version) <= key(released):
123
+ # Every version this repo can still write notes for is above the released one,
124
+ # whichever way the base branch got to where it is.
125
+ if key(version) == key(previous):
126
+ raise SystemExit(
127
+ f"error: pyproject.toml is at {version}, the same as {base}'s, and {released_ref} is "
128
+ f"on {released}: that version is released. Raise the version and open a CHANGELOG.md "
129
+ f"section for it."
130
+ )
131
+ raise SystemExit(
132
+ f"error: pyproject.toml is at {version}, which is not above {released_ref}'s {released}. "
133
+ f"{released} is released, so its notes are fixed. Raise the version past it."
134
+ )
135
+
136
+ if key(version) > key(previous):
137
+ # A bump always satisfies the guard. The section check above already made
138
+ # sure the version it opens is documented and not empty.
139
+ print(f"version {version} is above {base}'s {previous}")
140
+ return 0
141
+
142
+ if released is None:
143
+ raise SystemExit(
144
+ f"error: pyproject.toml is at {version}, the same as {base}'s. "
145
+ f"A PR into {base} has to raise the version to the one its entries were written under."
146
+ )
147
+
148
+ # The cycle is open, so no bump. The entry is what this PR owes instead. The
149
+ # checkout is the merge ref, so the working tree section holds the base's entries
150
+ # as well as this PR's: anything not on the base tip is what this PR added.
151
+ if not entry_lines(section) - entry_lines(section_at(base, version)):
152
+ raise SystemExit(
153
+ f"error: the CHANGELOG.md section for {version} adds nothing to {base}'s. "
154
+ f"{version} is unreleased, so no version bump is needed, but this change still has to "
155
+ f"say what it did. Add an entry under `## {version}`."
156
+ )
157
+
158
+ print(f"{version} is unreleased ({released_ref} is on {released}) and this PR adds to its section")
159
+ return 0
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main())
@@ -305,7 +305,7 @@ wheels = [
305
305
 
306
306
  [[package]]
307
307
  name = "insightfactory-cli"
308
- version = "1.1.1"
308
+ version = "1.1.2"
309
309
  source = { editable = "." }
310
310
 
311
311
  [package.optional-dependencies]
@@ -1,79 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Refuse a change that would land under a version someone has already written up.
3
-
4
- Two checks, run by the `release-guard` CI job:
5
-
6
- * the version in pyproject.toml has a CHANGELOG.md section, and
7
- * on a PR, that version is above the one on the base branch.
8
-
9
- BASE names the git ref to compare against (`origin/develop`). A push has no base
10
- to diff, so it passes `BASE=none` and gets the CHANGELOG check alone. BASE unset
11
- is an error rather than that same skip: the bump check is the half a human cannot
12
- replicate by reading the diff, and a renamed or mis-indented `env:` key should
13
- fail the job rather than quietly turn it into a no-op.
14
-
15
- Stdlib only: this runs on the CI runner's system python3, outside the project venv.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import os
21
- import subprocess
22
- import sys
23
- from pathlib import Path
24
-
25
- sys.path.append(str(Path(__file__).resolve().parent))
26
-
27
- import release_notes # noqa: E402
28
- from release_notes import changelog_section, current_version, parse_version, release_parts, version_key # noqa: E402
29
-
30
- NO_BASE = "none"
31
-
32
-
33
- def base_version(base: str) -> str:
34
- result = subprocess.run(
35
- ["git", "show", f"{base}:pyproject.toml"],
36
- cwd=release_notes.ROOT,
37
- capture_output=True,
38
- text=True,
39
- )
40
- if result.returncode != 0:
41
- raise SystemExit(
42
- f"error: cannot read pyproject.toml at {base}: {result.stderr.strip()}\n"
43
- f"If this is CI, the checkout needs fetch-depth: 0 for {base} to exist locally."
44
- )
45
- return parse_version(result.stdout, where=f"pyproject.toml at {base}")
46
-
47
-
48
- def main() -> int:
49
- version = current_version()
50
-
51
- # Raises if the section is missing or empty, with the message to act on.
52
- changelog_section(version)
53
- print(f"CHANGELOG.md documents {version}")
54
-
55
- base = os.environ.get("BASE", "").strip()
56
- if not base:
57
- raise SystemExit(
58
- f"error: BASE is unset. Pass the ref to compare against, "
59
- f"or BASE={NO_BASE} for a run that genuinely has no base branch."
60
- )
61
- if base == NO_BASE:
62
- print(f"BASE={NO_BASE}, skipping the version-bump check")
63
- return 0
64
-
65
- previous = base_version(base)
66
- width = max(len(release_parts(version)), len(release_parts(previous)))
67
- if version_key(version, width) <= version_key(previous, width):
68
- raise SystemExit(
69
- f"error: pyproject.toml is at {version}, and {base} is at {previous}. "
70
- f"{base} already has a {previous} section in CHANGELOG.md, so this change would land "
71
- f"under notes written for other work, and could be tagged before anyone lists it. "
72
- f"Raise the version and open its own section."
73
- )
74
- print(f"version {version} is above {base}'s {previous}")
75
- return 0
76
-
77
-
78
- if __name__ == "__main__":
79
- raise SystemExit(main())