Metadata-Version: 2.5
Name: metaws
Version: 0.4.0
Summary: Meta-workspace CLI tool for managing a virtual monorepo over independent Git repositories
Author: Viacheslav Karamov
License-Expression: MIT
Requires-Python: >=3.11
Requires-Dist: click~=8.1.7
Requires-Dist: ruamel-yaml~=0.18.0
Requires-Dist: typer~=0.9.0
Provides-Extra: dev
Requires-Dist: hypothesis~=6.82.0; extra == 'dev'
Requires-Dist: mypy~=1.5.0; extra == 'dev'
Requires-Dist: pytest-cov~=4.1.0; extra == 'dev'
Requires-Dist: pytest~=7.4.0; extra == 'dev'
Requires-Dist: ruff~=0.1.0; extra == 'dev'
Description-Content-Type: text/markdown

# metaws — Workspace CLI

`metaws` is an internal CLI tool that turns a YAML manifest (`config.yaml`) into a
virtual monorepo workspace. Instead of cloning and managing dozens of repositories by
hand, you define them once in a config file and let `workspace` handle syncing,
status checking, filtered command execution, and documentation generation.

The installed command is `workspace`.

```
workspace validate          # check your config.yaml
workspace sync              # clone / update all repos
workspace status            # see branch and dirty state at a glance
workspace list --platform ios
workspace exec --profile web -- npm test
workspace generate all      # produce repo-map, agent context, inventory
```

---

## Table of Contents

1. [Prerequisites](#prerequisites)
2. [Installation](#installation)
3. [Quick start](#quick-start)
4. [config.yaml reference](#configyaml-reference)
5. [Commands](#commands)
6. [Profiles and filters](#profiles-and-filters) — ad-hoc `--platform`/`--tag` vs saved profiles
7. [Safety policies](#safety-policies)
8. [Generated context files](#generated-context-files)
9. [Workspace Checkout](#workspace-checkout)
10. [Progress Output](#progress-output)
11. [Parallel Sync](#parallel-sync)
12. [Sync Lifecycle Hooks](#sync-lifecycle-hooks)
13. [Auto-Discovery Scan](#auto-discovery-scan)
14. [Git Worktrees](#git-worktrees)
15. [Releasing a new version](#releasing-a-new-version)

---

## Prerequisites

| Requirement | Minimum version | How to check |
|---|---|---|
| Python | 3.11 | `python3 --version` |
| Git | 2.25 | `git --version` |
| pipx (recommended) | any | `pipx --version` |

**Python 3.11+** is required. Most modern Macs ship with Python 3.9 or earlier via
Xcode tools, so you likely need to install a newer version.

**Install Python 3.11+ (macOS)**

The simplest option is [Homebrew](https://brew.sh):

```bash
brew install python@3.11
```

After that `python3.11 --version` should print `3.11.x` or higher.

**Install pipx**

`pipx` installs Python CLI tools in isolated environments so they don't interfere
with anything else on your machine. It is the recommended way to install `workspace`.

```bash
brew install pipx
pipx ensurepath
```

Restart your terminal after running `pipx ensurepath`.

**What pipx actually does**

When you run `pipx install metaws`, it creates a dedicated virtual environment for
the tool and symlinks the `workspace` binary into your `PATH`:

```
~/.local/pipx/venvs/metaws/      ← isolated venv, invisible to your projects
  bin/workspace
  lib/python3.x/...              ← metaws dependencies live here only

~/.local/bin/workspace           ← symlink on PATH, this is what you type
```

Your own Python environment and other projects are completely unaware of this venv.

---

## Installation

### If you use pyenv

pyenv manages multiple Python versions on a single machine and is common among
iOS/Android/Web developers who already have it for other tooling.

**The short version:** install `pipx` via `pip` instead of Homebrew to keep
everything on your pyenv Python and avoid pulling in an extra system Python:

```bash
# Make sure your pyenv Python is active
pyenv global 3.11.x          # or whichever 3.11+ version you have
python --version              # should print 3.11.x

# Install pipx using that Python — no Homebrew involved
pip install pipx
pipx ensurepath
```

Restart your terminal, then verify:

```bash
pipx --version
python --version              # still 3.11.x — pyenv is unaffected
```

**Why not `brew install pipx`?**
Homebrew's pipx formula declares a dependency on the latest Homebrew Python
(currently 3.14). Homebrew will silently download that Python as a dependency,
even though it is only used to run pipx itself. This does not break anything —
your pyenv Python remains the default — but it installs software you did not
ask for. Installing pipx via `pip` avoids that entirely.

**Pinning the workspace tool to your pyenv Python**

By default, `pipx install` creates the tool's venv using whichever Python ran
pipx. If you installed pipx via `pip` from your pyenv Python that is already
correct. You can verify with:

```bash
pipx list --verbose
# look for "python: .../pyenv/versions/3.11.x/..."
```

If for any reason you need to be explicit:

```bash
pipx install . --python $(pyenv which python)
```

### Recommended — pipx (isolated install)

```bash
pipx install metaws
```

Verify:

```bash
workspace --version
# metaws 0.4.0
```

### Alternative — clone and install locally

If you want to use a specific branch or work on the tool itself:

```bash
# 1. Clone
git clone git@git.betlab.com:betster/common/metaworkspace.git
cd metaworkspace

# 2. Install (pipx creates an isolated environment automatically)
pipx install .
```

Or with plain pip into your current Python environment:

```bash
pip install -e ".[dev]"
```

### Upgrading

```bash
pipx upgrade metaws
```

### Uninstalling

```bash
pipx uninstall metaws
```

---

## Quick start

### 1. Create a workspace directory

```bash
mkdir my-workspace
cd my-workspace
```

### 2. Generate a starter config

```bash
workspace init
```

This creates `config.yaml` with a commented skeleton and adds `repos/` to `.gitignore`.

### 3. Edit `config.yaml`

Open the generated file and fill in your repositories. A minimal example:

```yaml
version: 1

workspace:
  name: my-workspace
  default_branch: main
  repos_root: repos          # clones land in repos/web/apps/fe etc.

repos:
  - name: web-host
    url: git@github.com:your-org/web-host.git
    path: web/apps/web-host  # full clone path: repos/web/apps/web-host
    platforms: [web]
    tags: [host]
    access: write

  - name: ios-host
    url: git@github.com:your-org/ios-host.git
    path: iOS/apps/ios-host  # full clone path: repos/iOS/apps/ios-host
    branch: develop          # overrides default_branch for this repo
    platforms: [ios, mobile]
    tags: [host]
    access: write
```

### 4. Validate

```bash
workspace validate
```

### 5. Clone everything

```bash
workspace sync
```

Repos appear under the `path` values you specified, relative to where `config.yaml`
lives.

---

## config.yaml reference

### Top-level structure

```yaml
version: 1                  # required, must be 1

workspace:
  name: string              # required
  description: string       # optional
  default_branch: string    # required (e.g. "main")
  repos_root: string        # optional; base dir for all clones (e.g. "repos")
  output_path: string       # optional; where generated files are written

repos: []                   # list of repo entries (may be empty)
profiles: {}                # named filters (optional)
policies: {}                # safety rules (optional)
features: {}                # feature flags (optional)
```

### Repo entry

```yaml
- name: web-host                        # unique identifier
  url: git@github.com:org/web.git       # Git URL
  path: apps/web-host                   # local path relative to config.yaml
  branch: develop                       # optional; overrides default_branch
  platforms: [web]                      # at least one value required
  tags: [host, app]                     # optional
  owners: [web-team]                    # optional
  access: write                         # read | write | restricted (default: write)
  description: Web host application     # optional
```

**access levels:**

| Value | Meaning |
|---|---|
| `read` | read-only repo; safe to clone and inspect |
| `write` | normal development repo |
| `restricted` | infra/config repo; exec commands blocked without `--override` |

**branch resolution:**
- If a repo specifies `branch:` → that branch is used.
- Otherwise → `workspace.default_branch` is used as a fallback.

### `workspace.repos_root` — centralise all clones under one directory

By default, repo `path` values are resolved directly relative to the directory
containing `config.yaml` (the workspace root). This means `path: web/apps/fe`
puts the clone at `<workspace_root>/web/apps/fe`.

Set `repos_root` to prefix **every** repo path with a shared base directory:

```yaml
workspace:
  name: my-workspace
  default_branch: main
  repos_root: repos          # all repos land under <workspace_root>/repos/
```

With `repos_root: repos`:

| `path` in config | Actual clone location |
|---|---|
| `web/apps/fe` | `repos/web/apps/fe` |
| `iOS/apps/ios-host` | `repos/iOS/apps/ios-host` |
| `infra/terraform` | `repos/infra/terraform` |

This matches the `repos/` entry that `workspace init` adds to `.gitignore`, so cloned
code is never accidentally committed to the workspace repo itself.

`repos_root` is optional and omitting it preserves the old behaviour for
workspaces that already have repos at custom paths without a common prefix.

### Profiles

Profiles are saved filters you can reference with `--profile`:

```yaml
profiles:
  mobile:
    include_platforms: [ios, android]
    exclude_access: [restricted]

  infra:
    include_platforms: [infra]
    include_access: [restricted]
```

### Policies

```yaml
policies:
  restricted_repos:
    require_explicit_override_for_exec: true   # default: true
    block_destructive_commands: true           # default: true
  destructive_command_patterns:
    - "git reset --hard"
    - "git push --force"
    - "terraform apply"
    - "terraform destroy"
    - "kubectl apply"
    - "flux reconcile"
```

### Features

```yaml
features:
  knowledge_base:
    enabled: false     # set to true to unlock kb commands
    path: kb/
    index: index.md
```

---

## Commands

All commands are run from the directory that contains `config.yaml`, or from any
subdirectory inside the workspace.

### `workspace validate`

Check `config.yaml` for errors. Reports all issues at once (not just the first).

```bash
workspace validate
```

### `workspace list`

List repositories. Accepts filter flags.

```bash
workspace list
workspace list --platform ios
workspace list --platform ios --tag host
workspace list --profile mobile
workspace list --json
```

### `workspace sync`

Clone missing repos and pull updates for existing ones.

```bash
workspace sync                          # all repos
workspace sync --platform android
workspace sync --profile web
workspace sync --dry-run                # show what would happen, change nothing
workspace sync --fail-fast              # stop on first error
workspace sync --platform ios -j 8      # parallel with 8 jobs
```

What it does for each repo:
- Not yet cloned → `git clone`
- Already on expected branch → `git pull --rebase`
- On a different branch, no uncommitted changes → `git checkout <branch>` then pull
- On a different branch, has uncommitted changes → skip with a warning

### `workspace status`

Show current state of all cloned repos.

```bash
workspace status
workspace status --platform ios
workspace status --profile mobile
workspace status --json
```

Output columns: repo name, current branch, expected branch (mismatch highlighted),
dirty state, ahead/behind counts, tracking status. Repos not yet cloned are shown
as "not cloned".

### `workspace exec`

Run a shell command in every repo that matches the filter. At least one filter flag
is required.

```bash
workspace exec --profile ios -- xcodebuild test
workspace exec --platform android -- ./gradlew test
workspace exec --platform web -- npm test
workspace exec --tag payments -- make test
workspace exec --profile product --dry-run -- make lint
workspace exec --profile infra --override -- terraform plan   # bypass restricted policy
workspace exec --profile web --fail-fast -- npm ci
```

The `--` separator between `workspace exec` flags and the actual command is
recommended but optional.

### `workspace generate`

Generate Markdown context files from `config.yaml`.

```bash
workspace generate repo-map             # repo-map.generated.md
workspace generate agent-context        # AGENTS.generated.md
workspace generate all                  # all three files
workspace generate all --check          # exit non-zero if any file is stale
```

Useful in CI: `workspace generate all --check` fails the build if generated files
were not regenerated after a `config.yaml` change.

### `workspace init`

Scaffold a new `config.yaml` in the current directory.

```bash
workspace init
```

Exits with an error if `config.yaml` already exists.

### `workspace worktree`

Manage Git worktrees — work on multiple branches of the same repo at the same time
without switching branches.

```bash
workspace worktree add feature/my-branch --platform web
workspace worktree list
workspace worktree list --json
workspace worktree remove feature/my-branch --platform web
workspace worktree remove feature/my-branch --platform web --force   # even if dirty
```

Worktrees are created at `<repo-dir>--<branch>` next to the main clone by default.
Override the path with `--path`.

### Global flags

These work with every command:

| Flag | Effect |
|---|---|
| `--verbose` / `-v` | Show git commands and timing |
| `--quiet` / `-q` | Show only errors and final summary |
| `--help` | Show usage for the command |
| `--version` | Print `metaws <version>` |

---

## Hook Environment Variables (`--env` flag and `hooks.env`)

Inject custom environment variables into hook and exec subprocesses — without
modifying scripts or hardcoding values in `config.yaml`.

**CLI `--env` flag (repeatable):**

```bash
workspace sync --platform ios --env BRAND=Rocket --env OPEN=false
workspace exec --platform ios --env BUILD_TYPE=debug -- make build
workspace checkout develop --platform ios --env FEATURE_FLAG=on
```

**Manifest defaults (`hooks.env` section):**

```yaml
hooks:
  env:
    BRAND: TEST1
    OPEN: "true"
  post_sync:
    - name: ios-superapp
      run: ./setup_project.sh --brand $BRAND ${OPEN:+--open}
```

**Three-layer priority (lowest → highest):**

```
os.environ  →  hooks.env (manifest defaults)  →  --env flags (CLI overrides)
```

**Sensitive key masking:**

Keys containing `SECRET`, `TOKEN`, or `PASSWORD` (case-insensitive) have their
values replaced with `***` in `--verbose` and `--dry-run` output:

```
CLI environment variables (--env):
  BRAND=Rocket
  SECRET_KEY=***
```

**`workspace validate` checks `hooks.env` keys** — invalid key names (e.g.,
starting with a digit or containing hyphens) are reported as validation errors.

---

## Workspace Checkout

Safely switch branches across multiple repos. Skips dirty repos, reports missing branches.

```bash
workspace checkout develop --platform ios
workspace checkout feature/my-branch --platform ios --create
workspace checkout develop --platform ios --pull
workspace checkout develop --platform ios --dry-run
```

Flags:
| Flag | Effect |
|---|---|
| `--create` | Create branch if it doesn't exist locally |
| `--pull` | Run `git pull --rebase` after switching |
| `--dry-run` | Preview without executing |
| `--fail-fast` | Stop on first unexpected error |

At the end, prints a summary: how many switched, skipped (dirty), skipped (not found), etc.

---

## Progress Output

Real-time progress streaming from git clone/pull operations is enabled by default. Each line is prefixed with `[repo-name]`:

```
[ios-superapp] Receiving objects:  42% (150000/357000)
[ios-profile]  Receiving objects: 100% (8500/8500), done.
```

Suppressed with `--quiet` or `--json`.

---

## Parallel Sync

Run sync operations concurrently with `--jobs` / `-j`:

```bash
workspace sync --platform ios -j 8
workspace sync -j 4 --fail-fast
```

Default is 1 (sequential, backward-compatible). Progress lines include `[repo-name]` prefix for disambiguation. Only applies to `sync` — `exec` remains sequential.

---

## Sync Lifecycle Hooks

Define shell commands in `config.yaml` that run before/after sync per repo:

```yaml
hooks:
  pre_sync:
    - name: ios-profile
      run: echo "about to sync ios-profile"
  post_sync:
    - name: ios-profile
      run: echo "ios-profile synced"
    - name: ios-superapp
      run: ./setup_project.sh --brand TEST1 --open
      depends_on:
        - ios-profile
```

Key points:
- Hook failure produces a warning but does NOT fail the sync
- `depends_on` controls execution order (hooks wait for listed repos to complete)
- Hooks are matched by exact `name` to a repo in the manifest
- `--dry-run` shows which hooks would run without executing them
- `--quiet` suppresses hook stdout unless the hook fails
- Missing `hooks` section is fine — backward-compatible

---

## Auto-Discovery Scan

Discover Git repos in a directory that aren't in the manifest:

```bash
workspace scan ~/Projects/iOS --platform ios --tag modules
workspace scan ./repos --max-depth 2
workspace scan ~/Projects/Android --write    # append to config.yaml
```

Flags:
| Flag | Effect |
|---|---|
| `--platform` | Assign this platform to all discovered repos |
| `--tag` | Assign this tag to all discovered repos |
| `--max-depth N` | Limit traversal depth (default: 3) |
| `--write` | Append to config.yaml instead of stdout |

Repos already in the manifest are excluded. Repos without an `origin` remote are skipped with a warning.

---

## Profiles and filters

### Ad-hoc filters with `--platform` and `--tag`

`--platform` and `--tag` are quick one-off filters you pass directly on the command
line. They work on every command that accepts a repo selection: `list`, `sync`,
`status`, `exec`, and `worktree`.

```bash
workspace list --platform ios
workspace sync --platform android
workspace status --tag payments
workspace exec --platform web -- npm test
```

**Combining multiple values**

Pass the same flag more than once to match any of the given values (**OR** logic
within the same type):

```bash
# repos that are on ios OR android
workspace list --platform ios --platform android
```

Mix `--platform` and `--tag` together to require both (**AND** logic across types):

```bash
# repos that are on ios AND tagged as host
workspace list --platform ios --tag host

# repos that are on web AND tagged as payments
workspace exec --platform web --tag payments -- make test
```

**Errors on unknown values**

If the value you pass does not match any repo in the manifest, the command exits
with an error instead of silently returning an empty list:

```
Error: platform 'macos' does not match any repository in the manifest.
```

This prevents typos from going unnoticed.

---

### Profiles — saved filters

Ad-hoc filters are handy for one-off use, but if you repeat the same combination
daily (e.g., "all iOS repos excluding infra"), define it once as a **profile** in
`config.yaml` and refer to it by name with `--profile`.

Profiles support six fields, all optional:

| Field | Meaning |
|---|---|
| `include_platforms` | repo must belong to at least one of these platforms |
| `include_tags` | repo must have at least one of these tags |
| `include_access` | repo's access level must be one of these values |
| `exclude_platforms` | exclude repos that belong to any of these platforms |
| `exclude_tags` | exclude repos that have any of these tags |
| `exclude_access` | exclude repos whose access level is in this list |

A repo is selected when it passes **all** include rules that are defined AND does
**not** match any exclude rule.

#### Defining profiles

```yaml
profiles:
  # All iOS repos, no infra
  ios:
    include_platforms: [ios]
    exclude_access: [restricted]

  # iOS + Android together (the "mobile" slice)
  mobile:
    include_platforms: [ios, android]
    exclude_access: [restricted]

  # Everything a product team touches: web, iOS, Android, shared modules
  product:
    include_platforms: [web, android, ios, mobile, product]
    include_tags: [host, module, payments, checkout, cms]
    exclude_access: [restricted]

  # Infra repos only — include restricted explicitly
  infra:
    include_platforms: [infra]
    include_access: [read, write, restricted]

  # Only read-only repos across all platforms
  readonly:
    include_access: [read]
```

#### Using profiles

```bash
workspace list --profile mobile
workspace sync --profile product
workspace status --profile ios
workspace exec --profile android -- ./gradlew test
workspace worktree add feature/my-branch --profile mobile
```

`--profile` cannot be combined with `--platform` or `--tag` in the same invocation.
If you need a combination that is not covered by an existing profile, either add a
new profile to `config.yaml` or use `--platform` / `--tag` directly.

#### How the evaluation works

Given a repo, the profile filter runs these checks in order:

1. **`include_platforms`** defined? → repo's platforms must overlap with the list.
   If not defined, all repos pass this step.
2. **`include_tags`** defined? → repo's tags must overlap with the list.
   If not defined, all repos pass this step.
3. **`include_access`** defined? → repo's access level must be in the list.
   If not defined, all repos pass this step.
4. **`exclude_platforms`** defined? → repo is removed if its platforms overlap.
5. **`exclude_tags`** defined? → repo is removed if its tags overlap.
6. **`exclude_access`** defined? → repo is removed if its access level is in the list.

A repo is included only when it passes every step.

#### Worked example

Suppose your manifest contains these repos:

```yaml
repos:
  - name: web-host
    platforms: [web]
    tags: [host]
    access: write

  - name: checkout-module
    platforms: [web, ios, android]
    tags: [module, payments]
    access: write

  - name: terraform
    platforms: [infra]
    tags: [terraform]
    access: restricted

  - name: flux-configs
    platforms: [infra]
    tags: [flux]
    access: restricted
```

With this profile:

```yaml
profiles:
  product:
    include_platforms: [web, ios, android]
    exclude_access: [restricted]
```

Running `workspace list --profile product`:

| Repo | include_platforms check | exclude_access check | Result |
|---|---|---|---|
| web-host | ✅ `web` in list | ✅ `write` not excluded | **included** |
| checkout-module | ✅ `web` in list | ✅ `write` not excluded | **included** |
| terraform | ❌ `infra` not in list | — | **excluded** |
| flux-configs | ❌ `infra` not in list | — | **excluded** |

With this profile:

```yaml
profiles:
  infra:
    include_platforms: [infra]
    include_access: [restricted]
```

Running `workspace list --profile infra`:

| Repo | include_platforms check | include_access check | Result |
|---|---|---|---|
| web-host | ❌ `web` not in list | — | **excluded** |
| checkout-module | ❌ `web` not in list | — | **excluded** |
| terraform | ✅ `infra` in list | ✅ `restricted` in list | **included** |
| flux-configs | ✅ `infra` in list | ✅ `restricted` in list | **included** |

#### Validation warnings

If a profile references a platform or tag value that no repo in the manifest uses,
`workspace validate` prints a warning (not an error — the profile is still valid,
it just matches nothing):

```
Warning: profile 'mobile' includes platform 'watchos' which is not used by any repo.
```

---

### Summary: `--platform`/`--tag` vs `--profile`

| | `--platform` / `--tag` | `--profile` |
|---|---|---|
| Defined in | command line | `config.yaml` |
| Reusable | no | yes |
| Supports exclude rules | no | yes |
| Supports access-level filtering | no | yes |
| Combinable with each other | yes | no |
| Good for | quick one-off queries | repeatable team workflows |

---

## Safety policies

Repos with `access: restricted` are protected:

- `workspace exec` skips them unless you pass `--override`
- Commands matching `destructive_command_patterns` are blocked unless you pass
  `--override`

When `--override` is used, a warning is printed to stderr for each bypassed policy.
This is intentional — it creates a visible audit trail.

If `policies` is missing or `destructive_command_patterns` is empty, the policy is
inactive and all commands are allowed.

---

## Generated context files

`workspace generate all` produces three files:

| File | Contents |
|---|---|
| `repo-map.generated.md` | Table of all repos with path, platforms, tags, access, branch |
| `AGENTS.generated.md` | Workspace overview for AI agents, including repo details |
| `workspace-inventory.generated.md` | Repos grouped by platform with access summary |

All files start with:

```
<!-- AUTO-GENERATED FILE. Source of truth: config.yaml. Do not edit manually. Run: workspace generate all -->
```

Files are written to the workspace root by default. Set `workspace.output_path` in
`config.yaml` to redirect them to a subdirectory.

---

## Git Worktrees

Worktrees let you check out a second branch of a repo into a separate directory
without affecting your current checkout. The tool follows the naming convention
`<repo-dir>--<branch>` so worktree directories are easy to identify.

```bash
# Create a worktree for a feature branch in all iOS repos
workspace worktree add feature/my-feature --platform ios

# List what's open
workspace worktree list --platform ios

# Remove it when done
workspace worktree remove feature/my-feature --platform ios
```

---

## Releasing a new version

The single source of truth for the version is `src/metaws/_version.py`:

```python
__version__ = "0.4.0"
```

`workspace --version`, the wheel metadata, and the package name all read from
this file. To release, edit only this file — nothing else needs to change.

### Versioning scheme

Follow [Semantic Versioning](https://semver.org/):

| What changed | Before | After | Example |
|---|---|---|---|
| Bug fix or small patch | `0.1.0` | `0.1.1` | fixed `repos_root` path resolution |
| New feature, backward-compatible | `0.1.0` | `0.2.0` | added checkout, parallel sync, hooks, scan |
| Breaking change in `config.yaml` schema | `0.2.0` | `1.0.0` | renamed a manifest field |

Versions below `1.0.0` signal that the API is still evolving.

### Release steps

Tags use the bare version number without a `v` prefix (e.g., `0.4.0` not `v0.4.0`).
Pushing a tag triggers the CI pipeline which runs tests and publishes to PyPI automatically.

```bash
# 1. Edit the version
#    open src/metaws/_version.py and change __version__ = "0.4.0"

# 2. Run the full test suite locally
python -m pytest -q

# 3. Commit the version bump
git add src/metaws/_version.py
git commit -m "chore: release 0.4.0"

# 4. Tag and push
git tag 0.4.0
git push origin main
git push origin 0.4.0
```

The CI pipeline runs `test` then `publish`. Monitor it in GitLab. If the publish job
fails, check:
- `PYPI_TOKEN` is set as a **protected** CI/CD variable (Settings → CI/CD → Variables)
- The tag is **protected** (Settings → Repository → Protected tags) — protected
  variables are only injected into jobs triggered by protected refs

### Building manually

If you need to build the wheel locally (e.g. to test the artifact before publishing):

```bash
pip install hatch
hatch build
# produces dist/metaws-0.4.0-py3-none-any.whl and dist/metaws-0.4.0.tar.gz
```

To publish manually:

```bash
hatch publish --user __token__ --auth <your-pypi-token>
```

---

See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, running tests, and project structure.
See [CHANGELOG.md](CHANGELOG.md) for version history.
