zyplux-airlock 2.0.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.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.3
2
+ Name: zyplux-airlock
3
+ Version: 2.0.0
4
+ Summary: Prepare and publish reviewed npm, Python, container and GitHub Action releases
5
+ Requires-Dist: docker>=7.2.0
6
+ Requires-Dist: httpx>=0.28.1
7
+ Requires-Dist: markdown-it-py>=4.0.0
8
+ Requires-Dist: packaging>=26.0
9
+ Requires-Dist: pydantic>=2.12.0
10
+ Requires-Dist: semantic-version>=2.10.0
11
+ Requires-Dist: tomlkit>=0.15.2
12
+ Requires-Dist: typer>=0.27.3
13
+ Requires-Python: >=3.14
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Airlock
17
+
18
+ Airlock is a Python 3.14+ CLI. It prepares reviewed versions and changelogs with native pnpm, then publishes npm packages, Python packages, containers and GitHub Actions declared in `airlock-targets.toml`. Merging a prepared feature PR into `main` starts publication automatically.
19
+
20
+ *How does a code change become a published release?*
21
+
22
+ ```mermaid
23
+ flowchart TD
24
+ Change["Change release contents"] --> Intent["pnpm change: choose targets, bump and notes"]
25
+ Intent --> Prepare["pnpm release:prepare: versions, changelogs and lockfiles"]
26
+ Prepare --> Check["./just c: tests and Cerberus"]
27
+ Check --> Review["Commit and review the prepared feature PR"]
28
+ Review --> Merge["Merge into main"]
29
+ Merge --> Plan["Airlock plans pending publication batches"]
30
+ Plan --> Npm["npm jobs: wait for dependencies, publish package"]
31
+ Plan --> Python["Python jobs: uv build and publish"]
32
+ Plan --> Container["Container jobs: images and release assets"]
33
+ Plan --> Action["Action jobs: versioned tags and major aliases"]
34
+ Npm --> Github["Per-target GitHub release with reviewed notes"]
35
+ Python --> Github
36
+ Container --> Github
37
+ Action --> Github
38
+ ```
39
+
40
+ ## Prepare a feature PR
41
+
42
+ 1. Run `uv run cerberus --check release_surface_version_bump` to identify changed published targets.
43
+ 2. Run `pnpm change`, select those targets and choose patch for fixes, minor for compatible features or major for breaking changes. Write a concise consumer-facing summary, including migration steps for breaking changes; pnpm adds the list marker. Use separate fragments when targets need different descriptions.
44
+ 3. Run the commands below. Review the versions, dependency changes, changelog entries, `.changeset/ledger.yaml` and both lockfiles, then include those edits with the feature.
45
+ 4. Submit the PR through the repository's normal review process. After merge, check the automatic release run and its per-batch summaries; each GitHub release should contain the prepared entry for that version.
46
+
47
+ ```sh
48
+ pnpm release:prepare
49
+ ./just c
50
+ ```
51
+
52
+ Preparation invokes `pnpm version -r`, consumes change intents and propagates dependency versions. Set `versioning.changelog.storage: repository` in `pnpm-workspace.yaml` so generated changelogs are reviewed in the PR. Only declared release targets publish; private dependent workspaces can also receive version bumps.
53
+
54
+ Python targets have a private pnpm tracking `package.json` beside `pyproject.toml`; preparation synchronizes the native Python version through uv. Preparation initializes or refreshes every target's fingerprint after versioning. Cerberus's `release_readiness` bite rejects unconsumed fragments, mismatched versions, stale fingerprints and missing or empty changelog entries.
55
+
56
+ If preparation needs regeneration, preserve the code and note text, restore generated release files to the current `main` baseline, recreate the fragments and rerun `pnpm release:prepare` after any preceding release publishes.
57
+
58
+ ## Release content validation
59
+
60
+ Cerberus's `release_surface_version_bump` bite enforces single-step version bumps against finalized GitHub releases and the pull request target branch. Set `GITHUB_BASE_REF` to compare against a target branch; published versions also require unchanged release contents. Target-branch versions can be repaired without another bump before their first registry upload. Once any package artifact exists, including an incomplete PyPI upload, the target branch's fingerprint must remain unchanged until that version's GitHub release is finalized.
61
+
62
+ `airlock prepare` records each target's `content_hash` in `airlock-targets.toml` after preparing versions, changelogs and lockfiles. Fingerprints use `sha256:<digest>`. Hashes cover sorted paths, file contents and executable permissions; source fingerprints also cover entry kinds and symlink destinations. Timestamps, ordering and compression are ignored. TOML comments and layout are preserved.
63
+
64
+ Package archives must exclude `airlock-targets.toml` to avoid self-referential fingerprints.
65
+
66
+ | Target | Fingerprinted contents |
67
+ | --- | --- |
68
+ | npm | The normal `pnpm pack` archive, including bundled code and package metadata. |
69
+ | Python | Both the wheel and source distribution produced by `uv build`. |
70
+ | Action | Git-visible files in the Action directory and the directory containing its version file, plus declared `runtime_files`. `path` selects the Action directory when it differs from the version directory. |
71
+ | Container | The selected build-context and Dockerfile paths, plus the Dockerfile and Git-visible build-context files, filtered with Docker's SDK matcher. `.containerignore` takes precedence over `.dockerignore`. |
72
+
73
+ Git-visible files include tracked files and new files permitted by Git's ignore rules. The release manifest is excluded from source fingerprints because it stores the fingerprints themselves. Action and container hashes are a best-effort check of local inputs: they do not follow dependencies outside these directories, resolve remote base images or execute container builds. Git submodule contents are outside the check's scope.
74
+
75
+ Python packages pin `uv_build` exactly in `build-system.requires`. uv uses that backend version even when its bundled backend differs, keeping wheel metadata and fingerprints consistent across preparation, CI and publication.
76
+
77
+ Bump detection is a best-effort reminder using prepared fingerprints; release readiness owns artifact validation. Cerberus compares each prepared fingerprint with the latest finalized stable GitHub release's tagged manifest, without rebuilding packages. The current PR's fingerprint cannot replace that baseline. One GitHub release-history lookup serves all targets; comparisons read Git history without downloading published artifacts. The first release with a fingerprint establishes the baseline; content comparisons begin after that release.
78
+
79
+ Preparation also records each package's `requires` list of matching workspace release tags from its built metadata. Cerberus's `release_readiness` bite requires and recalculates every target's fingerprint and package dependency list. Run `uv run cerberus --check release_readiness` for a focused readiness check. Publication verifies the same npm/Python archives it uploads and verifies Action/container inputs before publication. Build and fingerprint failures are errors.
80
+
81
+ Release checks require the GitHub CLI to read published releases. Authenticate locally with `gh auth login`; in CI, set `GH_TOKEN` to the workflow token with `contents: read` permission.
82
+
83
+ Cerberus reports release readiness and enforces the version-bump policy. It reuses Airlock's fingerprints, manifest parsing, GitHub release history and version parsing through its `zyplux-airlock` Python dependency.
84
+
85
+ Private GitHub registry reads use `NODE_AUTH_TOKEN` for npm, or `GH_TOKEN`/`GITHUB_TOKEN`, and otherwise reuse `gh auth token --hostname github.com`. Local users can sign in with `gh auth login --scopes read:packages`. Tokens need access to the package; Actions use the job token and package access granted to the repository. Explicit credentials take precedence, and registry failures remain errors.
86
+
87
+ ## Automatic publication
88
+
89
+ The [release workflow](../../.github/workflows/release.yml) has a planning job and a publication matrix. `airlock plan` validates prepared releases and emits `matrix` and `pending` action outputs. Each row contains the exact release tags, a stable concurrency ID, an environment and a runner. `airlock publish --batch JSON` validates that row against the checked-out configuration and publishes its selected targets.
90
+
91
+ Every target has its own publication job, concurrency ID and GitHub release. Environments default to `npm`, `pypi`, `ghcr` and `actions`; set `environment` on a target to override it. The shared setup supports Linux runners.
92
+
93
+ Planning selects pending targets without building or packing packages. Each npm job packs its target once, inspects the archive's manifest after lifecycle scripts and `beforePacking` hooks, then publishes that same archive. Declare workspace dependencies retained in `dependencies` or `optionalDependencies` as npm release targets; private implementation packages removed during packing need no release target. A matching retained dependency on a declared workspace package is treated as a release edge, regardless of local linking settings. Each job waits for those workspace versions and tarballs before uploading. Development-only dependencies do not delay publication. Missing dependencies are checked every ten seconds for up to sixty retries; failed dependencies block their consumers while unrelated jobs continue. Packing failures affect only their target's job.
94
+
95
+ Each successful job finalizes its target's release; a failing job leaves the other jobs running. Npm publication requires each target to match a non-private workspace package at its configured version. Native pnpm publishes the inspected archive. Airlock verifies the exact version in registry metadata and uses GET to check tarball accessibility before creating its GitHub release. Airlock owns availability waiting; pnpm's HEAD-based wait is disabled because GitHub Packages rejects that method. Repairing a missing GitHub release for an existing npm or PyPI version rebuilds and validates its current fingerprint without uploading again. PyPI completion requires a wheel and source archive; uv checks the index to skip existing files when retrying incomplete uploads. Action publication creates a fixed versioned release and advances its compatible major alias to the newest published stable version. Package `prepack` hooks own npm builds and archive validation; publish-only lifecycle hooks are rejected before packing.
96
+
97
+ Python publication reads `Requires-Dist` from the built wheel and waits for matching versions of other configured PyPI targets before uploading those same archives. Matching dependencies include optional and conditional installations. The dependency's wheel and source archive must both be visible on PyPI; missing artifacts and transient registry failures use the same bounded wait as npm. A failed dependency blocks its consumers while unrelated jobs continue.
98
+
99
+ Each npm and Python job rejects cycles among its reachable unpublished workspace dependencies before waiting. Cycle checks use the prepared `requires` lists, validated against the actual archives during readiness checks and publication. Packing hooks, bundled dependencies and dynamic Python metadata are resolved during preparation; publication does not repack peer packages. Existing publications break dependency cycles; unrelated jobs remain independent.
100
+
101
+ To recover from a failed run, fix the cause and rerun the failed jobs in GitHub Actions, or dispatch `release.yml` on `main` with an optional `tag` to select one release.
102
+
103
+ ## Add Airlock to a repository
104
+
105
+ Configure pnpm versioning and declare `airlock-targets.toml`. Cerberus includes Airlock in its Python dependencies, so repositories using Cerberus can run `uv run airlock`. For a standalone CLI, use `uv tool install zyplux-airlock`. Use the [release workflow](../../.github/workflows/release.yml) as the consumer template, replacing its local action references with:
106
+
107
+ ```yaml
108
+ - uses: zyplux/zyplux/apps/action-airlock@action-airlock-v2
109
+ with:
110
+ command: plan
111
+ ```
112
+
113
+ The publishing step uses the same action with `command: publish` and `batch: ${{ toJSON(matrix) }}`. Planning sets up uv and the publisher runtime. Publication also sets up pnpm, installs the repository's locked build dependencies and configures GitHub Packages authentication. It installs Airlock from the action's versioned source and `uv.lock` in a separate environment; the action reference selects the publisher version. Its `token` defaults to the job's GitHub token.
114
+
115
+ The calling workflow declares triggers, permissions, concurrency and environments. A single matrix uses their combined token permissions; separate job definitions can narrow permissions by publication kind. Keep OIDC publishing jobs in this top-level workflow to support PyPI trusted publishing.
116
+
117
+ Airlock supplies each npm scope's `publishConfig` registry to pnpm for preparation lookups. Targets in one scope must share a publication registry. Public npm and PyPI use trusted publishing; GitHub Packages and GHCR use the job token with `packages: write`.
118
+
119
+ ## Add a release target
120
+
121
+ 1. Add a named table with its kind and version source to `airlock-targets.toml`. The table name supplies the `<name>-v` tag prefix; its label defaults to `<scope>/<name>` and can be overridden explicitly.
122
+ 2. Ensure pnpm discovers its version manifest. Python packages need the private tracking manifest described above; actions use a workspace `package.json`, with `private: true` when there is no npm package.
123
+ 3. Complete the registry setup below, then prepare and merge the release through the feature PR procedure.
124
+
125
+ Version files and declared paths must be repository-relative and resolve inside the repository. Set the top-level `scope` before the target tables:
126
+
127
+ ```toml
128
+ scope = "@zyplux"
129
+
130
+ [airlock]
131
+ kind = "pypi"
132
+ label = "zyplux-airlock"
133
+ version = { file = "apps/airlock/pyproject.toml", regex = '^version = "([^"]+)"' }
134
+ ```
135
+
136
+ Actions also declare their GitHub `repository`, which must match the checkout because publication writes its release tags through `origin`. Set `path = "apps/action-airlock"` when the Action directory differs from its version-file directory. Declare installation inputs outside those directories with `runtime_files`; Airlock's action includes the root `pyproject.toml` and `uv.lock`. These files must exist inside the repository. Airlock publishes as Python distribution `zyplux-airlock` with `airlock-vX.Y.Z` tags; its action uses fixed `action-airlock-vX.Y.Z` tags and the `action-airlock-vX` alias. The package-manager action uses `setup-pm-vX.Y.Z` and `setup-pm-vX`.
137
+
138
+ ## Container releases
139
+
140
+ Declare a GHCR target with a version source, build paths and image label:
141
+
142
+ ```toml
143
+ [core]
144
+ kind = "ghcr"
145
+ label = "ghcr.io/zyplux/zyploop"
146
+ version = { file = "apps/zyp-loop/package.json", json = "version" }
147
+ dockerfile = "apps/zyp-loop/Dockerfile"
148
+ build_context = "."
149
+ ```
150
+
151
+ Both build paths are relative to the repository root and must stay inside it. The build context defaults to the version file's directory. When `dockerfile` is omitted, Podman discovers `Containerfile` or `Dockerfile` in the build context.
152
+
153
+ Airlock authenticates GHCR, builds the versioned image with `org.opencontainers.image.version` and `org.opencontainers.image.revision` labels, and pushes it with Podman's `--digestfile`. It validates the returned SHA-256 digest and verifies registry availability before generating `release.json`:
154
+
155
+ ```json
156
+ {
157
+ "version": "1.2.3",
158
+ "image": "ghcr.io/zyplux/zyploop@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
159
+ }
160
+ ```
161
+
162
+ Airlock attaches this manifest to a draft GitHub release, then finalizes the release with its prepared changelog. Retrying an unfinished release rebuilds and records the newly pushed digest. Finalized releases retain their manifest and image; missing artifacts fail validation. Dispatch `release.yml` with the target tag to retry a container release. Deployment consumes the manifest separately.
163
+
164
+ ## Registry setup
165
+
166
+ ### npm
167
+
168
+ Follow the [manual first-publication guide](../../docs/guide/publish.md#first-npm-publication) to publish a new public npm package and configure trusted publishing for subsequent releases.
169
+
170
+ ### PyPI
171
+
172
+ Add a pending publisher before first publication: distribution name, owner and repository, workflow filename `release.yml` and environment `pypi` or the target's configured override. Preparing and merging the release publishes it with OIDC. PyPI requires no bootstrap token.
@@ -0,0 +1,157 @@
1
+ # Airlock
2
+
3
+ Airlock is a Python 3.14+ CLI. It prepares reviewed versions and changelogs with native pnpm, then publishes npm packages, Python packages, containers and GitHub Actions declared in `airlock-targets.toml`. Merging a prepared feature PR into `main` starts publication automatically.
4
+
5
+ *How does a code change become a published release?*
6
+
7
+ ```mermaid
8
+ flowchart TD
9
+ Change["Change release contents"] --> Intent["pnpm change: choose targets, bump and notes"]
10
+ Intent --> Prepare["pnpm release:prepare: versions, changelogs and lockfiles"]
11
+ Prepare --> Check["./just c: tests and Cerberus"]
12
+ Check --> Review["Commit and review the prepared feature PR"]
13
+ Review --> Merge["Merge into main"]
14
+ Merge --> Plan["Airlock plans pending publication batches"]
15
+ Plan --> Npm["npm jobs: wait for dependencies, publish package"]
16
+ Plan --> Python["Python jobs: uv build and publish"]
17
+ Plan --> Container["Container jobs: images and release assets"]
18
+ Plan --> Action["Action jobs: versioned tags and major aliases"]
19
+ Npm --> Github["Per-target GitHub release with reviewed notes"]
20
+ Python --> Github
21
+ Container --> Github
22
+ Action --> Github
23
+ ```
24
+
25
+ ## Prepare a feature PR
26
+
27
+ 1. Run `uv run cerberus --check release_surface_version_bump` to identify changed published targets.
28
+ 2. Run `pnpm change`, select those targets and choose patch for fixes, minor for compatible features or major for breaking changes. Write a concise consumer-facing summary, including migration steps for breaking changes; pnpm adds the list marker. Use separate fragments when targets need different descriptions.
29
+ 3. Run the commands below. Review the versions, dependency changes, changelog entries, `.changeset/ledger.yaml` and both lockfiles, then include those edits with the feature.
30
+ 4. Submit the PR through the repository's normal review process. After merge, check the automatic release run and its per-batch summaries; each GitHub release should contain the prepared entry for that version.
31
+
32
+ ```sh
33
+ pnpm release:prepare
34
+ ./just c
35
+ ```
36
+
37
+ Preparation invokes `pnpm version -r`, consumes change intents and propagates dependency versions. Set `versioning.changelog.storage: repository` in `pnpm-workspace.yaml` so generated changelogs are reviewed in the PR. Only declared release targets publish; private dependent workspaces can also receive version bumps.
38
+
39
+ Python targets have a private pnpm tracking `package.json` beside `pyproject.toml`; preparation synchronizes the native Python version through uv. Preparation initializes or refreshes every target's fingerprint after versioning. Cerberus's `release_readiness` bite rejects unconsumed fragments, mismatched versions, stale fingerprints and missing or empty changelog entries.
40
+
41
+ If preparation needs regeneration, preserve the code and note text, restore generated release files to the current `main` baseline, recreate the fragments and rerun `pnpm release:prepare` after any preceding release publishes.
42
+
43
+ ## Release content validation
44
+
45
+ Cerberus's `release_surface_version_bump` bite enforces single-step version bumps against finalized GitHub releases and the pull request target branch. Set `GITHUB_BASE_REF` to compare against a target branch; published versions also require unchanged release contents. Target-branch versions can be repaired without another bump before their first registry upload. Once any package artifact exists, including an incomplete PyPI upload, the target branch's fingerprint must remain unchanged until that version's GitHub release is finalized.
46
+
47
+ `airlock prepare` records each target's `content_hash` in `airlock-targets.toml` after preparing versions, changelogs and lockfiles. Fingerprints use `sha256:<digest>`. Hashes cover sorted paths, file contents and executable permissions; source fingerprints also cover entry kinds and symlink destinations. Timestamps, ordering and compression are ignored. TOML comments and layout are preserved.
48
+
49
+ Package archives must exclude `airlock-targets.toml` to avoid self-referential fingerprints.
50
+
51
+ | Target | Fingerprinted contents |
52
+ | --- | --- |
53
+ | npm | The normal `pnpm pack` archive, including bundled code and package metadata. |
54
+ | Python | Both the wheel and source distribution produced by `uv build`. |
55
+ | Action | Git-visible files in the Action directory and the directory containing its version file, plus declared `runtime_files`. `path` selects the Action directory when it differs from the version directory. |
56
+ | Container | The selected build-context and Dockerfile paths, plus the Dockerfile and Git-visible build-context files, filtered with Docker's SDK matcher. `.containerignore` takes precedence over `.dockerignore`. |
57
+
58
+ Git-visible files include tracked files and new files permitted by Git's ignore rules. The release manifest is excluded from source fingerprints because it stores the fingerprints themselves. Action and container hashes are a best-effort check of local inputs: they do not follow dependencies outside these directories, resolve remote base images or execute container builds. Git submodule contents are outside the check's scope.
59
+
60
+ Python packages pin `uv_build` exactly in `build-system.requires`. uv uses that backend version even when its bundled backend differs, keeping wheel metadata and fingerprints consistent across preparation, CI and publication.
61
+
62
+ Bump detection is a best-effort reminder using prepared fingerprints; release readiness owns artifact validation. Cerberus compares each prepared fingerprint with the latest finalized stable GitHub release's tagged manifest, without rebuilding packages. The current PR's fingerprint cannot replace that baseline. One GitHub release-history lookup serves all targets; comparisons read Git history without downloading published artifacts. The first release with a fingerprint establishes the baseline; content comparisons begin after that release.
63
+
64
+ Preparation also records each package's `requires` list of matching workspace release tags from its built metadata. Cerberus's `release_readiness` bite requires and recalculates every target's fingerprint and package dependency list. Run `uv run cerberus --check release_readiness` for a focused readiness check. Publication verifies the same npm/Python archives it uploads and verifies Action/container inputs before publication. Build and fingerprint failures are errors.
65
+
66
+ Release checks require the GitHub CLI to read published releases. Authenticate locally with `gh auth login`; in CI, set `GH_TOKEN` to the workflow token with `contents: read` permission.
67
+
68
+ Cerberus reports release readiness and enforces the version-bump policy. It reuses Airlock's fingerprints, manifest parsing, GitHub release history and version parsing through its `zyplux-airlock` Python dependency.
69
+
70
+ Private GitHub registry reads use `NODE_AUTH_TOKEN` for npm, or `GH_TOKEN`/`GITHUB_TOKEN`, and otherwise reuse `gh auth token --hostname github.com`. Local users can sign in with `gh auth login --scopes read:packages`. Tokens need access to the package; Actions use the job token and package access granted to the repository. Explicit credentials take precedence, and registry failures remain errors.
71
+
72
+ ## Automatic publication
73
+
74
+ The [release workflow](../../.github/workflows/release.yml) has a planning job and a publication matrix. `airlock plan` validates prepared releases and emits `matrix` and `pending` action outputs. Each row contains the exact release tags, a stable concurrency ID, an environment and a runner. `airlock publish --batch JSON` validates that row against the checked-out configuration and publishes its selected targets.
75
+
76
+ Every target has its own publication job, concurrency ID and GitHub release. Environments default to `npm`, `pypi`, `ghcr` and `actions`; set `environment` on a target to override it. The shared setup supports Linux runners.
77
+
78
+ Planning selects pending targets without building or packing packages. Each npm job packs its target once, inspects the archive's manifest after lifecycle scripts and `beforePacking` hooks, then publishes that same archive. Declare workspace dependencies retained in `dependencies` or `optionalDependencies` as npm release targets; private implementation packages removed during packing need no release target. A matching retained dependency on a declared workspace package is treated as a release edge, regardless of local linking settings. Each job waits for those workspace versions and tarballs before uploading. Development-only dependencies do not delay publication. Missing dependencies are checked every ten seconds for up to sixty retries; failed dependencies block their consumers while unrelated jobs continue. Packing failures affect only their target's job.
79
+
80
+ Each successful job finalizes its target's release; a failing job leaves the other jobs running. Npm publication requires each target to match a non-private workspace package at its configured version. Native pnpm publishes the inspected archive. Airlock verifies the exact version in registry metadata and uses GET to check tarball accessibility before creating its GitHub release. Airlock owns availability waiting; pnpm's HEAD-based wait is disabled because GitHub Packages rejects that method. Repairing a missing GitHub release for an existing npm or PyPI version rebuilds and validates its current fingerprint without uploading again. PyPI completion requires a wheel and source archive; uv checks the index to skip existing files when retrying incomplete uploads. Action publication creates a fixed versioned release and advances its compatible major alias to the newest published stable version. Package `prepack` hooks own npm builds and archive validation; publish-only lifecycle hooks are rejected before packing.
81
+
82
+ Python publication reads `Requires-Dist` from the built wheel and waits for matching versions of other configured PyPI targets before uploading those same archives. Matching dependencies include optional and conditional installations. The dependency's wheel and source archive must both be visible on PyPI; missing artifacts and transient registry failures use the same bounded wait as npm. A failed dependency blocks its consumers while unrelated jobs continue.
83
+
84
+ Each npm and Python job rejects cycles among its reachable unpublished workspace dependencies before waiting. Cycle checks use the prepared `requires` lists, validated against the actual archives during readiness checks and publication. Packing hooks, bundled dependencies and dynamic Python metadata are resolved during preparation; publication does not repack peer packages. Existing publications break dependency cycles; unrelated jobs remain independent.
85
+
86
+ To recover from a failed run, fix the cause and rerun the failed jobs in GitHub Actions, or dispatch `release.yml` on `main` with an optional `tag` to select one release.
87
+
88
+ ## Add Airlock to a repository
89
+
90
+ Configure pnpm versioning and declare `airlock-targets.toml`. Cerberus includes Airlock in its Python dependencies, so repositories using Cerberus can run `uv run airlock`. For a standalone CLI, use `uv tool install zyplux-airlock`. Use the [release workflow](../../.github/workflows/release.yml) as the consumer template, replacing its local action references with:
91
+
92
+ ```yaml
93
+ - uses: zyplux/zyplux/apps/action-airlock@action-airlock-v2
94
+ with:
95
+ command: plan
96
+ ```
97
+
98
+ The publishing step uses the same action with `command: publish` and `batch: ${{ toJSON(matrix) }}`. Planning sets up uv and the publisher runtime. Publication also sets up pnpm, installs the repository's locked build dependencies and configures GitHub Packages authentication. It installs Airlock from the action's versioned source and `uv.lock` in a separate environment; the action reference selects the publisher version. Its `token` defaults to the job's GitHub token.
99
+
100
+ The calling workflow declares triggers, permissions, concurrency and environments. A single matrix uses their combined token permissions; separate job definitions can narrow permissions by publication kind. Keep OIDC publishing jobs in this top-level workflow to support PyPI trusted publishing.
101
+
102
+ Airlock supplies each npm scope's `publishConfig` registry to pnpm for preparation lookups. Targets in one scope must share a publication registry. Public npm and PyPI use trusted publishing; GitHub Packages and GHCR use the job token with `packages: write`.
103
+
104
+ ## Add a release target
105
+
106
+ 1. Add a named table with its kind and version source to `airlock-targets.toml`. The table name supplies the `<name>-v` tag prefix; its label defaults to `<scope>/<name>` and can be overridden explicitly.
107
+ 2. Ensure pnpm discovers its version manifest. Python packages need the private tracking manifest described above; actions use a workspace `package.json`, with `private: true` when there is no npm package.
108
+ 3. Complete the registry setup below, then prepare and merge the release through the feature PR procedure.
109
+
110
+ Version files and declared paths must be repository-relative and resolve inside the repository. Set the top-level `scope` before the target tables:
111
+
112
+ ```toml
113
+ scope = "@zyplux"
114
+
115
+ [airlock]
116
+ kind = "pypi"
117
+ label = "zyplux-airlock"
118
+ version = { file = "apps/airlock/pyproject.toml", regex = '^version = "([^"]+)"' }
119
+ ```
120
+
121
+ Actions also declare their GitHub `repository`, which must match the checkout because publication writes its release tags through `origin`. Set `path = "apps/action-airlock"` when the Action directory differs from its version-file directory. Declare installation inputs outside those directories with `runtime_files`; Airlock's action includes the root `pyproject.toml` and `uv.lock`. These files must exist inside the repository. Airlock publishes as Python distribution `zyplux-airlock` with `airlock-vX.Y.Z` tags; its action uses fixed `action-airlock-vX.Y.Z` tags and the `action-airlock-vX` alias. The package-manager action uses `setup-pm-vX.Y.Z` and `setup-pm-vX`.
122
+
123
+ ## Container releases
124
+
125
+ Declare a GHCR target with a version source, build paths and image label:
126
+
127
+ ```toml
128
+ [core]
129
+ kind = "ghcr"
130
+ label = "ghcr.io/zyplux/zyploop"
131
+ version = { file = "apps/zyp-loop/package.json", json = "version" }
132
+ dockerfile = "apps/zyp-loop/Dockerfile"
133
+ build_context = "."
134
+ ```
135
+
136
+ Both build paths are relative to the repository root and must stay inside it. The build context defaults to the version file's directory. When `dockerfile` is omitted, Podman discovers `Containerfile` or `Dockerfile` in the build context.
137
+
138
+ Airlock authenticates GHCR, builds the versioned image with `org.opencontainers.image.version` and `org.opencontainers.image.revision` labels, and pushes it with Podman's `--digestfile`. It validates the returned SHA-256 digest and verifies registry availability before generating `release.json`:
139
+
140
+ ```json
141
+ {
142
+ "version": "1.2.3",
143
+ "image": "ghcr.io/zyplux/zyploop@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
144
+ }
145
+ ```
146
+
147
+ Airlock attaches this manifest to a draft GitHub release, then finalizes the release with its prepared changelog. Retrying an unfinished release rebuilds and records the newly pushed digest. Finalized releases retain their manifest and image; missing artifacts fail validation. Dispatch `release.yml` with the target tag to retry a container release. Deployment consumes the manifest separately.
148
+
149
+ ## Registry setup
150
+
151
+ ### npm
152
+
153
+ Follow the [manual first-publication guide](../../docs/guide/publish.md#first-npm-publication) to publish a new public npm package and configure trusted publishing for subsequent releases.
154
+
155
+ ### PyPI
156
+
157
+ Add a pending publisher before first publication: distribution name, owner and repository, workflow filename `release.yml` and environment `pypi` or the target's configured override. Preparing and merging the release publishes it with OIDC. PyPI requires no bootstrap token.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "zyplux-airlock"
3
+ version = "2.0.0"
4
+ description = "Prepare and publish reviewed npm, Python, container and GitHub Action releases"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "docker>=7.2.0",
9
+ "httpx>=0.28.1",
10
+ "markdown-it-py>=4.0.0",
11
+ "packaging>=26.0",
12
+ "pydantic>=2.12.0",
13
+ "semantic-version>=2.10.0",
14
+ "tomlkit>=0.15.2",
15
+ "typer>=0.27.3",
16
+ ]
17
+
18
+ [project.scripts]
19
+ airlock = "airlock.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build==0.13.5"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.build-backend]
26
+ module-name = "airlock"
27
+ module-root = "src"
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "zyplux-airlock"
3
+ version = "2.0.0"
4
+ description = "Prepare and publish reviewed npm, Python, container and GitHub Action releases"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "docker>=7.2.0",
9
+ "httpx>=0.28.1",
10
+ "markdown-it-py>=4.0.0",
11
+ "packaging>=26.0",
12
+ "pydantic>=2.12.0",
13
+ "semantic-version>=2.10.0",
14
+ "tomlkit>=0.15.2",
15
+ "typer>=0.27.3",
16
+ ]
17
+
18
+ [project.scripts]
19
+ airlock = "airlock.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build==0.13.5"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.build-backend]
26
+ module-name = "airlock"
27
+ module-root = "src"
File without changes
@@ -0,0 +1,3 @@
1
+ from airlock.cli import main
2
+
3
+ main()
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from airlock import github, proc
8
+ from airlock.contracts import ActionSpec
9
+ from airlock.targets import parse_stable_version
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from airlock.contracts import ReleaseTarget, TargetSpec
15
+
16
+ ACTION_POLL_ATTEMPTS = 12
17
+
18
+
19
+ def parse_remote_tag(listing: str, ref: str) -> str:
20
+ lines = listing.splitlines()
21
+ for candidate in (f"{ref}^{{}}", ref):
22
+ for line in lines:
23
+ if line.endswith(f"\t{candidate}"):
24
+ return line.split("\t", 1)[0]
25
+ return ""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ActionAlias:
30
+ commit: str
31
+ previous_sha: str
32
+ ref: str
33
+ release_ref: str
34
+
35
+
36
+ def validate_action_repository(spec: TargetSpec, directory: Path | None = None) -> ActionSpec:
37
+ if not isinstance(spec, ActionSpec):
38
+ msg = "action publication requires an action target"
39
+ raise TypeError(msg)
40
+ repository = proc.run("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner", cwd=directory)
41
+ if spec.repository.casefold() != repository.casefold():
42
+ msg = f"action repository {spec.repository} must match checkout {repository}"
43
+ raise ValueError(msg)
44
+ return spec
45
+
46
+
47
+ def resolve_action_alias(target: ReleaseTarget) -> ActionAlias | None:
48
+ spec = validate_action_repository(target.spec)
49
+ try:
50
+ version = parse_stable_version(target.version)
51
+ except ValueError as exc:
52
+ msg = f"action version '{target.version}' must be stable semver"
53
+ raise ValueError(msg) from exc
54
+ ref = f"refs/tags/{spec.tag_prefix}{version.major}"
55
+ previous_sha = parse_remote_tag(proc.run("git", "ls-remote", "origin", ref), ref)
56
+ releases = github.fetch_releases(spec.tag_prefix, spec.repository)
57
+ if all(tag != target.tag for tag, _ in releases):
58
+ return None
59
+ latest = next(tag for tag, candidate in releases if candidate.major == version.major)
60
+ release_ref = f"refs/tags/{latest}"
61
+ commit = parse_remote_tag(proc.run("git", "ls-remote", "origin", f"{release_ref}*"), release_ref)
62
+ if not commit:
63
+ msg = f"published action tag {latest} is missing"
64
+ raise ValueError(msg)
65
+ return ActionAlias(commit, previous_sha, ref, release_ref)
66
+
67
+
68
+ def is_action_published(target: ReleaseTarget) -> bool:
69
+ alias = resolve_action_alias(target)
70
+ return alias is not None and alias.previous_sha == alias.commit
71
+
72
+
73
+ def publish_action(target: ReleaseTarget, notes: str) -> None:
74
+ validate_action_repository(target.spec)
75
+ try:
76
+ parse_stable_version(target.version)
77
+ except ValueError as exc:
78
+ msg = f"action version '{target.version}' must be stable semver"
79
+ raise ValueError(msg) from exc
80
+ if github.load_release(target.tag) is None:
81
+ github.create_release(target.tag, notes, proc.run("git", "rev-parse", "HEAD"))
82
+ for attempt in range(ACTION_POLL_ATTEMPTS):
83
+ alias = resolve_action_alias(target)
84
+ if alias is not None:
85
+ break
86
+ if attempt < ACTION_POLL_ATTEMPTS - 1:
87
+ time.sleep(5)
88
+ else:
89
+ msg = f"published GitHub release {target.tag} was not visible after waiting; retry publication"
90
+ raise ValueError(msg)
91
+ if alias.previous_sha == alias.commit:
92
+ return
93
+ proc.run("git", "fetch", "--no-tags", "origin", alias.release_ref)
94
+ commit = proc.run("git", "rev-parse", "FETCH_HEAD^{commit}")
95
+ if commit != alias.commit:
96
+ msg = f"action tag {alias.release_ref} changed during publication"
97
+ raise ValueError(msg)
98
+ proc.run("git", "push", f"--force-with-lease={alias.ref}:{alias.previous_sha}", "origin", f"{commit}:{alias.ref}")
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from airlock.contracts import ReleaseBatch
12
+ from airlock.errors import RELEASE_ERRORS
13
+ from airlock.notes import prepare_release
14
+ from airlock.releases import build_batch, load_pending, publish_batch
15
+
16
+ app = typer.Typer(
17
+ help="Prepare and publish reviewed workspace releases.",
18
+ no_args_is_help=True,
19
+ context_settings={"help_option_names": ["-h", "--help"]},
20
+ )
21
+
22
+
23
+ def print_version(*, is_requested: bool) -> None:
24
+ if is_requested:
25
+ typer.echo(version("zyplux-airlock"))
26
+ raise typer.Exit
27
+
28
+
29
+ @app.callback()
30
+ def configure_cli(
31
+ *,
32
+ _show_version: Annotated[bool, typer.Option("--version", callback=print_version, is_eager=True)] = False,
33
+ ) -> None:
34
+ pass
35
+
36
+
37
+ @app.command(help="Prepare versions, changelogs, lockfiles and content fingerprints for review.")
38
+ def prepare() -> None:
39
+ prepare_release()
40
+
41
+
42
+ @app.command(help="Select pending releases and emit isolated publication batches.")
43
+ def plan(tag: Annotated[str | None, typer.Option()] = None) -> None:
44
+ batches = [build_batch(target) for target in load_pending(tag)]
45
+ matrix = json.dumps({"include": [batch.model_dump() for batch in batches]}, separators=(",", ":"))
46
+ typer.echo(matrix)
47
+ destination = os.getenv("GITHUB_OUTPUT")
48
+ if destination:
49
+ with Path(destination).open("a", encoding="utf-8") as stream:
50
+ stream.write(f"matrix={matrix}\npending={str(bool(batches)).lower()}\n")
51
+
52
+
53
+ @app.command(help="Publish exactly one planned batch and finalize its GitHub release.")
54
+ def publish(batch: Annotated[str, typer.Option()]) -> None:
55
+ publish_batch(ReleaseBatch.model_validate_json(batch))
56
+
57
+
58
+ def main() -> None:
59
+ try:
60
+ app()
61
+ except RELEASE_ERRORS as exc:
62
+ prefix = "ENOENT: " if isinstance(exc, FileNotFoundError) else ""
63
+ typer.echo(f"error: {prefix}{exc}", err=True)
64
+ raise SystemExit(1) from exc
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+ from typing import TYPE_CHECKING
8
+
9
+ from airlock import github, proc
10
+ from airlock.contracts import DIGEST, ContainerManifest
11
+ from airlock.registries import is_container_available
12
+
13
+ if TYPE_CHECKING:
14
+ from airlock.contracts import ReleaseTarget
15
+
16
+ CONTAINER_MANIFEST = "release.json"
17
+
18
+
19
+ def is_container_published(target: ReleaseTarget) -> bool:
20
+ release = github.load_release(target.tag, assets=True)
21
+ if not github.has_published_assets(release, target.tag, [Path(CONTAINER_MANIFEST)]):
22
+ return False
23
+ manifest = ContainerManifest.model_validate_json(
24
+ proc.run("gh", "release", "download", target.tag, "--pattern", CONTAINER_MANIFEST, "--output", "-")
25
+ )
26
+ prefix = f"{target.spec.label}@"
27
+ if manifest.version != target.version or not manifest.image.startswith(prefix):
28
+ msg = f"published release {target.tag} has an invalid container manifest"
29
+ raise ValueError(msg)
30
+ digest = DIGEST.validate_python(manifest.image.removeprefix(prefix))
31
+ if not is_container_available(target.spec.label, digest):
32
+ msg = f"published release {target.tag} is missing image {manifest.image}"
33
+ raise ValueError(msg)
34
+ return True
35
+
36
+
37
+ def publish_container(target: ReleaseTarget, notes: str) -> None:
38
+ if is_container_published(target):
39
+ return
40
+ token = os.getenv("GH_TOKEN")
41
+ actor = os.getenv("GITHUB_ACTOR")
42
+ if not token:
43
+ msg = "GH_TOKEN is required to push to GHCR"
44
+ raise ValueError(msg)
45
+ if not actor:
46
+ msg = "GITHUB_ACTOR is required to push to GHCR"
47
+ raise ValueError(msg)
48
+ revision = proc.run("git", "rev-parse", "HEAD")
49
+ versioned = f"{target.spec.label}:{target.version}"
50
+ with TemporaryDirectory(prefix="airlock-container-") as directory:
51
+ digest_file = Path(directory) / "digest"
52
+ manifest = Path(directory) / CONTAINER_MANIFEST
53
+ proc.run("podman", "login", "ghcr.io", "-u", actor, "--password-stdin", stdin=token)
54
+ try:
55
+ dockerfile = [] if target.dockerfile is None else ["--file", str(target.dockerfile)]
56
+ proc.run(
57
+ "podman",
58
+ "build",
59
+ *dockerfile,
60
+ "--label",
61
+ f"org.opencontainers.image.version={target.version}",
62
+ "--label",
63
+ f"org.opencontainers.image.revision={revision}",
64
+ "-t",
65
+ versioned,
66
+ str(target.build_context),
67
+ )
68
+ proc.run("podman", "push", "--digestfile", str(digest_file), versioned)
69
+ digest = DIGEST.validate_python(digest_file.read_text(encoding="utf-8").strip())
70
+ if not is_container_available(target.spec.label, digest):
71
+ msg = f"container push did not publish image {versioned}"
72
+ raise ValueError(msg)
73
+ manifest.write_text(
74
+ json.dumps({"image": f"{target.spec.label}@{digest}", "version": target.version}, separators=(",", ":"))
75
+ + "\n",
76
+ encoding="utf-8",
77
+ )
78
+ finally:
79
+ proc.run("podman", "logout", "ghcr.io")
80
+ github.publish_assets(target.tag, notes, revision, [manifest])