teams-cli 0.1.2__tar.gz → 0.1.3__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 (30) hide show
  1. teams_cli-0.1.3/.github/workflows/ci.yml +54 -0
  2. {teams_cli-0.1.2 → teams_cli-0.1.3}/.github/workflows/release.yml +78 -25
  3. teams_cli-0.1.3/.mise.toml +22 -0
  4. {teams_cli-0.1.2 → teams_cli-0.1.3}/CHANGELOG.md +11 -5
  5. {teams_cli-0.1.2 → teams_cli-0.1.3}/Cargo.lock +1 -1
  6. {teams_cli-0.1.2 → teams_cli-0.1.3}/Cargo.toml +2 -2
  7. {teams_cli-0.1.2 → teams_cli-0.1.3}/Makefile +10 -1
  8. {teams_cli-0.1.2 → teams_cli-0.1.3}/PKG-INFO +17 -1
  9. {teams_cli-0.1.2 → teams_cli-0.1.3}/README.md +16 -0
  10. teams_cli-0.1.3/docs/releases.md +38 -0
  11. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/cli.rs +16 -0
  12. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/config.rs +48 -3
  13. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/error.rs +16 -6
  14. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/main.rs +58 -6
  15. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/output.rs +2 -2
  16. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/schema.rs +23 -1
  17. {teams_cli-0.1.2 → teams_cli-0.1.3}/tests/cli.rs +47 -2
  18. teams_cli-0.1.3/vership.toml +10 -0
  19. teams_cli-0.1.2/.github/workflows/ci.yml +0 -47
  20. {teams_cli-0.1.2 → teams_cli-0.1.3}/.github/dependabot.yml +0 -0
  21. {teams_cli-0.1.2 → teams_cli-0.1.3}/.gitignore +0 -0
  22. {teams_cli-0.1.2 → teams_cli-0.1.3}/DESIGN.md +0 -0
  23. {teams_cli-0.1.2 → teams_cli-0.1.3}/LICENSE +0 -0
  24. {teams_cli-0.1.2 → teams_cli-0.1.3}/PRODUCT.md +0 -0
  25. {teams_cli-0.1.2 → teams_cli-0.1.3}/examples/validate_schema.rs +0 -0
  26. {teams_cli-0.1.2 → teams_cli-0.1.3}/pyproject.toml +0 -0
  27. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/auth.rs +0 -0
  28. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/graph.rs +0 -0
  29. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/lib.rs +0 -0
  30. {teams_cli-0.1.2 → teams_cli-0.1.3}/src/tui.rs +0 -0
@@ -0,0 +1,54 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ workflow_call:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ name: Test (${{ matrix.os }})
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ os: [ubuntu-latest, macos-latest, windows-latest]
18
+ runs-on: ${{ matrix.os }}
19
+ steps:
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
21
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
22
+ with:
23
+ toolchain: 1.96.0
24
+ components: rustfmt, clippy
25
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
26
+ - run: cargo test --locked --all-targets
27
+
28
+ quality:
29
+ name: Formatting and lints
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
33
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
34
+ with:
35
+ toolchain: 1.96.0
36
+ components: rustfmt, clippy
37
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
38
+ - run: cargo fmt -- --check
39
+ - run: cargo clippy --locked --all-targets -- -D warnings
40
+
41
+ security:
42
+ name: Dependency audit
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
46
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
47
+ with:
48
+ toolchain: 1.96.0
49
+ components: rustfmt, clippy
50
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
51
+ - name: Install cargo-audit
52
+ run: cargo install cargo-audit --version 0.22.2 --locked
53
+ - name: Audit Cargo.lock
54
+ run: cargo audit
@@ -3,6 +3,17 @@ name: Release
3
3
  on:
4
4
  push:
5
5
  tags: ["v[0-9]*.[0-9]*.[0-9]*"]
6
+ workflow_dispatch:
7
+ inputs:
8
+ version:
9
+ description: "Version to rehearse (defaults to Cargo.toml)"
10
+ required: false
11
+ type: string
12
+ dry_run:
13
+ description: "Build and validate without publishing"
14
+ required: true
15
+ default: true
16
+ type: boolean
6
17
 
7
18
  permissions:
8
19
  contents: read
@@ -12,11 +23,23 @@ jobs:
12
23
  name: Verify release version
13
24
  runs-on: ubuntu-latest
14
25
  steps:
15
- - uses: actions/checkout@v7
26
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
16
27
  - name: Match tag to package version
28
+ env:
29
+ REQUESTED_VERSION: ${{ inputs.version }}
30
+ DRY_RUN: ${{ inputs.dry_run }}
17
31
  run: |
18
- version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"
19
- test "v${version}" = "$GITHUB_REF_NAME"
32
+ manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"
33
+ version="${REQUESTED_VERSION#v}"
34
+ version="${version:-$manifest_version}"
35
+ [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]
36
+ test "$version" = "$manifest_version"
37
+ if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
38
+ test "v${version}" = "$GITHUB_REF_NAME"
39
+ elif [[ "$DRY_RUN" != "true" ]]; then
40
+ test "$GITHUB_REF_TYPE" = "tag"
41
+ test "v${version}" = "$GITHUB_REF_NAME"
42
+ fi
20
43
 
21
44
  verify:
22
45
  uses: ./.github/workflows/ci.yml
@@ -46,11 +69,13 @@ jobs:
46
69
  id-token: write
47
70
  attestations: write
48
71
  steps:
49
- - uses: actions/checkout@v7
50
- - uses: dtolnay/rust-toolchain@stable
72
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
73
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
51
74
  with:
75
+ toolchain: 1.96.0
76
+ components: rustfmt, clippy
52
77
  targets: ${{ matrix.target }}
53
- - uses: Swatinem/rust-cache@v2
78
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
54
79
  with:
55
80
  key: ${{ matrix.target }}
56
81
  - run: cargo build --locked --release --target ${{ matrix.target }}
@@ -63,10 +88,11 @@ jobs:
63
88
  tar -C dist/package -czf "dist/teams-cli-${GITHUB_REF_NAME}-${{ matrix.target }}.tar.gz" .
64
89
  rm -rf dist/package
65
90
  - name: Attest build provenance
66
- uses: actions/attest@v4
91
+ if: ${{ inputs.dry_run != true }}
92
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
67
93
  with:
68
94
  subject-path: dist/*.tar.gz
69
- - uses: actions/upload-artifact@v7
95
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
70
96
  with:
71
97
  name: binary-${{ matrix.target }}
72
98
  path: dist/*.tar.gz
@@ -93,7 +119,7 @@ jobs:
93
119
  manylinux: auto
94
120
  runs-on: ${{ matrix.os }}
95
121
  steps:
96
- - uses: actions/checkout@v7
122
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
97
123
  - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
98
124
  with:
99
125
  maturin-version: v1.14.1
@@ -101,7 +127,12 @@ jobs:
101
127
  target: ${{ matrix.target }}
102
128
  manylinux: ${{ matrix.manylinux }}
103
129
  args: --release --locked --compatibility pypi --out dist
104
- - uses: actions/upload-artifact@v7
130
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d
131
+ if: ${{ inputs.dry_run == true }}
132
+ - name: Validate wheel metadata
133
+ if: ${{ inputs.dry_run == true }}
134
+ run: uvx --from twine==6.2.0 twine check --strict dist/*.whl
135
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
105
136
  with:
106
137
  name: wheel-${{ matrix.target }}
107
138
  path: dist/*.whl
@@ -112,16 +143,27 @@ jobs:
112
143
  needs: [version, verify]
113
144
  runs-on: ubuntu-latest
114
145
  steps:
115
- - uses: actions/checkout@v7
116
- - uses: dtolnay/rust-toolchain@stable
146
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
147
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
148
+ with:
149
+ toolchain: 1.96.0
150
+ components: rustfmt, clippy
117
151
  - run: cargo package --locked
152
+ - name: Validate crates.io package
153
+ if: ${{ inputs.dry_run == true }}
154
+ run: cargo publish --locked --dry-run
118
155
  - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
119
156
  with:
120
157
  maturin-version: v1.14.1
121
158
  command: sdist
122
159
  args: --out dist
123
160
  - run: cp target/package/*.crate dist/
124
- - uses: actions/upload-artifact@v7
161
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d
162
+ if: ${{ inputs.dry_run == true }}
163
+ - name: Validate source distribution metadata
164
+ if: ${{ inputs.dry_run == true }}
165
+ run: uvx --from twine==6.2.0 twine check --strict dist/*.tar.gz
166
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
125
167
  with:
126
168
  name: source-distributions
127
169
  path: dist/*
@@ -136,13 +178,13 @@ jobs:
136
178
  id-token: write
137
179
  attestations: write
138
180
  steps:
139
- - uses: actions/checkout@v7
140
- - uses: actions/download-artifact@v8
181
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
182
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
141
183
  with:
142
184
  pattern: binary-*
143
185
  path: dist
144
186
  merge-multiple: true
145
- - uses: actions/download-artifact@v8
187
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
146
188
  with:
147
189
  name: source-distributions
148
190
  path: dist
@@ -155,11 +197,13 @@ jobs:
155
197
  working-directory: dist
156
198
  run: sha256sum ./*.tar.gz ./*.crate ./*.json > SHA256SUMS
157
199
  - name: Attest artifacts and SBOM
158
- uses: actions/attest@v4
200
+ if: ${{ inputs.dry_run != true }}
201
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
159
202
  with:
160
203
  subject-path: dist/*
161
204
  sbom-path: dist/teams-cli.cdx.json
162
205
  - name: Create GitHub release
206
+ if: ${{ inputs.dry_run != true }}
163
207
  env:
164
208
  GH_TOKEN: ${{ github.token }}
165
209
  run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes
@@ -167,13 +211,17 @@ jobs:
167
211
  crates-io:
168
212
  name: Publish crates.io
169
213
  needs: github-release
214
+ if: ${{ inputs.dry_run != true }}
170
215
  runs-on: ubuntu-latest
171
216
  environment: crates-io
172
217
  permissions:
173
218
  id-token: write
174
219
  steps:
175
- - uses: actions/checkout@v7
176
- - uses: dtolnay/rust-toolchain@stable
220
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
221
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
222
+ with:
223
+ toolchain: 1.96.0
224
+ components: rustfmt, clippy
177
225
  - name: Check whether this version is already published
178
226
  id: crate
179
227
  shell: bash
@@ -191,7 +239,7 @@ jobs:
191
239
  - name: Get temporary crates.io token
192
240
  if: steps.crate.outputs.exists != 'true'
193
241
  id: crates-auth
194
- uses: rust-lang/crates-io-auth-action@v1
242
+ uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18
195
243
  - if: steps.crate.outputs.exists != 'true'
196
244
  run: cargo publish --locked
197
245
  env:
@@ -202,6 +250,7 @@ jobs:
202
250
  pypi:
203
251
  name: Publish PyPI
204
252
  needs: github-release
253
+ if: ${{ inputs.dry_run != true }}
205
254
  runs-on: ubuntu-latest
206
255
  environment:
207
256
  name: pypi
@@ -209,28 +258,32 @@ jobs:
209
258
  permissions:
210
259
  id-token: write
211
260
  steps:
212
- - uses: actions/download-artifact@v8
261
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
213
262
  with:
214
263
  pattern: wheel-*
215
264
  path: dist
216
265
  merge-multiple: true
217
- - uses: actions/download-artifact@v8
266
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
218
267
  with:
219
268
  name: source-distributions
220
269
  path: source
221
270
  - name: Collect Python distributions
222
271
  run: find source -name '*.tar.gz' -exec cp {} dist/ \;
223
- - uses: pypa/gh-action-pypi-publish@release/v1
272
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
224
273
  with:
225
274
  packages-dir: dist
226
275
 
227
276
  post-publish-smoke:
228
277
  name: Verify public packages
229
278
  needs: [crates-io, pypi]
279
+ if: ${{ inputs.dry_run != true }}
230
280
  runs-on: ubuntu-latest
231
281
  steps:
232
- - uses: dtolnay/rust-toolchain@stable
233
- - uses: actions/setup-python@v7
282
+ - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
283
+ with:
284
+ toolchain: 1.96.0
285
+ components: rustfmt, clippy
286
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
234
287
  with:
235
288
  python-version: "3.x"
236
289
  - name: Install and run the crates.io package
@@ -0,0 +1,22 @@
1
+ # Reproducible local and CI tooling.
2
+ min_version = "2026.4.19"
3
+
4
+ [env]
5
+ RUST_BACKTRACE = "1"
6
+ BINSTALL_DISABLE_TELEMETRY = "true"
7
+
8
+ [tools]
9
+ rust = "1.96.0"
10
+ actionlint = "1.7.12"
11
+ cargo-binstall = "1.22.0"
12
+ "cargo:cargo-nextest" = "0.9.143"
13
+
14
+ [settings]
15
+ cargo.binstall = true
16
+
17
+ [tasks.setup]
18
+ description = "Install the exact development toolchain"
19
+ run = """
20
+ mise install rust actionlint cargo-binstall
21
+ mise install cargo:cargo-nextest
22
+ """
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.3](https://github.com/rvben/teams-cli/compare/v0.1.2...v0.1.3) - 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - **cli**: align safety and configuration contract ([c564f63](https://github.com/rvben/teams-cli/commit/c564f63f7deeb4e8a7c8faa359e32f3ea0da8556))
15
+
16
+ ### Fixed
17
+
18
+ - **config**: persist profiles atomically ([ababe5e](https://github.com/rvben/teams-cli/commit/ababe5e46f24a52d86007de5737cd7601a1ae0c3))
19
+ - **ci**: install pinned Rust components ([f5ecdcc](https://github.com/rvben/teams-cli/commit/f5ecdccfb4ca697ff23cc828393e519a788cbea8))
20
+
10
21
  ## [0.1.2] - 2026-08-24
11
22
 
12
23
  ### Added
@@ -35,8 +46,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
35
46
  - Commands for identity, joined teams, channels, chats, and reading or sending messages through Microsoft Graph.
36
47
  - Guided onboarding, diagnostics, completions, bounded collections, and deterministic TUI snapshots.
37
48
  - Distribution through crates.io, PyPI binary wheels, and signed GitHub release artifacts.
38
-
39
- [Unreleased]: https://github.com/rvben/teams-cli/compare/v0.1.2...HEAD
40
- [0.1.2]: https://github.com/rvben/teams-cli/compare/v0.1.1...v0.1.2
41
- [0.1.1]: https://github.com/rvben/teams-cli/compare/v0.1.0...v0.1.1
42
- [0.1.0]: https://github.com/rvben/teams-cli/releases/tag/v0.1.0
@@ -2964,7 +2964,7 @@ dependencies = [
2964
2964
 
2965
2965
  [[package]]
2966
2966
  name = "teams-cli"
2967
- version = "0.1.2"
2967
+ version = "0.1.3"
2968
2968
  dependencies = [
2969
2969
  "assert_cmd",
2970
2970
  "base64 0.23.1",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
@@ -31,6 +31,7 @@ serde = { version = "1", features = ["derive"] }
31
31
  serde_json = "1"
32
32
  sha2 = "0.10"
33
33
  thiserror = "2"
34
+ tempfile = "3"
34
35
  tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time"] }
35
36
  toml = "1.1"
36
37
  url = "2"
@@ -39,5 +40,4 @@ url = "2"
39
40
  assert_cmd = "2"
40
41
  jsonschema = "0.49.8"
41
42
  predicates = "3"
42
- tempfile = "3"
43
43
  wiremock = "0.6"
@@ -1,4 +1,4 @@
1
- .PHONY: build test lint fmt check release install clean
1
+ .PHONY: build test lint fmt check release install clean release-patch release-minor release-major
2
2
 
3
3
  build:
4
4
  cargo build --locked
@@ -23,3 +23,12 @@ install: check release
23
23
 
24
24
  clean:
25
25
  cargo clean
26
+
27
+ release-patch:
28
+ vership bump patch
29
+
30
+ release-minor:
31
+ vership bump minor
32
+
33
+ release-major:
34
+ vership bump major
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -108,11 +108,22 @@ teams auth login --channel-history # once, with administrator approval
108
108
  teams messages list --team TEAM_ID --channel CHANNEL_ID
109
109
  teams messages send --chat CHAT_ID --body "On it."
110
110
  printf 'Status update' | teams messages send --chat CHAT_ID --body -
111
+ teams config show
111
112
  teams doctor
112
113
  ```
113
114
 
114
115
  Collection commands expose `--limit` and `--fields`; Graph-native collections use `--cursor`, while joined teams and channels use `--offset` because those endpoints do not accept `$top`. With a terminal they render concise text; when stdout is piped they emit JSON envelopes with continuation metadata and `truncated`.
115
116
 
117
+ Profiles can block every remote write while keeping reads, sign-in, diagnostics, and the TUI available:
118
+
119
+ ```console
120
+ teams init --no-login --read-only
121
+ # or for one invocation/environment
122
+ TEAMS_READ_ONLY=true teams messages send --chat CHAT_ID --body "Blocked"
123
+ ```
124
+
125
+ Like the other tools in the suite, the config file is selected with `--profile`, overridden by `TEAMS_*` environment variables, and inspectable without exposing credentials through `teams config show` and `teams config path`.
126
+
116
127
  ## Boundaries
117
128
 
118
129
  - Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
@@ -125,3 +136,8 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
125
136
 
126
137
  `teams schema` is offline, unauthenticated, and follows [CLI Spec v0.3](https://clispec.dev/): flat invocable command paths, declared effects and cardinality, bounded collections, stable error kinds and exit codes, stdout/stderr separation, and safe noninteractive behavior.
127
138
 
139
+ ## Releasing
140
+
141
+ Vership owns versioning, changelog generation, release commits, and tags. See
142
+ [the release runbook](docs/releases.md) for the verified workflow and recovery policy.
143
+
@@ -92,11 +92,22 @@ teams auth login --channel-history # once, with administrator approval
92
92
  teams messages list --team TEAM_ID --channel CHANNEL_ID
93
93
  teams messages send --chat CHAT_ID --body "On it."
94
94
  printf 'Status update' | teams messages send --chat CHAT_ID --body -
95
+ teams config show
95
96
  teams doctor
96
97
  ```
97
98
 
98
99
  Collection commands expose `--limit` and `--fields`; Graph-native collections use `--cursor`, while joined teams and channels use `--offset` because those endpoints do not accept `$top`. With a terminal they render concise text; when stdout is piped they emit JSON envelopes with continuation metadata and `truncated`.
99
100
 
101
+ Profiles can block every remote write while keeping reads, sign-in, diagnostics, and the TUI available:
102
+
103
+ ```console
104
+ teams init --no-login --read-only
105
+ # or for one invocation/environment
106
+ TEAMS_READ_ONLY=true teams messages send --chat CHAT_ID --body "Blocked"
107
+ ```
108
+
109
+ Like the other tools in the suite, the config file is selected with `--profile`, overridden by `TEAMS_*` environment variables, and inspectable without exposing credentials through `teams config show` and `teams config path`.
110
+
100
111
  ## Boundaries
101
112
 
102
113
  - Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
@@ -108,3 +119,8 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
108
119
  ## CLI Spec
109
120
 
110
121
  `teams schema` is offline, unauthenticated, and follows [CLI Spec v0.3](https://clispec.dev/): flat invocable command paths, declared effects and cardinality, bounded collections, stable error kinds and exit codes, stdout/stderr separation, and safe noninteractive behavior.
122
+
123
+ ## Releasing
124
+
125
+ Vership owns versioning, changelog generation, release commits, and tags. See
126
+ [the release runbook](docs/releases.md) for the verified workflow and recovery policy.
@@ -0,0 +1,38 @@
1
+ # Releases
2
+
3
+ Vership is the release control plane for `teams-cli`. GitHub Actions is the
4
+ execution plane for cross-platform builds, publication, provenance, and
5
+ downstream packaging.
6
+
7
+ ## Create a release
8
+
9
+ Start from a clean, up-to-date `main` branch and run:
10
+
11
+ ```sh
12
+ vership preflight
13
+ vership bump patch # or minor/major
14
+ tarry cmd --timeout 20m -- vership verify
15
+ ```
16
+
17
+ Vership runs `make check`, updates the package version and changelog, creates
18
+ the Conventional Commit release commit and tag, and pushes both. The tag starts
19
+ the release workflow. Use `vership release` only when the on-disk version was
20
+ intentionally set in advance.
21
+
22
+ Cargo and Python package versions are kept synchronized as one release unit.
23
+
24
+ ## Failure policy
25
+
26
+ If a release fails before any release, artifact, package, checksum, or
27
+ attestation becomes public, delete and recreate the brief tag and retry the
28
+ same version. Once anything was published, preserve the tag and issue a patch
29
+ release for release-content corrections. Registry and downstream-packaging
30
+ jobs are separate recovery domains; retry only the failed job when possible.
31
+
32
+ ## Dry runs
33
+
34
+ The Release workflow can be dispatched from `main` with `dry_run` enabled. It
35
+ uses the package version unless an explicit `version` is supplied, builds the
36
+ real artifacts, and exercises registry validation without publishing packages,
37
+ creating a GitHub release, generating attestations, or updating downstream
38
+ packaging.
@@ -36,6 +36,11 @@ pub enum Command {
36
36
  #[command(subcommand)]
37
37
  command: AuthCommand,
38
38
  },
39
+ /// Inspect resolved, secret-free configuration
40
+ Config {
41
+ #[command(subcommand)]
42
+ command: ConfigCommand,
43
+ },
39
44
  /// Show the signed-in Microsoft 365 identity
40
45
  Whoami,
41
46
  /// List teams joined by the signed-in user
@@ -87,6 +92,17 @@ pub struct InitArgs {
87
92
  /// Request admin-consented access to read channel history
88
93
  #[arg(long)]
89
94
  pub channel_history: bool,
95
+ /// Block remote write operations for this profile
96
+ #[arg(long, env = "TEAMS_READ_ONLY")]
97
+ pub read_only: bool,
98
+ }
99
+
100
+ #[derive(Debug, Subcommand)]
101
+ pub enum ConfigCommand {
102
+ /// Print the resolved profile without credentials
103
+ Show,
104
+ /// Print the absolute configuration file path
105
+ Path,
90
106
  }
91
107
 
92
108
  #[derive(Debug, Subcommand)]
@@ -1,4 +1,5 @@
1
1
  use std::collections::BTreeMap;
2
+ use std::io::Write;
2
3
  use std::path::PathBuf;
3
4
 
4
5
  use serde::{Deserialize, Serialize};
@@ -14,6 +15,21 @@ pub struct Profile {
14
15
  pub client_id: String,
15
16
  #[serde(default = "default_tenant")]
16
17
  pub tenant: String,
18
+ #[serde(default)]
19
+ pub read_only: bool,
20
+ }
21
+
22
+ impl Profile {
23
+ pub fn require_writable(&self) -> Result<(), AppError> {
24
+ if self.read_only {
25
+ Err(AppError::ReadOnly(
26
+ "read-only mode is enabled (unset TEAMS_READ_ONLY or disable read_only in the active profile to allow writes)"
27
+ .into(),
28
+ ))
29
+ } else {
30
+ Ok(())
31
+ }
32
+ }
17
33
  }
18
34
 
19
35
  fn default_tenant() -> String {
@@ -50,12 +66,18 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
50
66
  .ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
51
67
  std::fs::create_dir_all(parent)?;
52
68
  let body = toml::to_string_pretty(&config).map_err(|e| AppError::Unexpected(e.to_string()))?;
53
- std::fs::write(&path, body)?;
69
+ let mut temp = tempfile::Builder::new()
70
+ .prefix(".config-")
71
+ .suffix(".toml.tmp")
72
+ .tempfile_in(parent)?;
73
+ temp.write_all(body.as_bytes())?;
74
+ temp.flush()?;
54
75
  #[cfg(unix)]
55
76
  {
56
77
  use std::os::unix::fs::PermissionsExt;
57
- std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
78
+ std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
58
79
  }
80
+ temp.persist(&path).map_err(|error| error.error)?;
59
81
  Ok(path)
60
82
  }
61
83
 
@@ -79,10 +101,33 @@ pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
79
101
  .ok()
80
102
  .or_else(|| stored.map(|profile| profile.tenant.clone()))
81
103
  .unwrap_or_else(default_tenant);
82
- let profile = Profile { client_id, tenant };
104
+ let read_only = match std::env::var("TEAMS_READ_ONLY") {
105
+ Ok(value) => parse_bool("TEAMS_READ_ONLY", &value)?,
106
+ Err(std::env::VarError::NotPresent) => stored.is_some_and(|profile| profile.read_only),
107
+ Err(error) => {
108
+ return Err(AppError::InvalidInput(format!(
109
+ "cannot read TEAMS_READ_ONLY: {error}"
110
+ )));
111
+ }
112
+ };
113
+ let profile = Profile {
114
+ client_id,
115
+ tenant,
116
+ read_only,
117
+ };
83
118
  Ok((name, profile))
84
119
  }
85
120
 
121
+ fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
122
+ match value.trim().to_ascii_lowercase().as_str() {
123
+ "1" | "true" | "yes" | "on" => Ok(true),
124
+ "0" | "false" | "no" | "off" => Ok(false),
125
+ _ => Err(AppError::InvalidInput(format!(
126
+ "{name} must be true or false"
127
+ ))),
128
+ }
129
+ }
130
+
86
131
  #[cfg(windows)]
87
132
  fn config_base() -> PathBuf {
88
133
  dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"))
@@ -8,6 +8,8 @@ pub enum AppError {
8
8
  #[error("{0}")]
9
9
  Auth(String),
10
10
  #[error("{0}")]
11
+ ReadOnly(String),
12
+ #[error("{0}")]
11
13
  Permission(String),
12
14
  #[error("{0}")]
13
15
  NotFound(String),
@@ -32,7 +34,7 @@ pub struct ErrorContract {
32
34
  }
33
35
 
34
36
  pub const INVALID_INPUT: ErrorContract = ErrorContract {
35
- kind: "usage",
37
+ kind: "invalid_input",
36
38
  exit_code: 2,
37
39
  retryable: false,
38
40
  description: "Arguments or local configuration are invalid",
@@ -45,13 +47,13 @@ pub const AUTH: ErrorContract = ErrorContract {
45
47
  };
46
48
  pub const PERMISSION: ErrorContract = ErrorContract {
47
49
  kind: "permission_denied",
48
- exit_code: 4,
50
+ exit_code: 5,
49
51
  retryable: false,
50
52
  description: "The tenant or granted scopes do not permit the operation",
51
53
  };
52
54
  pub const NOT_FOUND: ErrorContract = ErrorContract {
53
55
  kind: "not_found",
54
- exit_code: 5,
56
+ exit_code: 4,
55
57
  retryable: false,
56
58
  description: "The requested Teams resource does not exist",
57
59
  };
@@ -63,26 +65,33 @@ pub const RATE_LIMIT: ErrorContract = ErrorContract {
63
65
  };
64
66
  pub const API: ErrorContract = ErrorContract {
65
67
  kind: "api_error",
66
- exit_code: 7,
68
+ exit_code: 5,
67
69
  retryable: false,
68
70
  description: "Microsoft Graph returned an API error",
69
71
  };
70
72
  pub const NON_INTERACTIVE: ErrorContract = ErrorContract {
71
73
  kind: "tty_required",
72
- exit_code: 8,
74
+ exit_code: 2,
73
75
  retryable: false,
74
76
  description: "An interactive command was invoked without a terminal",
75
77
  };
76
78
  pub const UNEXPECTED: ErrorContract = ErrorContract {
77
- kind: "internal",
79
+ kind: "unexpected_error",
78
80
  exit_code: 1,
79
81
  retryable: false,
80
82
  description: "An unexpected local or transport error occurred",
81
83
  };
84
+ pub const READ_ONLY: ErrorContract = ErrorContract {
85
+ kind: "read_only",
86
+ exit_code: 2,
87
+ retryable: false,
88
+ description: "The active profile blocks remote write operations",
89
+ };
82
90
 
83
91
  pub const ALL: &[ErrorContract] = &[
84
92
  INVALID_INPUT,
85
93
  AUTH,
94
+ READ_ONLY,
86
95
  PERMISSION,
87
96
  NOT_FOUND,
88
97
  RATE_LIMIT,
@@ -96,6 +105,7 @@ impl AppError {
96
105
  match self {
97
106
  Self::InvalidInput(_) => INVALID_INPUT,
98
107
  Self::Auth(_) => AUTH,
108
+ Self::ReadOnly(_) => READ_ONLY,
99
109
  Self::Permission(_) => PERMISSION,
100
110
  Self::NotFound(_) => NOT_FOUND,
101
111
  Self::RateLimit(_) => RATE_LIMIT,
@@ -6,8 +6,8 @@ use serde_json::Value;
6
6
 
7
7
  use teams_cli::auth;
8
8
  use teams_cli::cli::{
9
- AuthCommand, ChannelsCommand, ChatsCommand, Cli, Command, InitArgs, MessagesCommand,
10
- OffsetPageArgs, PageArgs, TuiArgs,
9
+ AuthCommand, ChannelsCommand, ChatsCommand, Cli, Command, ConfigCommand, InitArgs,
10
+ MessagesCommand, OffsetPageArgs, PageArgs, TuiArgs,
11
11
  };
12
12
  use teams_cli::config::{self, Profile};
13
13
  use teams_cli::error::AppError;
@@ -36,7 +36,7 @@ async fn main() {
36
36
  }
37
37
  };
38
38
  let out = Output {
39
- format: if cli.json {
39
+ format: if cli.json && cli.output == OutputFormat::Auto {
40
40
  OutputFormat::Json
41
41
  } else {
42
42
  cli.output
@@ -60,6 +60,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
60
60
  no_login: false,
61
61
  device_code: false,
62
62
  channel_history: false,
63
+ read_only: false,
63
64
  })
64
65
  }
65
66
  None if io::stdin().is_terminal() && io::stdout().is_terminal() => Command::Tui(TuiArgs {
@@ -77,6 +78,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
77
78
  match command {
78
79
  Command::Init(args) => init(profile_arg.unwrap_or("default"), args, out).await,
79
80
  Command::Auth { command } => auth_command(profile_arg, command, out).await,
81
+ Command::Config { command } => config_command(profile_arg, command, out),
80
82
  Command::Whoami => {
81
83
  let client = client(profile_arg).await?;
82
84
  let value = client.me().await?;
@@ -137,7 +139,10 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
137
139
  "message body cannot be empty".into(),
138
140
  ));
139
141
  }
140
- let client = client(profile_arg).await?;
142
+ let (name, profile) = config::load(profile_arg)?;
143
+ profile.require_writable()?;
144
+ let token = auth::access_token(&name, &profile).await?;
145
+ let client = GraphClient::new(token);
141
146
  let value = client
142
147
  .send(chat.as_deref(), team.as_deref(), channel.as_deref(), &body)
143
148
  .await?;
@@ -199,6 +204,7 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
199
204
  Profile {
200
205
  client_id: client_id.clone(),
201
206
  tenant: args.tenant.clone(),
207
+ read_only: args.read_only,
202
208
  },
203
209
  )?;
204
210
  let mut signed_in = false;
@@ -222,6 +228,7 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
222
228
  client_id: &'a str,
223
229
  tenant: &'a str,
224
230
  channel_history_requested: bool,
231
+ read_only: bool,
225
232
  }
226
233
  let result = InitResult {
227
234
  profile: profile_name,
@@ -230,6 +237,7 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
230
237
  client_id: &client_id,
231
238
  tenant: &args.tenant,
232
239
  channel_history_requested: args.channel_history,
240
+ read_only: args.read_only,
233
241
  };
234
242
  out.value(&result, || {
235
243
  if signed_in {
@@ -240,6 +248,38 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
240
248
  })
241
249
  }
242
250
 
251
+ fn config_command(
252
+ profile_arg: Option<&str>,
253
+ command: ConfigCommand,
254
+ out: Output,
255
+ ) -> Result<(), AppError> {
256
+ match command {
257
+ ConfigCommand::Path => out.value(&config::path().display().to_string(), || {
258
+ config::path().display().to_string()
259
+ }),
260
+ ConfigCommand::Show => {
261
+ let (name, profile) = config::load(profile_arg)?;
262
+ let value = serde_json::json!({
263
+ "profile": name,
264
+ "client_id": profile.client_id,
265
+ "tenant": profile.tenant,
266
+ "read_only": profile.read_only,
267
+ "config_path": config::path(),
268
+ });
269
+ out.value(&value, || {
270
+ format!(
271
+ "Profile: {}\nTenant: {}\nClient ID: {}\nRead only: {}\nConfig: {}",
272
+ value["profile"].as_str().unwrap_or_default(),
273
+ value["tenant"].as_str().unwrap_or_default(),
274
+ value["client_id"].as_str().unwrap_or_default(),
275
+ yes_no(value["read_only"].as_bool().unwrap_or(false)),
276
+ config::path().display(),
277
+ )
278
+ })
279
+ }
280
+ }
281
+ }
282
+
243
283
  async fn auth_command(
244
284
  profile_arg: Option<&str>,
245
285
  command: AuthCommand,
@@ -276,12 +316,13 @@ async fn auth_command(
276
316
  let signed_in = auth::has_token(name);
277
317
  let scopes = auth::granted_scopes(name);
278
318
  let channel_history = auth::channel_history_granted(name);
279
- let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path(),"granted_scopes":scopes,"channel_history":channel_history});
319
+ let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path(),"read_only":configured.as_ref().is_some_and(|(_, profile)| profile.read_only),"granted_scopes":scopes,"channel_history":channel_history});
280
320
  out.value(&value, || {
281
321
  format!(
282
- "Profile: {name}\nConfigured: {}\nSigned in: {}\nChannel history: {}\nConfig: {}",
322
+ "Profile: {name}\nConfigured: {}\nSigned in: {}\nRead only: {}\nChannel history: {}\nConfig: {}",
283
323
  yes_no(configured.is_some()),
284
324
  yes_no(signed_in),
325
+ yes_no(value["read_only"].as_bool().unwrap_or(false)),
285
326
  optional_access(channel_history),
286
327
  config::path().display()
287
328
  )
@@ -398,6 +439,17 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
398
439
  status: if configured.is_some() { "pass" } else { "fail" },
399
440
  detail: config::path().display().to_string(),
400
441
  });
442
+ if let Some((_, profile)) = &configured {
443
+ checks.push(Check {
444
+ name: "write_safety",
445
+ status: "pass",
446
+ detail: if profile.read_only {
447
+ "read-only mode is enabled".into()
448
+ } else {
449
+ "remote writes are enabled".into()
450
+ },
451
+ });
452
+ }
401
453
  let signed_in = configured
402
454
  .as_ref()
403
455
  .is_some_and(|(name, _)| auth::has_token(name));
@@ -5,7 +5,7 @@ use serde::Serialize;
5
5
 
6
6
  use crate::error::AppError;
7
7
 
8
- #[derive(Debug, Clone, Copy, ValueEnum)]
8
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
9
9
  pub enum OutputFormat {
10
10
  Auto,
11
11
  Text,
@@ -62,7 +62,7 @@ pub fn print_error(error: &AppError, structured: bool) {
62
62
  "retryable": contract.retryable,
63
63
  }
64
64
  });
65
- eprintln!("{}", serde_json::to_string(&envelope).unwrap_or_else(|_| r#"{"error":{"kind":"internal","message":"error serialization failed","retryable":false}}"#.into()));
65
+ eprintln!("{}", serde_json::to_string(&envelope).unwrap_or_else(|_| r#"{"error":{"kind":"unexpected_error","message":"error serialization failed","retryable":false}}"#.into()));
66
66
  }
67
67
  }
68
68
 
@@ -73,6 +73,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
73
73
  field("client_id", "string"),
74
74
  field("tenant", "string"),
75
75
  field("channel_history_requested", "boolean"),
76
+ field("read_only", "boolean"),
76
77
  ],
77
78
  );
78
79
  value["args"] = json!([
@@ -92,7 +93,8 @@ pub fn generate(command_filter: Option<&str>) -> Value {
92
93
  "--channel-history",
93
94
  "boolean",
94
95
  "Request admin-consented channel-history access"
95
- )
96
+ ),
97
+ arg("--read-only", "boolean", "Block remote write operations")
96
98
  ]);
97
99
  value
98
100
  },
@@ -137,10 +139,29 @@ pub fn generate(command_filter: Option<&str>) -> Value {
137
139
  field("configured", "boolean"),
138
140
  field("signed_in", "boolean"),
139
141
  field("config_path", "string"),
142
+ field("read_only", "boolean"),
140
143
  nullable_array_field("granted_scopes", "string"),
141
144
  nullable_field("channel_history", "boolean"),
142
145
  ],
143
146
  ),
147
+ single(
148
+ "config show",
149
+ "Print the resolved profile without credentials",
150
+ "read_only",
151
+ vec![
152
+ field("profile", "string"),
153
+ field("client_id", "string"),
154
+ field("tenant", "string"),
155
+ field("read_only", "boolean"),
156
+ field("config_path", "string"),
157
+ ],
158
+ ),
159
+ single(
160
+ "config path",
161
+ "Print the absolute configuration file path",
162
+ "read_only",
163
+ vec![],
164
+ ),
144
165
  single(
145
166
  "whoami",
146
167
  "Show the signed-in Microsoft 365 identity",
@@ -275,6 +296,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
275
296
  "api":"Microsoft Graph v1.0",
276
297
  "default_client_id": crate::config::DEFAULT_CLIENT_ID,
277
298
  "login_modes":["browser_pkce","device_code"],
299
+ "read_only_env":"TEAMS_READ_ONLY",
278
300
  "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
279
301
  "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
280
302
  }
@@ -101,7 +101,7 @@ fn no_args_never_prompts_when_piped() {
101
101
  Command::cargo_bin("teams")
102
102
  .unwrap()
103
103
  .assert()
104
- .code(8)
104
+ .code(2)
105
105
  .stderr(predicate::str::contains("\"kind\":\"tty_required\""));
106
106
  }
107
107
 
@@ -119,9 +119,54 @@ fn noninteractive_init_uses_the_maintained_client_id() {
119
119
  assert_eq!(result["client_id"], "66ebad71-1604-48fc-a086-0d4caa24988b");
120
120
  assert_eq!(result["tenant"], "organizations");
121
121
  assert_eq!(result["channel_history_requested"], false);
122
+ assert_eq!(result["read_only"], false);
122
123
  assert!(temp.path().join("teams/config.toml").is_file());
123
124
  }
124
125
 
126
+ #[test]
127
+ fn init_can_create_a_read_only_profile_and_config_show_resolves_it() {
128
+ let temp = tempfile::tempdir().unwrap();
129
+ Command::cargo_bin("teams")
130
+ .unwrap()
131
+ .env("XDG_CONFIG_HOME", temp.path())
132
+ .args(["init", "--no-login", "--read-only"])
133
+ .assert()
134
+ .success();
135
+
136
+ Command::cargo_bin("teams")
137
+ .unwrap()
138
+ .env("XDG_CONFIG_HOME", temp.path())
139
+ .args(["config", "show"])
140
+ .assert()
141
+ .success()
142
+ .stdout(predicate::str::contains("\"read_only\": true"));
143
+ }
144
+
145
+ #[test]
146
+ fn suite_exit_codes_and_error_kinds_are_stable() {
147
+ let schema: Value = serde_json::from_slice(
148
+ &Command::cargo_bin("teams")
149
+ .unwrap()
150
+ .arg("schema")
151
+ .output()
152
+ .unwrap()
153
+ .stdout,
154
+ )
155
+ .unwrap();
156
+ let errors = schema["errors"].as_array().unwrap();
157
+ let code = |kind: &str| {
158
+ errors.iter().find(|error| error["kind"] == kind).unwrap()["exit_code"]
159
+ .as_i64()
160
+ .unwrap()
161
+ };
162
+ assert_eq!(code("invalid_input"), 2);
163
+ assert_eq!(code("auth"), 3);
164
+ assert_eq!(code("not_found"), 4);
165
+ assert_eq!(code("api_error"), 5);
166
+ assert_eq!(code("rate_limit"), 6);
167
+ assert_eq!(code("read_only"), 2);
168
+ }
169
+
125
170
  #[test]
126
171
  fn headless_browser_login_refuses_before_writing_config() {
127
172
  let temp = tempfile::tempdir().unwrap();
@@ -130,7 +175,7 @@ fn headless_browser_login_refuses_before_writing_config() {
130
175
  .env("XDG_CONFIG_HOME", temp.path())
131
176
  .arg("init")
132
177
  .assert()
133
- .code(8)
178
+ .code(2)
134
179
  .stderr(predicate::str::contains(
135
180
  "browser sign-in requires a terminal",
136
181
  ));
@@ -0,0 +1,10 @@
1
+ [project]
2
+ branch = "main"
3
+
4
+ [changelog]
5
+ unconventional = "exclude"
6
+
7
+ [checks]
8
+ lint = false
9
+ tests = true
10
+ test_command = "make check"
@@ -1,47 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- pull_request:
6
- workflow_call:
7
-
8
- permissions:
9
- contents: read
10
-
11
- jobs:
12
- test:
13
- name: Test (${{ matrix.os }})
14
- strategy:
15
- fail-fast: false
16
- matrix:
17
- os: [ubuntu-latest, macos-latest, windows-latest]
18
- runs-on: ${{ matrix.os }}
19
- steps:
20
- - uses: actions/checkout@v7
21
- - uses: dtolnay/rust-toolchain@stable
22
- - uses: Swatinem/rust-cache@v2
23
- - run: cargo test --locked --all-targets
24
-
25
- quality:
26
- name: Formatting and lints
27
- runs-on: ubuntu-latest
28
- steps:
29
- - uses: actions/checkout@v7
30
- - uses: dtolnay/rust-toolchain@stable
31
- with:
32
- components: rustfmt, clippy
33
- - uses: Swatinem/rust-cache@v2
34
- - run: cargo fmt -- --check
35
- - run: cargo clippy --locked --all-targets -- -D warnings
36
-
37
- security:
38
- name: Dependency audit
39
- runs-on: ubuntu-latest
40
- steps:
41
- - uses: actions/checkout@v7
42
- - uses: dtolnay/rust-toolchain@stable
43
- - uses: Swatinem/rust-cache@v2
44
- - name: Install cargo-audit
45
- run: cargo install cargo-audit --version 0.22.2 --locked
46
- - name: Audit Cargo.lock
47
- run: cargo audit
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes