Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be51a9c347 | ||
|
|
e36a6573bf |
@@ -8,6 +8,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
publish-package:
|
publish-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -16,17 +18,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Validate protected release tag and package version
|
- name: Validate protected release tag and package version
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
python - <<'PY'
|
python - <<'PY'
|
||||||
import fnmatch
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tomllib
|
import tomllib
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
tag = os.environ["GITEA_REF_NAME"]
|
tag = os.environ["GITEA_REF_NAME"]
|
||||||
project = tomllib.loads(Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8"))["project"]
|
project = tomllib.loads(Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
@@ -34,14 +31,6 @@ jobs:
|
|||||||
raise SystemExit("meta-package version does not match the release tag")
|
raise SystemExit("meta-package version does not match the release tag")
|
||||||
if subprocess.run(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"]).returncode:
|
if subprocess.run(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"]).returncode:
|
||||||
raise SystemExit("release tag is not contained in main")
|
raise SystemExit("release tag is not contained in main")
|
||||||
request = urllib.request.Request(
|
|
||||||
f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/tag_protections",
|
|
||||||
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
|
||||||
protections = json.load(response)
|
|
||||||
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
|
||||||
raise SystemExit("release tag is not protected")
|
|
||||||
PY
|
PY
|
||||||
- name: Build and publish developer package
|
- name: Build and publish developer package
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -18,24 +18,43 @@ python tools/repo/sync-module-package-workflows.py --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
The workflow runs for `v*` tags and may be dispatched manually for an existing
|
The workflow runs for `v*` tags and may be dispatched manually for an existing
|
||||||
tag. Before building, it verifies that:
|
tag. The organization preflight verifies that every package repository protects
|
||||||
|
the `v*` namespace. Before building, the workflow itself verifies that:
|
||||||
|
|
||||||
- the selected tag is covered by repository tag protection;
|
|
||||||
- the tagged commit is contained in `main`;
|
- the tagged commit is contained in `main`;
|
||||||
- the tag, Python project version, and optional WebUI package version agree;
|
- the tag, Python project version, and optional WebUI package version agree;
|
||||||
- package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces.
|
- package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces.
|
||||||
|
|
||||||
|
The workflow binds the repository explicitly from the Gitea Actions context.
|
||||||
|
Do not rely on GitHub-compatible environment variables being injected by the
|
||||||
|
runner image; Gitea runners may expose only the context values. Gitea 1.24 job
|
||||||
|
tokens cannot read repository tag-protection settings, so package jobs must not
|
||||||
|
receive a broad administrator token merely to repeat the organization preflight.
|
||||||
|
Run the following before the first publication and after repository or tag-rule
|
||||||
|
changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/gitea/gitea-configure-package-releases.py
|
||||||
|
```
|
||||||
|
|
||||||
It builds one wheel and, where applicable, one npm tarball. The workflow records
|
It builds one wheel and, where applicable, one npm tarball. The workflow records
|
||||||
the source tag, source commit, filename, size, and SHA-256 in
|
the source tag, source commit, filename, size, and SHA-256 in
|
||||||
`package-artifacts.json` before publishing. Gitea rejects a second upload of the
|
`package-artifacts.json` before publishing. Gitea rejects a second upload of the
|
||||||
same package version, so correction requires a new version rather than artifact
|
same package version, so correction requires a new version rather than artifact
|
||||||
replacement.
|
replacement.
|
||||||
|
|
||||||
|
The npm tarball is always published through an explicit local `./dist/...`
|
||||||
|
path. Without that prefix, npm may interpret a relative tarball name as a Git
|
||||||
|
package shorthand before it ever contacts the configured registry.
|
||||||
|
|
||||||
Published WebUI packages contain registry-compatible dependencies only. The
|
Published WebUI packages contain registry-compatible dependencies only. The
|
||||||
workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag
|
workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag
|
||||||
into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git
|
into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git
|
||||||
dependencies. Repository development metadata may therefore keep local or Git
|
dependencies. Repository development metadata may therefore keep local or Git
|
||||||
references without leaking them into the published package contract.
|
references without leaking them into the published package contract.
|
||||||
|
Historical `add-ideas` and current `GovOPlaN` organization URLs are accepted
|
||||||
|
for immutable tagged releases; both normalize to the same exact registry
|
||||||
|
dependency and no branch or unversioned Git reference is accepted.
|
||||||
|
|
||||||
## One-time Gitea setup
|
## One-time Gitea setup
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Production Target And Independent Evidence Handoff
|
||||||
|
|
||||||
|
This runbook identifies the external inputs needed to finish
|
||||||
|
[GovOPlaN #27](https://git.add-ideas.de/GovOPlaN/govoplan/issues/27) and
|
||||||
|
[GovOPlaN #37](https://git.add-ideas.de/GovOPlaN/govoplan/issues/37). The
|
||||||
|
repository can render, inspect and sign evidence for a target, but it cannot
|
||||||
|
manufacture an independent failure domain or an independent approval authority.
|
||||||
|
|
||||||
|
## GovOPlaN #27: real two-node target
|
||||||
|
|
||||||
|
The bounded acceptance target is two independently schedulable worker nodes.
|
||||||
|
The API and WebUI must each have ready replicas on both nodes, all Deployments
|
||||||
|
must be available, the active module composition and software versions must be
|
||||||
|
consistent, every configured queue must have a worker, and the database
|
||||||
|
connection budget must pass. The validation then deletes one ready API pod and
|
||||||
|
requires replacement without an observed readiness outage.
|
||||||
|
|
||||||
|
Two virtual machines on different physical hosts or availability zones meet the
|
||||||
|
failure-domain intent. Two containers, VMs or Kubernetes nodes on one physical
|
||||||
|
host are useful development targets but do not close #27. A two-worker cluster
|
||||||
|
also does not prove control-plane high availability. For a self-managed
|
||||||
|
production cluster, use three control-plane nodes plus at least two workers; a
|
||||||
|
managed control plane plus two workers is the shorter path.
|
||||||
|
|
||||||
|
### What the target owner must provide
|
||||||
|
|
||||||
|
Provide these through a secure handoff, not an issue, chat message or Git:
|
||||||
|
|
||||||
|
1. A kubeconfig path with access to the target, for example
|
||||||
|
`~/.config/govoplan/targets/<target>.kubeconfig`, mode `0600`.
|
||||||
|
2. A stable installation ID, public HTTPS hostname, namespace, ingress class and
|
||||||
|
TLS-secret or certificate-manager arrangement.
|
||||||
|
3. Two independently schedulable workers and permission to place API and WebUI
|
||||||
|
replicas on both.
|
||||||
|
4. External, logically shared PostgreSQL, Redis and S3 endpoints with trusted
|
||||||
|
CA material and network reachability from every worker. Do not co-locate the
|
||||||
|
only copies of these services on the two workers used for the failure drill.
|
||||||
|
5. The six runtime secret values required by the generated manifest:
|
||||||
|
`MASTER_KEY_B64`, `DATABASE_URL`, `GOVOPLAN_DATABASE_URL_PGTOOLS`,
|
||||||
|
`REDIS_URL`, `FILE_STORAGE_S3_ACCESS_KEY_ID` and
|
||||||
|
`FILE_STORAGE_S3_SECRET_ACCESS_KEY`.
|
||||||
|
6. A short-lived GovOPlaN API key limited to `ops:operations:read`, supplied in
|
||||||
|
`GOVOPLAN_OPS_API_KEY` only for evidence collection.
|
||||||
|
7. An approved drill window and permission to delete one API pod.
|
||||||
|
|
||||||
|
If no Kubernetes target exists, provide hostnames/IP addresses for the machines,
|
||||||
|
an SSH user and key path, the internal/external DNS plan, and the permitted
|
||||||
|
firewall ports. Those inputs are sufficient to provision a k3s target. They are
|
||||||
|
not sufficient to claim control-plane HA unless three control-plane failure
|
||||||
|
domains are present.
|
||||||
|
|
||||||
|
### Separate deployment and evidence authorities
|
||||||
|
|
||||||
|
The deployment identity may create and update the namespace, Secret,
|
||||||
|
ConfigMap, Deployments, Services, Jobs, PodDisruptionBudgets and Ingress. The
|
||||||
|
evidence collector only needs:
|
||||||
|
|
||||||
|
- cluster scope: `get` and `list` for `nodes`;
|
||||||
|
- target namespace: `get` and `list` for `pods` and `deployments`;
|
||||||
|
- target namespace during the approved drill: `delete` for `pods`.
|
||||||
|
|
||||||
|
Use separate kubeconfig contexts or service accounts when the same person does
|
||||||
|
not hold both roles.
|
||||||
|
|
||||||
|
### Render, apply and verify
|
||||||
|
|
||||||
|
Use the signed, digest-pinned installation bundle selected for the target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG="$HOME/.config/govoplan/targets/<target>.kubeconfig"
|
||||||
|
|
||||||
|
python tools/deployment/govoplan-deploy.py render-kubernetes \
|
||||||
|
--directory /srv/govoplan/<installation-id> \
|
||||||
|
--namespace govoplan \
|
||||||
|
--secret-name govoplan-runtime \
|
||||||
|
--tls-secret-name govoplan-tls \
|
||||||
|
--ingress-class-name nginx \
|
||||||
|
--output /srv/govoplan/<installation-id>/kubernetes.json
|
||||||
|
|
||||||
|
kubectl apply -f /srv/govoplan/<installation-id>/kubernetes.json
|
||||||
|
kubectl -n govoplan wait --for=condition=available deployment --all --timeout=10m
|
||||||
|
|
||||||
|
export GOVOPLAN_OPS_API_KEY="$(cat /run/secrets/govoplan-ops-evidence-key)"
|
||||||
|
python tools/deployment/govoplan-deploy.py verify-kubernetes \
|
||||||
|
--directory /srv/govoplan/<installation-id> \
|
||||||
|
--namespace govoplan \
|
||||||
|
--exercise-api-pod-loss \
|
||||||
|
--output /srv/govoplan/<installation-id>/evidence/kubernetes-multi-host.json
|
||||||
|
unset GOVOPLAN_OPS_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier emits sanitized JSON and exits nonzero if the topology, runtime,
|
||||||
|
queue, connection-budget or pod-loss checks fail. Preserve the private cluster
|
||||||
|
logs and manifest alongside the sanitized result in the controlled evidence
|
||||||
|
store.
|
||||||
|
|
||||||
|
## GovOPlaN #37: controlled signed target evidence
|
||||||
|
|
||||||
|
Yes, collection, review and signing can run in containers. A container provides
|
||||||
|
repeatability and process isolation; it does not create independent authority.
|
||||||
|
The production approver must control a different private key from the target
|
||||||
|
operator/assessor and must review the evidence before signing the
|
||||||
|
`production_approval` scope.
|
||||||
|
|
||||||
|
Use at least these three key boundaries:
|
||||||
|
|
||||||
|
1. **Installer authority:** signs installed-release-origin receipts only.
|
||||||
|
2. **Target assessment authority:** signs the permitted target, accessibility,
|
||||||
|
privacy, security, operations and recovery scopes.
|
||||||
|
3. **Production approval authority:** independently signs only
|
||||||
|
`production_approval` after reviewing the other evidence.
|
||||||
|
|
||||||
|
Do not reuse release-catalog keys for any of these roles. Keep private Ed25519
|
||||||
|
keys outside Git, Gitea, GovOPlaN application storage and chat. Publish only the
|
||||||
|
public keyrings. The proof issuer already rejects key reuse across release,
|
||||||
|
installer and proof trust domains.
|
||||||
|
|
||||||
|
### Generate independently held keys
|
||||||
|
|
||||||
|
Each authority runs this command in its own `0700` directory. The generator
|
||||||
|
refuses existing output paths and writes both files as `0600`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 "$HOME/.config/govoplan/authority-keys"
|
||||||
|
|
||||||
|
python tools/assessments/generate-authority-keypair.py \
|
||||||
|
--purpose proof \
|
||||||
|
--key-id authority:target-2026 \
|
||||||
|
--scope target_environment \
|
||||||
|
--scope accessibility \
|
||||||
|
--scope privacy \
|
||||||
|
--scope security \
|
||||||
|
--scope operations \
|
||||||
|
--scope recovery \
|
||||||
|
--private-key "$HOME/.config/govoplan/authority-keys/target-2026.pem" \
|
||||||
|
--keyring "$HOME/.config/govoplan/authority-keys/target-2026-public.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
The independent production approver generates another key with only
|
||||||
|
`--scope production_approval`. An installer authority uses `--purpose installer`
|
||||||
|
and no `--scope`. Merge public key entries into the separately controlled
|
||||||
|
keyrings only after the responsible authorities verify fingerprints out of
|
||||||
|
band.
|
||||||
|
|
||||||
|
### Container boundary
|
||||||
|
|
||||||
|
Use two one-shot jobs or containers:
|
||||||
|
|
||||||
|
- **Collector/assessor:** network access, read-only source and trust mounts,
|
||||||
|
read/write private evidence output, and the narrowly scoped kubeconfig. It
|
||||||
|
must not receive the production-approval private key.
|
||||||
|
- **Production approver:** `--network none`, read-only assessment/evidence/trust
|
||||||
|
mounts, a read-only secret mount containing only the approval key, and a
|
||||||
|
separate output mount. It must not receive deployment credentials.
|
||||||
|
|
||||||
|
Build or select the assessment image by digest and record that digest in the
|
||||||
|
evidence log. A representative runtime shape is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network none --read-only --tmpfs /tmp \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--mount type=bind,src="$PWD/evidence",dst=/evidence,readonly \
|
||||||
|
--mount type=bind,src="$PWD/trust",dst=/trust,readonly \
|
||||||
|
--mount type=bind,src="$HOME/.config/govoplan/authority-keys",dst=/run/keys,readonly \
|
||||||
|
--mount type=bind,src="$PWD/approved",dst=/output \
|
||||||
|
<assessment-image>@sha256:<digest> \
|
||||||
|
<assessment command>
|
||||||
|
```
|
||||||
|
|
||||||
|
The current evidence commands and required scopes are documented in
|
||||||
|
[`TARGET_MATURITY_EVIDENCE_RUNBOOK.md`](TARGET_MATURITY_EVIDENCE_RUNBOOK.md).
|
||||||
|
The final proof must cover `target_environment`, `accessibility`, `privacy`,
|
||||||
|
`security`, `operations`, `recovery` and independent `production_approval`, and
|
||||||
|
must bind to the verified installed composition and installer receipt.
|
||||||
|
|
||||||
|
## Completion boundary
|
||||||
|
|
||||||
|
#27 can close after the real target produces a passing pod-loss result. #37 can
|
||||||
|
close after an independently approved, schema-valid proof is generated for that
|
||||||
|
same installed composition and the public authority keyrings, proof and private
|
||||||
|
evidence custody references are recorded. Neither issue should close from a
|
||||||
|
single-host simulation or a self-approved signature.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Scaling And Multi-Host Deployment
|
# Scaling And Multi-Host Deployment
|
||||||
|
|
||||||
|
For the exact external handoff, least-privilege collector permissions and live
|
||||||
|
two-node acceptance procedure, see
|
||||||
|
[`PRODUCTION_TARGET_HANDOFF.md`](PRODUCTION_TARGET_HANDOFF.md).
|
||||||
|
|
||||||
## Implemented Contract
|
## Implemented Contract
|
||||||
|
|
||||||
GovOPlaN now supports a stateless application tier backed by logically shared
|
GovOPlaN now supports a stateless application tier backed by logically shared
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Target Maturity Evidence Runbook
|
# Target Maturity Evidence Runbook
|
||||||
|
|
||||||
|
For authority-key generation, container isolation and the concrete inputs that
|
||||||
|
must be supplied by the target owner and independent production approver, see
|
||||||
|
[`PRODUCTION_TARGET_HANDOFF.md`](PRODUCTION_TARGET_HANDOFF.md).
|
||||||
|
|
||||||
This runbook turns retained target-environment results into a sanitized,
|
This runbook turns retained target-environment results into a sanitized,
|
||||||
signed GovOPlaN capability-fit proof. It does not make a deployment suitable,
|
signed GovOPlaN capability-fit proof. It does not make a deployment suitable,
|
||||||
certified, supported, or production-approved by itself. The proof records what
|
certified, supported, or production-approved by itself. The proof records what
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GENERATOR = META_ROOT / "tools" / "assessments" / "generate-authority-keypair.py"
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentAuthorityKeypairTests(unittest.TestCase):
|
||||||
|
def test_generates_schema_valid_scoped_proof_authority(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_dir = Path(temp_dir)
|
||||||
|
output_dir.chmod(0o700)
|
||||||
|
private_path = output_dir / "target.pem"
|
||||||
|
keyring_path = output_dir / "target.json"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
(
|
||||||
|
sys.executable,
|
||||||
|
str(GENERATOR),
|
||||||
|
"--purpose",
|
||||||
|
"proof",
|
||||||
|
"--key-id",
|
||||||
|
"authority:target-2026",
|
||||||
|
"--scope",
|
||||||
|
"target_environment",
|
||||||
|
"--scope",
|
||||||
|
"operations",
|
||||||
|
"--private-key",
|
||||||
|
str(private_path),
|
||||||
|
"--keyring",
|
||||||
|
str(keyring_path),
|
||||||
|
),
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(private_path.stat().st_mode))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(keyring_path.stat().st_mode))
|
||||||
|
keyring = json.loads(keyring_path.read_text(encoding="utf-8"))
|
||||||
|
schema = json.loads(
|
||||||
|
(
|
||||||
|
META_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "capability-fit-proof-authority-keyring.schema.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
errors = tuple(
|
||||||
|
Draft202012Validator(
|
||||||
|
schema, format_checker=FormatChecker()
|
||||||
|
).iter_errors(keyring)
|
||||||
|
)
|
||||||
|
self.assertEqual((), errors)
|
||||||
|
self.assertEqual(
|
||||||
|
["target_environment", "operations"],
|
||||||
|
keyring["keys"][0]["allowed_scopes"],
|
||||||
|
)
|
||||||
|
private_key = serialization.load_pem_private_key(
|
||||||
|
private_path.read_bytes(), password=None
|
||||||
|
)
|
||||||
|
self.assertIsInstance(private_key, Ed25519PrivateKey)
|
||||||
|
public_key = base64.b64encode(
|
||||||
|
private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
).decode("ascii")
|
||||||
|
self.assertEqual(public_key, keyring["keys"][0]["public_key"])
|
||||||
|
|
||||||
|
def test_installer_authority_uses_fixed_scope_and_refuses_overwrite(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_dir = Path(temp_dir)
|
||||||
|
output_dir.chmod(0o700)
|
||||||
|
private_path = output_dir / "installer.pem"
|
||||||
|
keyring_path = output_dir / "installer.json"
|
||||||
|
command = (
|
||||||
|
sys.executable,
|
||||||
|
str(GENERATOR),
|
||||||
|
"--purpose",
|
||||||
|
"installer",
|
||||||
|
"--key-id",
|
||||||
|
"authority:installer-2026",
|
||||||
|
"--private-key",
|
||||||
|
str(private_path),
|
||||||
|
"--keyring",
|
||||||
|
str(keyring_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = subprocess.run(
|
||||||
|
command, check=False, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
second = subprocess.run(
|
||||||
|
command, check=False, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, first.returncode, first.stderr)
|
||||||
|
self.assertNotEqual(0, second.returncode)
|
||||||
|
keyring = json.loads(keyring_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(
|
||||||
|
["installed_release_origin"],
|
||||||
|
keyring["keys"][0]["allowed_scopes"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -17,15 +17,18 @@ class ModulePackageWorkflowTests(unittest.TestCase):
|
|||||||
META_ROOT / "tools/repo/templates/module-package-release.yml"
|
META_ROOT / "tools/repo/templates/module-package-release.yml"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
self.assertIn("tag_protections", workflow)
|
self.assertIn("GITEA_REPOSITORY: ${{ gitea.repository }}", workflow)
|
||||||
|
self.assertNotIn("tag_protections", workflow)
|
||||||
|
self.assertNotIn("secrets.GITEA_TOKEN", workflow)
|
||||||
self.assertIn("git merge-base --is-ancestor", workflow)
|
self.assertIn("git merge-base --is-ancestor", workflow)
|
||||||
self.assertIn("does not match", workflow)
|
self.assertIn("does not match", workflow)
|
||||||
self.assertIn("package-artifacts.json", workflow)
|
self.assertIn("package-artifacts.json", workflow)
|
||||||
self.assertIn("api/packages/GovOPlaN/pypi", workflow)
|
self.assertIn("api/packages/GovOPlaN/pypi", workflow)
|
||||||
self.assertIn("api/packages/GovOPlaN/npm", workflow)
|
self.assertIn("api/packages/GovOPlaN/npm", workflow)
|
||||||
|
self.assertIn('npm publish "./${webui_packages[0]}"', workflow)
|
||||||
self.assertIn("GOVOPLAN_PACKAGE_TOKEN", workflow)
|
self.assertIn("GOVOPLAN_PACKAGE_TOKEN", workflow)
|
||||||
self.assertIn("must resolve to an exact registry version", workflow)
|
self.assertIn("must resolve to an exact registry version", workflow)
|
||||||
self.assertIn("git\\\\.add-ideas\\\\.de/GovOPlaN", workflow)
|
self.assertIn("git\\\\.add-ideas\\\\.de/(?:GovOPlaN|add-ideas)", workflow)
|
||||||
self.assertIn("release package identity does not match", workflow)
|
self.assertIn("release package identity does not match", workflow)
|
||||||
self.assertNotIn("Generic", workflow)
|
self.assertNotIn("Generic", workflow)
|
||||||
|
|
||||||
@@ -52,6 +55,10 @@ class ModulePackageWorkflowTests(unittest.TestCase):
|
|||||||
"@govoplan/access-webui": (
|
"@govoplan/access-webui": (
|
||||||
"git+ssh://git@git.add-ideas.de/GovOPlaN/"
|
"git+ssh://git@git.add-ideas.de/GovOPlaN/"
|
||||||
"govoplan-access.git#v0.1.11"
|
"govoplan-access.git#v0.1.11"
|
||||||
|
),
|
||||||
|
"@govoplan/admin-webui": (
|
||||||
|
"git+ssh://git@git.add-ideas.de/add-ideas/"
|
||||||
|
"govoplan-admin.git#v0.1.8"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -73,6 +80,9 @@ class ModulePackageWorkflowTests(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"0.1.11", package["dependencies"]["@govoplan/access-webui"]
|
"0.1.11", package["dependencies"]["@govoplan/access-webui"]
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"0.1.8", package["dependencies"]["@govoplan/admin-webui"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_sync_script_only_targets_packageable_govoplan_repositories(self) -> None:
|
def test_sync_script_only_targets_packageable_govoplan_repositories(self) -> None:
|
||||||
namespace: dict[str, object] = {
|
namespace: dict[str, object] = {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate an independently held Ed25519 assessment-authority keypair."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
KEY_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
||||||
|
PROOF_SCOPES = (
|
||||||
|
"target_environment",
|
||||||
|
"external_providers",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"recovery",
|
||||||
|
"production_approval",
|
||||||
|
)
|
||||||
|
PURPOSES = {
|
||||||
|
"proof": (
|
||||||
|
"govoplan.capability-fit-proof-authorities",
|
||||||
|
"./capability-fit-proof-authority-keyring.schema.json",
|
||||||
|
),
|
||||||
|
"installer": (
|
||||||
|
"govoplan.installer-receipt-authorities",
|
||||||
|
"./installer-receipt-authority-keyring.schema.json",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--purpose", choices=tuple(PURPOSES), required=True)
|
||||||
|
parser.add_argument("--key-id", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scope",
|
||||||
|
action="append",
|
||||||
|
choices=PROOF_SCOPES,
|
||||||
|
default=[],
|
||||||
|
help="Authorized proof scope; repeat as needed. Not used for installer keys.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--private-key", type=Path, required=True)
|
||||||
|
parser.add_argument("--keyring", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--valid-days",
|
||||||
|
type=int,
|
||||||
|
default=365,
|
||||||
|
help="Validity from generation time (default: 365 days).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--status",
|
||||||
|
choices=("active", "next"),
|
||||||
|
default="active",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not KEY_ID_PATTERN.fullmatch(args.key_id):
|
||||||
|
parser.error("--key-id must be a valid opaque identifier")
|
||||||
|
if args.valid_days < 1 or args.valid_days > 3660:
|
||||||
|
parser.error("--valid-days must be between 1 and 3660")
|
||||||
|
scopes = _resolve_scopes(parser, purpose=args.purpose, scopes=args.scope)
|
||||||
|
|
||||||
|
private_path = args.private_key.expanduser().resolve()
|
||||||
|
keyring_path = args.keyring.expanduser().resolve()
|
||||||
|
_require_fresh_output(parser, private_path, label="private key")
|
||||||
|
_require_fresh_output(parser, keyring_path, label="keyring")
|
||||||
|
_require_private_directory(parser, private_path.parent)
|
||||||
|
_require_output_directory(parser, keyring_path.parent)
|
||||||
|
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
private_bytes = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
public_bytes = private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
public_base64 = base64.b64encode(public_bytes).decode("ascii")
|
||||||
|
now = datetime.now(UTC).replace(microsecond=0)
|
||||||
|
not_after = now + timedelta(days=args.valid_days)
|
||||||
|
purpose, schema = PURPOSES[args.purpose]
|
||||||
|
keyring = {
|
||||||
|
"$schema": schema,
|
||||||
|
"schema_version": "0.1.0",
|
||||||
|
"purpose": purpose,
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"key_id": args.key_id,
|
||||||
|
"status": args.status,
|
||||||
|
"public_key": public_base64,
|
||||||
|
"allowed_scopes": scopes,
|
||||||
|
"not_before": _rfc3339(now),
|
||||||
|
"not_after": _rfc3339(not_after),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_write_new_private_file(private_path, private_bytes)
|
||||||
|
try:
|
||||||
|
_write_new_private_file(
|
||||||
|
keyring_path,
|
||||||
|
(json.dumps(keyring, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
private_path.unlink(missing_ok=True)
|
||||||
|
keyring_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
print(f"private_key={private_path}")
|
||||||
|
print(f"keyring={keyring_path}")
|
||||||
|
print(f"key_id={args.key_id}")
|
||||||
|
print(f"allowed_scopes={','.join(scopes)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scopes(
|
||||||
|
parser: argparse.ArgumentParser, *, purpose: str, scopes: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
if purpose == "installer":
|
||||||
|
if scopes:
|
||||||
|
parser.error("installer authorities do not accept --scope")
|
||||||
|
return ["installed_release_origin"]
|
||||||
|
unique = list(dict.fromkeys(scopes))
|
||||||
|
if not unique:
|
||||||
|
parser.error("proof authorities require at least one --scope")
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _require_fresh_output(
|
||||||
|
parser: argparse.ArgumentParser, path: Path, *, label: str
|
||||||
|
) -> None:
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
parser.error(f"{label.capitalize()} output already exists: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_private_directory(
|
||||||
|
parser: argparse.ArgumentParser, directory: Path
|
||||||
|
) -> None:
|
||||||
|
_require_output_directory(parser, directory)
|
||||||
|
mode = stat.S_IMODE(directory.stat().st_mode)
|
||||||
|
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||||
|
parser.error(
|
||||||
|
"Private-key parent directory must not be accessible by group or others"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_output_directory(
|
||||||
|
parser: argparse.ArgumentParser, directory: Path
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
metadata = directory.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
parser.error(f"Output parent directory is unavailable: {directory}")
|
||||||
|
raise AssertionError from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
parser.error(f"Output parent must be a real directory: {directory}")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_new_private_file(path: Path, payload: bytes) -> None:
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(path, flags, 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb", closefd=False) as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
|
||||||
|
raise OSError("Authority output could not be secured")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc3339(value: datetime) -> str:
|
||||||
|
return value.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -14,6 +14,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
publish-packages:
|
publish-packages:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -29,7 +31,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
@@ -43,24 +44,6 @@ jobs:
|
|||||||
echo "Release tag is not contained in main" >&2
|
echo "Release tag is not contained in main" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
python - "$tag" <<'PY'
|
|
||||||
import fnmatch
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
tag = sys.argv[1]
|
|
||||||
repository = os.environ["GITEA_REPOSITORY"]
|
|
||||||
request = urllib.request.Request(
|
|
||||||
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
|
|
||||||
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
|
||||||
protections = json.load(response)
|
|
||||||
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
|
||||||
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
|
|
||||||
PY
|
|
||||||
git checkout --detach "$tag"
|
git checkout --detach "$tag"
|
||||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
@@ -130,7 +113,7 @@ jobs:
|
|||||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const gitTag = specifier.match(
|
const gitTag = specifier.match(
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/GovOPlaN/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (gitTag) {
|
if (gitTag) {
|
||||||
@@ -203,7 +186,7 @@ jobs:
|
|||||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
> "$npmrc"
|
> "$npmrc"
|
||||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
--ignore-scripts --access public \
|
--ignore-scripts --access public \
|
||||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user