5 Commits
Author SHA1 Message Date
zemion 80795ef706 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:27 +02:00
zemion 7fade1b173 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 12s
2026-08-04 15:10:24 +02:00
zemion 260f87a6a6 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 20b8aa080c Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 4e7e972415 Adopt the shared interface pattern language 2026-08-04 08:21:50 +02:00
9 changed files with 178 additions and 46 deletions
+83 -22
View File
@@ -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) {
@@ -180,6 +163,78 @@ jobs:
with: with:
name: module-packages-${{ gitea.ref_name }} name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package - name: Publish wheel and WebUI package
shell: bash shell: bash
env: env:
@@ -189,13 +244,17 @@ jobs:
set -euo pipefail set -euo pipefail
test -n "$PACKAGE_USERNAME" test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN" test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \ python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob shopt -s nullglob
webui_packages=(dist/*.tgz) webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )); then if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)" npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc" chmod 600 "$npmrc"
@@ -203,7 +262,9 @@ 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/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi fi
+2
View File
@@ -22,6 +22,8 @@ Connectors; Projects stores only the native planning object and canonical
external reference. external reference.
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md). See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md).
The list-detail, editor, state, consequence, and accessibility mapping is in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
## Development ## Development
+27
View File
@@ -0,0 +1,27 @@
# Projects Interface Pattern Migration
Projects uses a full-height list-detail workspace. It owns native portfolio,
project, and milestone context; Tasks and Tickets own actionable work,
Connectors owns OpenProject transport, and optional integrations remain
capability-based.
| Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- |
| `/projects` catalogue | Search/filter and select a planning object | Loading, empty, failed, filtered, and selected states retain the list context. Restricted records are removed by backend authorization rather than cosmetically hidden. |
| Object detail | Inspect status, dates, ownership/membership evidence, outcomes, benefits, dependencies, and links | The selected identity, key, state, and revision remain visible while detail changes. |
| Create/edit dialog | Adaptive create/edit | Core Dialog supplies focus containment and return. Stable key, state, visibility, parent, dates, and change reason have labelled controls; save errors remain attached to the dialog. |
| Revisioned save | Consequential corrective action | Every successful save creates immutable revision and lifecycle evidence under optimistic concurrency. The required change reason explains the new record. |
Creating and editing require the Projects write permission. Restricted
visibility changes who may read an object, so its consequence is explained at
the field and enforced by the backend ACL. The workspace reflows to list then
detail at narrow widths, preserves semantic button/list behavior, and uses Core
buttons, icon buttons, dialog, alerts, loading, status, field labels, scrolling,
and documentation help.
Verification:
- `npm run test:interface-pattern`
- Projects service, migration, and manifest tests
- the Core TypeScript graph, structural localization audit, theme check, module
permutations, and full-product bundle budget
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/projects", "name": "@govoplan/projects",
"version": "0.1.14", "version": "0.1.16",
"private": true, "private": true,
"description": "GovOPlaN Projects domain module.", "description": "GovOPlaN Projects domain module.",
"type": "module", "type": "module",
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-projects" name = "govoplan-projects"
version = "0.1.14" version = "0.1.16"
description = "GovOPlaN Projects domain module." description = "GovOPlaN Projects domain module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.14", "govoplan-core>=0.1.16",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.16",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+6 -1
View File
@@ -45,7 +45,7 @@ from govoplan_projects.backend.service import (
MODULE_ID = "projects" MODULE_ID = "projects"
MODULE_NAME = "Projects" MODULE_NAME = "Projects"
MODULE_VERSION = "0.1.14" MODULE_VERSION = "0.1.16"
READ_SCOPE = "projects:project:read" READ_SCOPE = "projects:project:read"
WRITE_SCOPE = "projects:project:write" WRITE_SCOPE = "projects:project:write"
ADMIN_SCOPE = "projects:project:admin" ADMIN_SCOPE = "projects:project:admin"
@@ -222,6 +222,11 @@ DOCUMENTATION = (
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md", href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
kind="repository", kind="repository",
), ),
DocumentationLink(
label="Projects interface pattern audit",
href="govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
), ),
metadata={ metadata={
"seed": True, "seed": True,
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/projects-webui", "name": "@govoplan/projects-webui",
"version": "0.1.14", "version": "0.1.16",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,8 +13,11 @@
}, },
"./styles/projects.css": "./src/styles/projects.css" "./styles/projects.css": "./src/styles/projects.css"
}, },
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.14", "@govoplan/core-webui": "^0.1.16",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/projects/ProjectsPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/projects.css", "utf8");
assert.ok(page.includes("DocumentationHelpLink"), "Projects exposes configured-system help");
assert.ok(page.includes("PageScrollViewport"), "Projects owns bounded list and detail scrolling");
assert.ok(page.includes("<Dialog"), "Projects uses the shared focus-contained editor dialog");
assert.ok(page.includes("FieldLabel"), "Project editor fields use the shared label/help contract");
assert.ok(page.includes('DismissibleAlert tone="danger" resetKey={error}'), "Save failures remain attached to the editor");
assert.ok(page.includes("StatusBadge"), "Project lifecycle state is not conveyed by color alone");
assert.ok(!page.includes("window.alert("), "Projects must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Projects uses semantic interactive elements");
assert.ok(styles.includes("@media (max-width: 560px)"), "Projects retains a narrow-viewport editor layout");
assert.ok(styles.includes(":focus-visible"), "Projects retains visible keyboard focus");
console.log("Projects interface pattern contract passed.");
+29 -13
View File
@@ -16,7 +16,9 @@ import {
import { import {
Button, Button,
Dialog, Dialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FieldLabel,
IconButton, IconButton,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
@@ -69,6 +71,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [editorError, setEditorError] = useState("");
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<ProjectRecord | null>(null); const [editing, setEditing] = useState<ProjectRecord | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -123,12 +126,14 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
setEditorError("");
setEditorOpen(true); setEditorOpen(true);
} }
function openEdit() { function openEdit() {
if (!selected) return; if (!selected) return;
setEditing(selected); setEditing(selected);
setEditorError("");
setEditorOpen(true); setEditorOpen(true);
} }
@@ -188,10 +193,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
} }
setEditorOpen(false); setEditorOpen(false);
setEditing(null); setEditing(null);
setEditorError("");
await reload(); await reload();
setSelectedKey(objectKey(saved)); setSelectedKey(objectKey(saved));
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "The project object could not be saved."); setEditorError(reason instanceof Error ? reason.message : "The project object could not be saved.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -221,6 +227,10 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
</select> </select>
</label> </label>
<span className="projects-count">{total} objects</span> <span className="projects-count">{total} objects</span>
<DocumentationHelpLink
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
label="Open Projects documentation"
/>
{canWrite && {canWrite &&
<Button type="button" variant="primary" onClick={openCreate}> <Button type="button" variant="primary" onClick={openCreate}>
<Plus size={16} aria-hidden="true" /> New <Plus size={16} aria-hidden="true" /> New
@@ -270,7 +280,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
record={editing} record={editing}
objects={parentOptions} objects={parentOptions}
saving={saving} saving={saving}
onClose={() => setEditorOpen(false)} error={editorError}
onClose={() => {
setEditorOpen(false);
setEditorError("");
}}
onSave={save} onSave={save}
/> />
</main> </main>
@@ -337,11 +351,12 @@ function PlanningStat({ label, value }: { label: string; value: number }) {
return <div><span>{label}</span><strong>{value}</strong></div>; return <div><span>{label}</span><strong>{value}</strong></div>;
} }
function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: { function ProjectEditorDialog({ open, record, objects, saving, error, onClose, onSave }: {
open: boolean; open: boolean;
record: ProjectRecord | null; record: ProjectRecord | null;
objects: ProjectRecord[]; objects: ProjectRecord[];
saving: boolean; saving: boolean;
error: string;
onClose: () => void; onClose: () => void;
onSave: (values: EditorValues) => Promise<void>; onSave: (values: EditorValues) => Promise<void>;
}) { }) {
@@ -381,9 +396,10 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</Button> </Button>
</> </>
}> }>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}> <form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
<label> <label>
<span>Type</span> <FieldLabel>Type</FieldLabel>
<select <select
value={values.kind} value={values.kind}
disabled={Boolean(record)} disabled={Boolean(record)}
@@ -398,7 +414,7 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</label> </label>
{values.kind !== "portfolio" && {values.kind !== "portfolio" &&
<label className="project-editor-wide"> <label className="project-editor-wide">
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span> <FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
<select <select
value={values.parentRef} value={values.parentRef}
required={values.kind === "milestone"} required={values.kind === "milestone"}
@@ -418,40 +434,40 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</label> </label>
} }
<label> <label>
<span>Key</span> <FieldLabel help="Stable identifier used in links and external mappings.">Key</FieldLabel>
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} /> <input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="project-editor-wide">
<span>Title</span> <FieldLabel>Title</FieldLabel>
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /> <input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
</label> </label>
<label> <label>
<span>State</span> <FieldLabel>State</FieldLabel>
<select value={values.state} onChange={(event) => set("state", event.target.value)}> <select value={values.state} onChange={(event) => set("state", event.target.value)}>
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)} {STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select> </select>
</label> </label>
<label> <label>
<span>Visibility</span> <FieldLabel help="Restricted objects require an owner or explicit membership grant.">Visibility</FieldLabel>
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}> <select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
<option value="tenant">Tenant</option> <option value="tenant">Tenant</option>
<option value="restricted">Restricted</option> <option value="restricted">Restricted</option>
</select> </select>
</label> </label>
<label> <label>
<span>Starts</span> <FieldLabel>Starts</FieldLabel>
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} /> <input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
</label> </label>
<label> <label>
<span>Due</span> <FieldLabel>Due</FieldLabel>
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} /> <input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="project-editor-wide">
<span>Description</span> <FieldLabel>Description</FieldLabel>
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} /> <textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="project-editor-wide">
<span>Change reason</span> <FieldLabel help="Recorded with the immutable project revision and lifecycle evidence.">Change reason</FieldLabel>
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} /> <input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
</label> </label>
</form> </form>