lightnow-cli 1.6.0__tar.gz → 1.7.0__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.
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/.github/workflows/ci.yml +20 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/.github/workflows/release.yml +3 -3
- lightnow_cli-1.7.0/.release-please-manifest.json +3 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/CHANGELOG.md +16 -0
- lightnow_cli-1.7.0/PKG-INFO +138 -0
- lightnow_cli-1.7.0/README.md +90 -0
- lightnow_cli-1.7.0/ci/create_client_artifact.py +126 -0
- lightnow_cli-1.7.0/ci/test_client_artifact.py +124 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/docs/release.md +23 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/integrations.py +111 -10
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/query.py +1 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/pyproject.toml +1 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/renovate.json +2 -2
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_cli.py +44 -2
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_integrations.py +305 -1
- lightnow_cli-1.6.0/.release-please-manifest.json +0 -3
- lightnow_cli-1.6.0/PKG-INFO +0 -344
- lightnow_cli-1.6.0/README.md +0 -296
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/.github/workflows/release-please.yml +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/.gitignore +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/LICENSE +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/Makefile +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/SECURITY.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/docs/development.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/examples/README.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/examples/docs.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/examples/openapi.json +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/examples/server.json +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/client.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/auth.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/context.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/publish.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/runner.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/updates.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/config.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/main.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/target.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/updates.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/release-please-config.json +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/__init__.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_auth_flow.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_authenticated_http.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_client.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_config.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_connection_target.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_context.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_tls.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_updates.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.7.0}/tests/test_validation.py +0 -0
|
@@ -73,8 +73,27 @@ jobs:
|
|
|
73
73
|
- name: Build and check distributions
|
|
74
74
|
run: make package
|
|
75
75
|
|
|
76
|
+
- name: Verify distribution provenance contract
|
|
77
|
+
run: python -m unittest discover -s ci -p 'test_*.py'
|
|
78
|
+
|
|
79
|
+
- name: Bind PR distribution provenance
|
|
80
|
+
if: github.event_name == 'pull_request'
|
|
81
|
+
env:
|
|
82
|
+
ARTIFACT_REPOSITORY: ${{ github.repository }}
|
|
83
|
+
ARTIFACT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
84
|
+
ARTIFACT_HEAD_REVISION: ${{ github.event.pull_request.head.sha }}
|
|
85
|
+
ARTIFACT_RUN_ID: ${{ github.run_id }}
|
|
86
|
+
ARTIFACT_RUN_ATTEMPT: ${{ github.run_attempt }}
|
|
87
|
+
run: |
|
|
88
|
+
python ci/create_client_artifact.py --component cli \
|
|
89
|
+
--repository "$ARTIFACT_REPOSITORY" \
|
|
90
|
+
--pr-number "$ARTIFACT_PR_NUMBER" \
|
|
91
|
+
--head-revision "$ARTIFACT_HEAD_REVISION" \
|
|
92
|
+
--run-id "$ARTIFACT_RUN_ID" \
|
|
93
|
+
--run-attempt "$ARTIFACT_RUN_ATTEMPT"
|
|
94
|
+
|
|
76
95
|
- name: Upload distributions
|
|
77
|
-
uses: actions/upload-artifact@
|
|
96
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
|
78
97
|
with:
|
|
79
98
|
name: python-distributions
|
|
80
99
|
path: dist/*
|
|
@@ -51,7 +51,7 @@ jobs:
|
|
|
51
51
|
run: make package
|
|
52
52
|
|
|
53
53
|
- name: Upload distributions
|
|
54
|
-
uses: actions/upload-artifact@
|
|
54
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
|
55
55
|
with:
|
|
56
56
|
name: python-distributions
|
|
57
57
|
path: dist/*
|
|
@@ -66,7 +66,7 @@ jobs:
|
|
|
66
66
|
|
|
67
67
|
steps:
|
|
68
68
|
- name: Download distributions
|
|
69
|
-
uses: actions/download-artifact@
|
|
69
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
70
70
|
with:
|
|
71
71
|
name: python-distributions
|
|
72
72
|
path: dist
|
|
@@ -89,7 +89,7 @@ jobs:
|
|
|
89
89
|
|
|
90
90
|
steps:
|
|
91
91
|
- name: Download distributions
|
|
92
|
-
uses: actions/download-artifact@
|
|
92
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
93
93
|
with:
|
|
94
94
|
name: python-distributions
|
|
95
95
|
path: dist
|
|
@@ -4,6 +4,22 @@ All notable changes to the LightNow CLI are documented here.
|
|
|
4
4
|
|
|
5
5
|
This project follows semantic versioning.
|
|
6
6
|
|
|
7
|
+
## [1.7.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.6.1...v1.7.0) (2026-11-13)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* support LibreChat local proxy configuration ([#47](https://github.com/lightnow-ai/lightnow-cli/issues/47)) ([fffb3ca](https://github.com/lightnow-ai/lightnow-cli/commit/fffb3ca2ac8c87f08e9938bfcb54e3952fcb23f7))
|
|
13
|
+
|
|
14
|
+
## [1.6.1](https://github.com/lightnow-ai/lightnow-cli/compare/v1.6.0...v1.6.1) (2026-11-09)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **cli:** use active context for default favorites ([5399173](https://github.com/lightnow-ai/lightnow-cli/commit/539917321a8fbafedb05394841b5fadd0b5319e0))
|
|
20
|
+
* **cli:** use active context for default favorites ([1ccba54](https://github.com/lightnow-ai/lightnow-cli/commit/1ccba543e34b236d449bdedeb644029c54f49f03))
|
|
21
|
+
* **cli:** use active context for default favorites ([#45](https://github.com/lightnow-ai/lightnow-cli/issues/45)) ([5399173](https://github.com/lightnow-ai/lightnow-cli/commit/539917321a8fbafedb05394841b5fadd0b5319e0))
|
|
22
|
+
|
|
7
23
|
## [1.6.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.5.0...v1.6.0) (2026-10-09)
|
|
8
24
|
|
|
9
25
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lightnow-cli
|
|
3
|
+
Version: 1.7.0
|
|
4
|
+
Summary: Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows
|
|
5
|
+
Project-URL: Homepage, https://github.com/lightnow-ai/lightnow-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/lightnow-ai/lightnow-cli
|
|
7
|
+
Author: LightNow Team
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,lightnow,mcp,model-context-protocol,registry
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: filelock>=3.16.1
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Requires-Dist: jsonschema>=4.17.0
|
|
24
|
+
Requires-Dist: packaging>=24.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
27
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
28
|
+
Requires-Dist: rich>=13.0.0
|
|
29
|
+
Requires-Dist: truststore>=0.10.0
|
|
30
|
+
Requires-Dist: typer>=0.9.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: build>=1.2.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: flake8>=6.0.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: isort>=5.12.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-mock>=3.10.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: responses>=0.23.0; extra == 'dev'
|
|
42
|
+
Requires-Dist: twine>=5.0.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: types-jsonschema>=4.25.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
|
|
45
|
+
Provides-Extra: keyring
|
|
46
|
+
Requires-Dist: keyring>=25.6.0; extra == 'keyring'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# LightNow CLI
|
|
50
|
+
|
|
51
|
+
[](https://pypi.org/project/lightnow-cli/)
|
|
52
|
+
|
|
53
|
+
Find MCP servers and connect your AI assistants to the tools you manage in LightNow.
|
|
54
|
+
|
|
55
|
+
The CLI signs you in and configures your clients. [LightNow Proxy](https://github.com/lightnow-ai/lightnow-proxy)
|
|
56
|
+
runs their connections to the servers in your Runtime profiles.
|
|
57
|
+
|
|
58
|
+
## Get started
|
|
59
|
+
|
|
60
|
+
You need a [LightNow account](https://lightnow.ai/), an AI client and a Runtime
|
|
61
|
+
profile with the server connections you want to use.
|
|
62
|
+
|
|
63
|
+
### 1. Install the CLI and proxy
|
|
64
|
+
|
|
65
|
+
With Homebrew:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
brew tap lightnow-ai/tap
|
|
69
|
+
brew install lightnow-cli lightnow-proxy
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or with [uv](https://docs.astral.sh/uv/getting-started/installation/) and Python 3.11 or later:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv tool install lightnow-cli
|
|
76
|
+
uv tool install lightnow-proxy
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
[pipx](https://pipx.pypa.io/stable/installation/) is also supported.
|
|
80
|
+
|
|
81
|
+
### 2. Sign in and connect a client
|
|
82
|
+
|
|
83
|
+
If your client already has MCP servers, [review the import options](https://docs.lightnow.ai/how-to/import-existing-mcp-config)
|
|
84
|
+
before syncing.
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
lightnow login
|
|
88
|
+
lightnow sync --client codex --profile default --local-proxy
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Replace `default` with your profile's name. Restart the client and check its MCP
|
|
92
|
+
tools list. The CLI supports Codex, Claude Code, Claude Desktop, Cursor, VS Code,
|
|
93
|
+
Antigravity, Gemini CLI and LibreChat.
|
|
94
|
+
|
|
95
|
+
See [Connect AI assistants](https://docs.lightnow.ai/getting-started/sync-mcp-clients)
|
|
96
|
+
for each client's setup and configuration paths.
|
|
97
|
+
|
|
98
|
+
For LibreChat, run the CLI and proxy on its backend and select its configuration:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
lightnow sync --client librechat --profile default --local-proxy --config-path /path/to/librechat.yaml
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
LibreChat users with access to this connection share the backend account's
|
|
105
|
+
LightNow permissions. Reload LibreChat after changing its configuration.
|
|
106
|
+
|
|
107
|
+
### 3. Check the connection
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
lightnow status
|
|
111
|
+
lightnow config-status --client codex
|
|
112
|
+
lightnow-proxy --config ~/.lightnow/lightnow-proxy/codex.yaml --health
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For help, see [Troubleshoot the connection](https://docs.lightnow.ai/how-to/debug-local-proxy).
|
|
116
|
+
|
|
117
|
+
## Useful commands
|
|
118
|
+
|
|
119
|
+
| Task | Command |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| Find servers | `lightnow search sonarqube` |
|
|
122
|
+
| View your favorites | `lightnow favorites` |
|
|
123
|
+
| Use organization-managed client settings | `lightnow sync --client codex --from-settings` |
|
|
124
|
+
| Check for updates | `lightnow update --check` |
|
|
125
|
+
| Update the CLI and proxy | `lightnow update` |
|
|
126
|
+
|
|
127
|
+
Use `lightnow --help` or `lightnow <command> --help` for available options.
|
|
128
|
+
For profiles, organization connections and other workflows, see the
|
|
129
|
+
[CLI reference](https://docs.lightnow.ai/reference/cli).
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
See [development setup](docs/development.md), [contributing](CONTRIBUTING.md),
|
|
134
|
+
[release process](docs/release.md) and [security policy](SECURITY.md).
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
[Apache License 2.0](LICENSE).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# LightNow CLI
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/lightnow-cli/)
|
|
4
|
+
|
|
5
|
+
Find MCP servers and connect your AI assistants to the tools you manage in LightNow.
|
|
6
|
+
|
|
7
|
+
The CLI signs you in and configures your clients. [LightNow Proxy](https://github.com/lightnow-ai/lightnow-proxy)
|
|
8
|
+
runs their connections to the servers in your Runtime profiles.
|
|
9
|
+
|
|
10
|
+
## Get started
|
|
11
|
+
|
|
12
|
+
You need a [LightNow account](https://lightnow.ai/), an AI client and a Runtime
|
|
13
|
+
profile with the server connections you want to use.
|
|
14
|
+
|
|
15
|
+
### 1. Install the CLI and proxy
|
|
16
|
+
|
|
17
|
+
With Homebrew:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
brew tap lightnow-ai/tap
|
|
21
|
+
brew install lightnow-cli lightnow-proxy
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or with [uv](https://docs.astral.sh/uv/getting-started/installation/) and Python 3.11 or later:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv tool install lightnow-cli
|
|
28
|
+
uv tool install lightnow-proxy
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
[pipx](https://pipx.pypa.io/stable/installation/) is also supported.
|
|
32
|
+
|
|
33
|
+
### 2. Sign in and connect a client
|
|
34
|
+
|
|
35
|
+
If your client already has MCP servers, [review the import options](https://docs.lightnow.ai/how-to/import-existing-mcp-config)
|
|
36
|
+
before syncing.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
lightnow login
|
|
40
|
+
lightnow sync --client codex --profile default --local-proxy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Replace `default` with your profile's name. Restart the client and check its MCP
|
|
44
|
+
tools list. The CLI supports Codex, Claude Code, Claude Desktop, Cursor, VS Code,
|
|
45
|
+
Antigravity, Gemini CLI and LibreChat.
|
|
46
|
+
|
|
47
|
+
See [Connect AI assistants](https://docs.lightnow.ai/getting-started/sync-mcp-clients)
|
|
48
|
+
for each client's setup and configuration paths.
|
|
49
|
+
|
|
50
|
+
For LibreChat, run the CLI and proxy on its backend and select its configuration:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
lightnow sync --client librechat --profile default --local-proxy --config-path /path/to/librechat.yaml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
LibreChat users with access to this connection share the backend account's
|
|
57
|
+
LightNow permissions. Reload LibreChat after changing its configuration.
|
|
58
|
+
|
|
59
|
+
### 3. Check the connection
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
lightnow status
|
|
63
|
+
lightnow config-status --client codex
|
|
64
|
+
lightnow-proxy --config ~/.lightnow/lightnow-proxy/codex.yaml --health
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For help, see [Troubleshoot the connection](https://docs.lightnow.ai/how-to/debug-local-proxy).
|
|
68
|
+
|
|
69
|
+
## Useful commands
|
|
70
|
+
|
|
71
|
+
| Task | Command |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| Find servers | `lightnow search sonarqube` |
|
|
74
|
+
| View your favorites | `lightnow favorites` |
|
|
75
|
+
| Use organization-managed client settings | `lightnow sync --client codex --from-settings` |
|
|
76
|
+
| Check for updates | `lightnow update --check` |
|
|
77
|
+
| Update the CLI and proxy | `lightnow update` |
|
|
78
|
+
|
|
79
|
+
Use `lightnow --help` or `lightnow <command> --help` for available options.
|
|
80
|
+
For profiles, organization connections and other workflows, see the
|
|
81
|
+
[CLI reference](https://docs.lightnow.ai/reference/cli).
|
|
82
|
+
|
|
83
|
+
## Contributing
|
|
84
|
+
|
|
85
|
+
See [development setup](docs/development.md), [contributing](CONTRIBUTING.md),
|
|
86
|
+
[release process](docs/release.md) and [security policy](SECURITY.md).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
[Apache License 2.0](LICENSE).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Bind a PR build to its tested checkout and one universal Python wheel."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tomllib
|
|
9
|
+
import zipfile
|
|
10
|
+
from email.parser import Parser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = "platform.lightnow.ai/feature-stack-client-artifact/v1"
|
|
14
|
+
MANIFEST_FILENAME = "feature-stack-client-artifact.json"
|
|
15
|
+
COMPONENTS = {"cli": "lightnow-cli", "proxy": "lightnow-proxy"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_manifest(
|
|
19
|
+
root: Path,
|
|
20
|
+
component: str,
|
|
21
|
+
repository: str,
|
|
22
|
+
pr_number: int,
|
|
23
|
+
head_revision: str,
|
|
24
|
+
checkout_revision: str,
|
|
25
|
+
run_id: int,
|
|
26
|
+
run_attempt: int,
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""Validate wheel contents and runner identity before creating provenance."""
|
|
29
|
+
package_name = COMPONENTS.get(component)
|
|
30
|
+
if package_name is None or repository != f"lightnow-ai/{package_name}":
|
|
31
|
+
raise ValueError("Component and repository do not match")
|
|
32
|
+
if any(value <= 0 for value in (pr_number, run_id, run_attempt)):
|
|
33
|
+
raise ValueError("PR number, run ID and run attempt must be positive")
|
|
34
|
+
if any(
|
|
35
|
+
re.fullmatch(r"[0-9a-f]{40}", value) is None
|
|
36
|
+
for value in (head_revision, checkout_revision)
|
|
37
|
+
):
|
|
38
|
+
raise ValueError("Head and checkout revisions must be full Git SHA-1 values")
|
|
39
|
+
|
|
40
|
+
with (root / "pyproject.toml").open("rb") as project_file:
|
|
41
|
+
project = tomllib.load(project_file)["project"]
|
|
42
|
+
if project["name"] != package_name:
|
|
43
|
+
raise ValueError("Repository package metadata does not match component")
|
|
44
|
+
|
|
45
|
+
wheels = sorted((root / "dist").glob("*.whl"))
|
|
46
|
+
if len(wheels) != 1 or wheels[0].is_symlink() or not wheels[0].is_file():
|
|
47
|
+
raise ValueError("Exactly one regular wheel is required")
|
|
48
|
+
wheel = wheels[0]
|
|
49
|
+
wheel_package = package_name.replace("-", "_")
|
|
50
|
+
expected_filename = f"{wheel_package}-{project['version']}-py3-none-any.whl"
|
|
51
|
+
if wheel.name != expected_filename:
|
|
52
|
+
raise ValueError("Expected the repository's universal py3-none-any wheel")
|
|
53
|
+
|
|
54
|
+
prefix = f"{wheel_package}-{project['version']}.dist-info/"
|
|
55
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
56
|
+
for name in (f"{prefix}METADATA", f"{prefix}WHEEL"):
|
|
57
|
+
if archive.namelist().count(name) != 1:
|
|
58
|
+
raise ValueError("Wheel metadata must be unique and match its filename")
|
|
59
|
+
metadata_files = [
|
|
60
|
+
name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
|
|
61
|
+
]
|
|
62
|
+
if metadata_files != [f"{prefix}METADATA"]:
|
|
63
|
+
raise ValueError("Wheel must contain exactly one distribution's metadata")
|
|
64
|
+
metadata = Parser().parsestr(archive.read(f"{prefix}METADATA").decode("utf-8"))
|
|
65
|
+
wheel_metadata = Parser().parsestr(
|
|
66
|
+
archive.read(f"{prefix}WHEEL").decode("utf-8")
|
|
67
|
+
)
|
|
68
|
+
if metadata.get_all("Name") != [package_name] or metadata.get_all("Version") != [
|
|
69
|
+
project["version"]
|
|
70
|
+
]:
|
|
71
|
+
raise ValueError("Wheel METADATA name/version do not match repository metadata")
|
|
72
|
+
if wheel_metadata.get_all("Tag") != ["py3-none-any"] or wheel_metadata.get_all(
|
|
73
|
+
"Root-Is-Purelib"
|
|
74
|
+
) != ["true"]:
|
|
75
|
+
raise ValueError("Wheel must declare the universal pure-Python tag")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
79
|
+
"component": component,
|
|
80
|
+
"repository": repository,
|
|
81
|
+
"packageName": metadata["Name"],
|
|
82
|
+
"packageVersion": metadata["Version"],
|
|
83
|
+
"filename": wheel.name,
|
|
84
|
+
"sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(),
|
|
85
|
+
"source": {
|
|
86
|
+
"kind": "pull-request",
|
|
87
|
+
"number": pr_number,
|
|
88
|
+
"headRevision": head_revision,
|
|
89
|
+
"checkoutRevision": checkout_revision,
|
|
90
|
+
"runId": run_id,
|
|
91
|
+
"runAttempt": run_attempt,
|
|
92
|
+
"workflowPath": ".github/workflows/ci.yml",
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
99
|
+
parser.add_argument("--component", choices=tuple(COMPONENTS), required=True)
|
|
100
|
+
parser.add_argument("--repository", required=True)
|
|
101
|
+
parser.add_argument("--pr-number", type=int, required=True)
|
|
102
|
+
parser.add_argument("--head-revision", required=True)
|
|
103
|
+
parser.add_argument("--run-id", type=int, required=True)
|
|
104
|
+
parser.add_argument("--run-attempt", type=int, required=True)
|
|
105
|
+
args = parser.parse_args()
|
|
106
|
+
root = Path(__file__).resolve().parents[1]
|
|
107
|
+
checkout_revision = subprocess.check_output(
|
|
108
|
+
["git", "rev-parse", "HEAD"], cwd=root, text=True
|
|
109
|
+
).strip()
|
|
110
|
+
manifest = build_manifest(
|
|
111
|
+
root,
|
|
112
|
+
args.component,
|
|
113
|
+
args.repository,
|
|
114
|
+
args.pr_number,
|
|
115
|
+
args.head_revision,
|
|
116
|
+
checkout_revision,
|
|
117
|
+
args.run_id,
|
|
118
|
+
args.run_attempt,
|
|
119
|
+
)
|
|
120
|
+
(root / "dist" / MANIFEST_FILENAME).write_text(
|
|
121
|
+
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Offline provenance contract checks; no installation or external services."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from create_client_artifact import build_manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientArtifactTests(unittest.TestCase):
|
|
13
|
+
def setUp(self) -> None:
|
|
14
|
+
self.directory = tempfile.TemporaryDirectory()
|
|
15
|
+
self.addCleanup(self.directory.cleanup)
|
|
16
|
+
self.root = Path(self.directory.name)
|
|
17
|
+
(self.root / "dist").mkdir()
|
|
18
|
+
(self.root / "pyproject.toml").write_text(
|
|
19
|
+
'[project]\nname = "lightnow-cli"\nversion = "1.2.3"\n'
|
|
20
|
+
)
|
|
21
|
+
self.wheel = self.root / "dist/lightnow_cli-1.2.3-py3-none-any.whl"
|
|
22
|
+
self.write_wheel()
|
|
23
|
+
|
|
24
|
+
def write_wheel(
|
|
25
|
+
self,
|
|
26
|
+
name: str = "lightnow-cli",
|
|
27
|
+
version: str = "1.2.3",
|
|
28
|
+
tag: str = "py3-none-any",
|
|
29
|
+
) -> None:
|
|
30
|
+
with zipfile.ZipFile(self.wheel, "w") as archive:
|
|
31
|
+
archive.writestr(
|
|
32
|
+
"lightnow_cli-1.2.3.dist-info/METADATA",
|
|
33
|
+
f"Metadata-Version: 2.3\nName: {name}\nVersion: {version}\n",
|
|
34
|
+
)
|
|
35
|
+
archive.writestr(
|
|
36
|
+
"lightnow_cli-1.2.3.dist-info/WHEEL",
|
|
37
|
+
f"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: {tag}\n",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def manifest(self, **overrides: object) -> dict:
|
|
41
|
+
arguments = dict(
|
|
42
|
+
root=self.root,
|
|
43
|
+
component="cli",
|
|
44
|
+
repository="lightnow-ai/lightnow-cli",
|
|
45
|
+
pr_number=42,
|
|
46
|
+
head_revision="a" * 40,
|
|
47
|
+
checkout_revision="b" * 40,
|
|
48
|
+
run_id=123,
|
|
49
|
+
run_attempt=2,
|
|
50
|
+
)
|
|
51
|
+
arguments.update(overrides)
|
|
52
|
+
return build_manifest(**arguments)
|
|
53
|
+
|
|
54
|
+
def test_exact_contract_keeps_head_and_tested_checkout_separate(self) -> None:
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
self.manifest(),
|
|
57
|
+
{
|
|
58
|
+
"schemaVersion": "platform.lightnow.ai/feature-stack-client-artifact/v1",
|
|
59
|
+
"component": "cli",
|
|
60
|
+
"repository": "lightnow-ai/lightnow-cli",
|
|
61
|
+
"packageName": "lightnow-cli",
|
|
62
|
+
"packageVersion": "1.2.3",
|
|
63
|
+
"filename": self.wheel.name,
|
|
64
|
+
"sha256": hashlib.sha256(self.wheel.read_bytes()).hexdigest(),
|
|
65
|
+
"source": {
|
|
66
|
+
"kind": "pull-request",
|
|
67
|
+
"number": 42,
|
|
68
|
+
"headRevision": "a" * 40,
|
|
69
|
+
"checkoutRevision": "b" * 40,
|
|
70
|
+
"runId": 123,
|
|
71
|
+
"runAttempt": 2,
|
|
72
|
+
"workflowPath": ".github/workflows/ci.yml",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_rejects_unbound_identity(self) -> None:
|
|
78
|
+
for arguments in (
|
|
79
|
+
{"repository": "other/lightnow-cli"},
|
|
80
|
+
{"component": "proxy"},
|
|
81
|
+
{"head_revision": "a" * 7},
|
|
82
|
+
{"checkout_revision": "not-a-revision"},
|
|
83
|
+
{"pr_number": 0},
|
|
84
|
+
{"run_id": -1},
|
|
85
|
+
{"run_attempt": 0},
|
|
86
|
+
):
|
|
87
|
+
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
|
|
88
|
+
self.manifest(**arguments)
|
|
89
|
+
|
|
90
|
+
def test_rejects_metadata_or_platform_mismatch(self) -> None:
|
|
91
|
+
for arguments in (
|
|
92
|
+
{"name": "another-package"},
|
|
93
|
+
{"version": "9.0.0"},
|
|
94
|
+
{"tag": "cp312-cp312-linux_x86_64"},
|
|
95
|
+
):
|
|
96
|
+
with self.subTest(arguments=arguments):
|
|
97
|
+
self.write_wheel(**arguments)
|
|
98
|
+
with self.assertRaises(ValueError):
|
|
99
|
+
self.manifest()
|
|
100
|
+
|
|
101
|
+
def test_rejects_ambiguous_missing_or_symlinked_wheel(self) -> None:
|
|
102
|
+
extra = self.wheel.with_name("another.whl")
|
|
103
|
+
extra.write_bytes(self.wheel.read_bytes())
|
|
104
|
+
with self.assertRaises(ValueError):
|
|
105
|
+
self.manifest()
|
|
106
|
+
extra.unlink()
|
|
107
|
+
self.wheel.unlink()
|
|
108
|
+
with self.assertRaises(ValueError):
|
|
109
|
+
self.manifest()
|
|
110
|
+
self.wheel.symlink_to(self.root / "outside.whl")
|
|
111
|
+
with self.assertRaises(ValueError):
|
|
112
|
+
self.manifest()
|
|
113
|
+
|
|
114
|
+
def test_rejects_duplicate_distribution_metadata(self) -> None:
|
|
115
|
+
with zipfile.ZipFile(self.wheel, "a") as archive:
|
|
116
|
+
archive.writestr(
|
|
117
|
+
"other-1.0.dist-info/METADATA", "Name: other\nVersion: 1.0\n"
|
|
118
|
+
)
|
|
119
|
+
with self.assertRaises(ValueError):
|
|
120
|
+
self.manifest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -101,3 +101,26 @@ tag. If the project does not exist yet, use PyPI's pending publisher flow for
|
|
|
101
101
|
```bash
|
|
102
102
|
python -m twine check dist/*
|
|
103
103
|
```
|
|
104
|
+
|
|
105
|
+
## PR distribution provenance
|
|
106
|
+
|
|
107
|
+
PR CI archives `feature-stack-client-artifact.json` alongside the wheel and
|
|
108
|
+
source distribution in `python-distributions`. The manifest uses
|
|
109
|
+
`platform.lightnow.ai/feature-stack-client-artifact/v1` and binds the universal
|
|
110
|
+
wheel SHA-256 and its actual METADATA name/version to the PR number, head
|
|
111
|
+
revision, tested checkout revision, workflow, run ID, and run attempt. The head
|
|
112
|
+
and tested merge checkout are separate fields. Consumers must verify these
|
|
113
|
+
bindings and the wheel checksum against the selected successful CI run before
|
|
114
|
+
installing a candidate; the manifest itself is build metadata, not a signature
|
|
115
|
+
or a runtime test result.
|
|
116
|
+
|
|
117
|
+
The generator uses only Python's standard library. After a clean package build,
|
|
118
|
+
run its focused offline contract checks with:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python -m unittest discover -s ci -p 'test_*.py'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Main CI does not fabricate PR metadata. Tagged release publishing is unchanged:
|
|
125
|
+
release consumers qualify the exact Git tag and published PyPI wheel separately.
|
|
126
|
+
The PR-only JSON never enters the existing PyPI release upload.
|