Skip to content

Commit 36e4daa

Browse files
committed
feat(forge): per-type release-asset resolvers (Phase D fetchers)
The `forge` schema field shipped informational in v0.4.0 (commit `08db77e`). This commit turns it into real URL resolution. Five forges are first-class: - github -> https://api.github.com/.../releases/tags/{tag} - gitlab -> https://gitlab.com/api/v4/projects/{owner}%2F{repo}/releases/{tag} - codeberg -> https://codeberg.org/api/v1/repos/.../releases/tags/{tag} - gitea -> {host}/api/v1/repos/.../releases/tags/{tag} (host required) - bitbucket -> direct /downloads/ URL (no release metadata API) Module: src/get_installer/forge.py - ForgeSpec dataclass (parses the registry.json `forge` block) - parse_forge_spec(block) -> ForgeSpec (validates supported types and owner/repo presence) - release_tag(spec, version) (template substitution) - Per-forge URL builders: github_release_url + github_release_api_url + gitlab/codeberg/gitea/bitbucket variants - resolve_release_metadata_url(spec, version) — picks the right API URL per forge - resolve_asset_url(spec, version, asset) — direct download URL where applicable; raises on gitlab/codeberg/gitea (those return asset URLs via the metadata JSON) Bundle script: added `forge` to MODULE_ORDER between env_file and installer so the bundled installer.py ships the resolvers too. 18 new tests (136 total): parse round-trips, missing owner/repo rejected, unsupported forge type rejected, every URL builder's output verified against a fixed template, dispatcher delegates correctly, bitbucket-has-no-metadata-API error path. Next: a hook in installer.py that, given a registry version with a `forge` block and no direct URL, calls these resolvers to fetch the right asset. That's v0.5 work — the resolvers alone don't need the installer integration.
1 parent c3d45cc commit 36e4daa

5 files changed

Lines changed: 560 additions & 15 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# ADR-0001 — Sigstore key management
2+
3+
**Status**: Accepted (2026-05-16)
4+
**Phase**: SPEC §4 Phase F — signed releases via sigstore
5+
**Unblocks**: `verify.sign_bundle_with_sigstore` implementation
6+
7+
## Context
8+
9+
Phase F ships a sigstore-signed `installer.py` bundle so an
10+
operator can verify the binary they're about to pipe into `sh`
11+
came from this repo, not from a tampered mirror. The scaffold
12+
shipped in v0.4.0 (commit `433d7e1`) but the actual signing flow
13+
was blocked on three open questions:
14+
15+
1. **Which signing identity?** sigstore's identity-based signing
16+
ties a signature to an OIDC subject. Options: maintainer's
17+
personal GitHub identity, a service account, the repo's GitHub
18+
Actions workflow identity, or a hybrid.
19+
2. **Where does the public verification key live?** Sigstore uses
20+
Rekor for public transparency log entries; verification doesn't
21+
need a pre-shared public key, but operators need to know what
22+
identity to trust.
23+
3. **Rotation cadence?** If we use a long-lived signing identity,
24+
when do we rotate it? If we use a short-lived workflow
25+
identity, what happens to old signatures when the workflow
26+
moves?
27+
28+
## Decision
29+
30+
### 1. Identity: GitHub Actions workflow OIDC subject
31+
32+
Sign from the release workflow only, using the workflow's OIDC
33+
identity:
34+
35+
```
36+
repo:simtabi/get-installer:ref:refs/tags/v*
37+
```
38+
39+
This is sigstore's "keyless" flow: no long-lived signing key
40+
exists. The signature is bound to an ephemeral certificate issued
41+
by Fulcio for the duration of the workflow run, recorded in
42+
Rekor. Verification proves "this artifact was signed by a release
43+
workflow on `simtabi/get-installer` targeting a `v*` tag at the
44+
time recorded in Rekor."
45+
46+
### 2. Verification: documented OIDC subject
47+
48+
`SECURITY.md` and the bundle's sidecar `installer.py.sigstore`
49+
both name the expected subject. Operators verify with:
50+
51+
```bash
52+
sigstore verify identity \
53+
--bundle installer.py.sigstore \
54+
--cert-identity 'https://github.com/simtabi/get-installer/.github/workflows/release.yml@refs/tags/v0.4.0' \
55+
--cert-oidc-issuer 'https://token.actions.githubusercontent.com' \
56+
installer.py
57+
```
58+
59+
The `--cert-identity` URL includes the exact tag, so an attacker
60+
who managed to sign a malicious `installer.py` from a feature
61+
branch wouldn't pass verification.
62+
63+
### 3. Rotation: not applicable
64+
65+
No long-lived keys = nothing to rotate. The trust anchor is the
66+
GitHub Actions OIDC issuer, which Sigstore re-roots via Fulcio's
67+
public root every ~6 months. Tooling updates handle that
68+
transparently.
69+
70+
When a release workflow is renamed or moved, old signatures
71+
remain verifiable against the historical identity (Rekor entries
72+
are immutable). The verification command in the consumer docs
73+
must reference the SAME path the signature was minted under.
74+
75+
## Consequences
76+
77+
### What this enables
78+
79+
- `verify.sign_bundle_with_sigstore(path, dry_run=False)` can be
80+
implemented. The implementation calls
81+
`sigstore-python`'s `sign` command via subprocess (the python
82+
API is also fine; we go subprocess to match the rest of our
83+
signing flow which is shell-readable).
84+
- Release workflow gains a `sign` step between the bundle build
85+
and the GitHub release attachment. The `.sigstore` file is
86+
uploaded alongside `installer.py`.
87+
- `docs/security.md` gets a "Verifying the bundle" subsection.
88+
89+
### What this costs
90+
91+
- A new optional dep (`sigstore>=3.0`) — already scaffolded via
92+
the `[sigstore]` extras.
93+
- Release workflow gets one more required permission:
94+
`id-token: write` (already there for PyPI trusted publishing,
95+
no new grant needed).
96+
- Renaming `release.yml` after a release retroactively breaks
97+
the verification command for prior signatures. Mitigation:
98+
document this in `SECURITY.md` + keep the file name forever.
99+
100+
### What this doesn't fix
101+
102+
- A compromised GitHub Actions runner can still sign whatever it
103+
wants while the workflow runs. Sigstore signing is a transparency
104+
layer, not an attestation that the build was reproducible. For
105+
reproducibility, see the `bundle.py --check` reproducibility
106+
test (already gated in CI).
107+
108+
## Implementation
109+
110+
```python
111+
# Inside verify.sign_bundle_with_sigstore:
112+
import subprocess
113+
result = subprocess.run(
114+
["sigstore", "sign", "--bundle", f"{bundle_path}.sigstore", str(bundle_path)],
115+
capture_output=True, text=True, check=False, timeout=120,
116+
)
117+
if result.returncode != 0:
118+
raise SecurityError(f"sigstore sign failed: {result.stderr.strip()}")
119+
return bundle_path.with_suffix(bundle_path.suffix + ".sigstore")
120+
```
121+
122+
The 120-second timeout matches sigstore's typical Fulcio + Rekor
123+
round-trip latency with headroom.
124+
125+
## Alternatives considered
126+
127+
### Long-lived GPG key (rejected)
128+
129+
Owner: maintainer. Rotation: annual. Public key: published in
130+
SECURITY.md.
131+
132+
**Why rejected:** key rotation is operationally painful; key
133+
revocation has no good story when the maintainer changes; GPG
134+
verification has worse UX than `sigstore verify`.
135+
136+
### Cosign with a static keypair (rejected)
137+
138+
Same pros/cons as GPG with slightly better tooling.
139+
140+
**Why rejected:** still requires a long-lived secret on disk
141+
somewhere. Sigstore keyless removes that whole class of
142+
operational risk.
143+
144+
### Per-maintainer identity (rejected)
145+
146+
Each maintainer's personal GitHub OIDC signs releases.
147+
148+
**Why rejected:** maintainer comings + goings are a real concern
149+
over the project lifetime. Workflow identity outlives any single
150+
maintainer.
151+
152+
## Next steps
153+
154+
1. Land the `sign_bundle_with_sigstore` implementation behind the
155+
existing `dry_run=False` path.
156+
2. Wire the sign step into `.github/workflows/release.yml` after
157+
the bundle build, before the GitHub release attachment.
158+
3. Update `SECURITY.md` with the verification command + cert
159+
identity URL.
160+
4. Add a `tests/test_verify.py::test_sigstore_smoke` test that
161+
asserts the sign step works against a real Fulcio endpoint
162+
(skipped unless `INTEGRATION_SIGSTORE=1` in env, since it
163+
needs network + OIDC). Document the skip rationale.
164+
165+
Tracked in the `[sigstore]` extras + the
166+
`verify.sign_bundle_with_sigstore` symbol shipped in v0.4.0.

scripts/bundle.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"verify",
4242
"python_setup",
4343
"env_file",
44+
"forge",
4445
"installer",
4546
"__main__",
4647
)

src/get_installer/forge.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Per-forge release-asset resolvers (SPEC Phase D — Round 3 #19).
2+
3+
The registry.json schema gained a per-version ``forge`` block in
4+
v0.4.0 (commit ``08db77e``). This module turns that informational
5+
block into actual URL resolution: given the forge type + owner +
6+
repo + version, return the URL where the release asset lives.
7+
8+
Five forges are first-class:
9+
10+
- ``github`` — ``https://api.github.com/repos/{owner}/{repo}/releases/...``
11+
- ``gitlab`` — ``https://gitlab.com/api/v4/projects/{owner}%2F{repo}/releases/...``
12+
- ``codeberg`` — Gitea-flavoured at ``https://codeberg.org/api/v1``
13+
- ``gitea`` — generic Gitea host (the user provides ``host`` in extras)
14+
- ``bitbucket`` — ``https://api.bitbucket.org/2.0/repositories/{workspace}/{repo}/downloads``
15+
16+
Each resolver returns a URL string that downstream callers feed
17+
through :func:`get_installer.verify.fetch_https` so the existing
18+
HTTPS-only / allowlist / sha256 enforcement still applies.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from dataclasses import dataclass
24+
from typing import Any
25+
26+
27+
class ForgeError(Exception):
28+
"""Raised when a forge metadata block is malformed or unresolvable."""
29+
30+
31+
@dataclass(frozen=True)
32+
class ForgeSpec:
33+
"""Parsed forge metadata. Built from the registry.json ``forge`` block.
34+
35+
@field type one of github|gitlab|codeberg|gitea|bitbucket
36+
@field owner org / user / workspace name
37+
@field repo repository name
38+
@field release_tag_template how to compute the tag from a version
39+
(default ``"v{version}"``)
40+
@field asset_pattern optional glob matching the release asset filename
41+
@field host custom hostname (gitea only; defaults per forge)
42+
"""
43+
44+
type: str
45+
owner: str
46+
repo: str
47+
release_tag_template: str = "v{version}"
48+
asset_pattern: str | None = None
49+
host: str | None = None
50+
51+
52+
SUPPORTED_FORGES: tuple[str, ...] = ("github", "gitlab", "codeberg", "gitea", "bitbucket")
53+
54+
55+
def parse_forge_spec(block: dict[str, Any]) -> ForgeSpec:
56+
"""Build a ForgeSpec from the registry.json ``forge`` dict."""
57+
forge_type = str(block.get("type", ""))
58+
if forge_type not in SUPPORTED_FORGES:
59+
raise ForgeError(
60+
f"unsupported forge type {forge_type!r}; "
61+
f"expected one of {SUPPORTED_FORGES}"
62+
)
63+
owner = str(block.get("owner", ""))
64+
repo = str(block.get("repo", ""))
65+
if not owner or not repo:
66+
raise ForgeError(
67+
f"forge {forge_type}: missing owner/repo "
68+
f"(got owner={owner!r}, repo={repo!r})"
69+
)
70+
return ForgeSpec(
71+
type=forge_type,
72+
owner=owner,
73+
repo=repo,
74+
release_tag_template=str(block.get("release_tag_template", "v{version}")),
75+
asset_pattern=block.get("asset_pattern"),
76+
host=block.get("host"),
77+
)
78+
79+
80+
def release_tag(spec: ForgeSpec, version: str) -> str:
81+
"""Compute the release tag for a given version + template."""
82+
return spec.release_tag_template.replace("{version}", version)
83+
84+
85+
# --- per-forge resolvers ---------------------------------------------------
86+
87+
88+
def github_release_url(spec: ForgeSpec, version: str, asset: str) -> str:
89+
"""Direct asset URL for a GitHub release."""
90+
tag = release_tag(spec, version)
91+
return (
92+
f"https://github.com/{spec.owner}/{spec.repo}"
93+
f"/releases/download/{tag}/{asset}"
94+
)
95+
96+
97+
def github_release_api_url(spec: ForgeSpec, version: str) -> str:
98+
"""API URL for a GitHub release's metadata (asset list, SHA, etc.)."""
99+
tag = release_tag(spec, version)
100+
return (
101+
f"https://api.github.com/repos/{spec.owner}/{spec.repo}"
102+
f"/releases/tags/{tag}"
103+
)
104+
105+
106+
def gitlab_release_api_url(spec: ForgeSpec, version: str) -> str:
107+
"""GitLab Releases API URL. ``owner/repo`` is URL-encoded."""
108+
tag = release_tag(spec, version)
109+
project = f"{spec.owner}%2F{spec.repo}"
110+
return f"https://gitlab.com/api/v4/projects/{project}/releases/{tag}"
111+
112+
113+
def codeberg_release_api_url(spec: ForgeSpec, version: str) -> str:
114+
"""Codeberg uses Gitea's API at codeberg.org."""
115+
tag = release_tag(spec, version)
116+
return (
117+
f"https://codeberg.org/api/v1/repos/{spec.owner}/{spec.repo}"
118+
f"/releases/tags/{tag}"
119+
)
120+
121+
122+
def gitea_release_api_url(spec: ForgeSpec, version: str) -> str:
123+
"""Generic Gitea host. ``host`` extras key required."""
124+
if not spec.host:
125+
raise ForgeError(
126+
"gitea forge requires a 'host' field "
127+
"(e.g., 'host: gitea.my-org.com')"
128+
)
129+
tag = release_tag(spec, version)
130+
return (
131+
f"https://{spec.host}/api/v1/repos/{spec.owner}/{spec.repo}"
132+
f"/releases/tags/{tag}"
133+
)
134+
135+
136+
def bitbucket_download_url(spec: ForgeSpec, version: str, asset: str) -> str:
137+
"""Bitbucket downloads area — assets live under /downloads/, not releases."""
138+
return (
139+
f"https://bitbucket.org/{spec.owner}/{spec.repo}"
140+
f"/downloads/{asset}"
141+
)
142+
143+
144+
# --- unified entry point ---------------------------------------------------
145+
146+
147+
def resolve_release_metadata_url(spec: ForgeSpec, version: str) -> str:
148+
"""Return the URL that lists release metadata (assets + checksums).
149+
150+
The caller fetches this JSON (via verify.fetch_https) and finds
151+
the asset matching ``spec.asset_pattern`` if set, else the sdist.
152+
153+
@raises ForgeError on bitbucket (no metadata API; assets are
154+
downloaded by direct URL via :func:`bitbucket_download_url`).
155+
"""
156+
if spec.type == "github":
157+
return github_release_api_url(spec, version)
158+
if spec.type == "gitlab":
159+
return gitlab_release_api_url(spec, version)
160+
if spec.type == "codeberg":
161+
return codeberg_release_api_url(spec, version)
162+
if spec.type == "gitea":
163+
return gitea_release_api_url(spec, version)
164+
if spec.type == "bitbucket":
165+
raise ForgeError(
166+
"bitbucket has no release-metadata API; "
167+
"use bitbucket_download_url() with an explicit asset name"
168+
)
169+
raise ForgeError(f"no resolver for forge type {spec.type!r}")
170+
171+
172+
def resolve_asset_url(spec: ForgeSpec, version: str, asset: str) -> str:
173+
"""Return the direct download URL for a named release asset."""
174+
if spec.type == "github":
175+
return github_release_url(spec, version, asset)
176+
if spec.type == "bitbucket":
177+
return bitbucket_download_url(spec, version, asset)
178+
if spec.type in {"gitlab", "codeberg", "gitea"}:
179+
# These forges return asset URLs via the release-metadata API;
180+
# the caller resolves them from there. We surface a clear
181+
# exception so callers don't accidentally guess.
182+
raise ForgeError(
183+
f"{spec.type}: use resolve_release_metadata_url() to fetch the "
184+
"release JSON and read the asset URL from its 'assets' list"
185+
)
186+
raise ForgeError(f"no resolver for forge type {spec.type!r}")

0 commit comments

Comments
 (0)