7 Commits
Author SHA1 Message Date
zemion 801b11016c Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:51 +02:00
zemion eccf7dc207 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:13 +02:00
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
+87 -26
View File
@@ -14,6 +14,8 @@ on:
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
@@ -29,7 +31,6 @@ jobs:
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
@@ -43,24 +44,6 @@ jobs:
echo "Release tag is not contained in main" >&2
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"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$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 gitTag = specifier.match(
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) {
@@ -180,6 +163,78 @@ jobs:
with:
name: module-packages-${{ gitea.ref_name }}
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
shell: bash
env:
@@ -189,13 +244,17 @@ jobs:
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )); then
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
@@ -203,7 +262,9 @@ jobs:
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--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
+2
View File
@@ -22,6 +22,8 @@ Connectors; Projects stores only the native planning object and canonical
external reference.
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
+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",
"version": "0.1.14",
"version": "0.1.18",
"private": true,
"description": "GovOPlaN Projects domain module.",
"type": "module",
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-projects"
version = "0.1.14"
version = "0.1.18"
description = "GovOPlaN Projects domain module."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.14",
"govoplan-access>=0.1.8",
"govoplan-core>=0.1.18",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
+6 -1
View File
@@ -45,7 +45,7 @@ from govoplan_projects.backend.service import (
MODULE_ID = "projects"
MODULE_NAME = "Projects"
MODULE_VERSION = "0.1.14"
MODULE_VERSION = "0.1.18"
READ_SCOPE = "projects:project:read"
WRITE_SCOPE = "projects:project:write"
ADMIN_SCOPE = "projects:project:admin"
@@ -222,6 +222,11 @@ DOCUMENTATION = (
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
kind="repository",
),
DocumentationLink(
label="Projects interface pattern audit",
href="govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
),
metadata={
"seed": True,
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/projects-webui",
"version": "0.1.14",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,8 +13,11 @@
},
"./styles/projects.css": "./src/styles/projects.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=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 {
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
IconButton,
LoadingIndicator,
PageScrollViewport,
@@ -69,6 +71,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editorError, setEditorError] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<ProjectRecord | null>(null);
const [saving, setSaving] = useState(false);
@@ -123,12 +126,14 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
function openCreate() {
setEditing(null);
setEditorError("");
setEditorOpen(true);
}
function openEdit() {
if (!selected) return;
setEditing(selected);
setEditorError("");
setEditorOpen(true);
}
@@ -188,10 +193,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
}
setEditorOpen(false);
setEditing(null);
setEditorError("");
await reload();
setSelectedKey(objectKey(saved));
} 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 {
setSaving(false);
}
@@ -221,6 +227,10 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
</select>
</label>
<span className="projects-count">{total} objects</span>
<DocumentationHelpLink
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
label="Open Projects documentation"
/>
{canWrite &&
<Button type="button" variant="primary" onClick={openCreate}>
<Plus size={16} aria-hidden="true" /> New
@@ -270,7 +280,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
record={editing}
objects={parentOptions}
saving={saving}
onClose={() => setEditorOpen(false)}
error={editorError}
onClose={() => {
setEditorOpen(false);
setEditorError("");
}}
onSave={save}
/>
</main>
@@ -337,11 +351,12 @@ function PlanningStat({ label, value }: { label: string; value: number }) {
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;
record: ProjectRecord | null;
objects: ProjectRecord[];
saving: boolean;
error: string;
onClose: () => void;
onSave: (values: EditorValues) => Promise<void>;
}) {
@@ -381,9 +396,10 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</Button>
</>
}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
<label>
<span>Type</span>
<FieldLabel>Type</FieldLabel>
<select
value={values.kind}
disabled={Boolean(record)}
@@ -398,7 +414,7 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</label>
{values.kind !== "portfolio" &&
<label className="project-editor-wide">
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span>
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
<select
value={values.parentRef}
required={values.kind === "milestone"}
@@ -418,40 +434,40 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</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)} />
</label>
<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)} />
</label>
<label>
<span>State</span>
<FieldLabel>State</FieldLabel>
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select>
</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"])}>
<option value="tenant">Tenant</option>
<option value="restricted">Restricted</option>
</select>
</label>
<label>
<span>Starts</span>
<FieldLabel>Starts</FieldLabel>
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
</label>
<label>
<span>Due</span>
<FieldLabel>Due</FieldLabel>
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
</label>
<label className="project-editor-wide">
<span>Description</span>
<FieldLabel>Description</FieldLabel>
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
</label>
<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)} />
</label>
</form>