Compare commits

...

72 Commits

Author SHA1 Message Date
jmiller 41e48945fd chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-23 23:12:05 +00:00
jmiller 7d28fe522d Merge pull request 'fix: correct dev health URL to git.dev.mokoconsulting.tech' (#695) from fix/dev-deploy-hostname into main
Deploy MokoGitea / deploy (push) Blocked by required conditions
Deploy MokoGitea / Verify dev environment is healthy (push) Successful in 2s
fix: correct dev health URL to git.dev.mokoconsulting.tech (#695)
2026-06-23 23:08:13 +00:00
Jonathan Miller 8780ec7c5f fix: correct dev health URL to git.dev.mokoconsulting.tech
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
PR RC Release / Build RC Release (pull_request) Failing after 59s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m22s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m20s
Was dev.git.mokoconsulting.tech which doesn't exist. The dev instance
runs at git.dev.mokoconsulting.tech (port 3090 behind nginx).
2026-06-23 18:06:49 -05:00
jmiller 9106cfe254 Merge pull request 'feat: wiki search, metadata deploy fields, workflow cleanup' (#694) from dev into main
Deploy MokoGitea / deploy (push) Blocked by required conditions
Deploy MokoGitea / Verify dev environment is healthy (push) Failing after 2s
Merge PR #694: wiki search, metadata deploy fields, workflow cleanup
2026-06-23 22:34:39 +00:00
Jonathan Miller f7d70ae95a docs: update changelog with wiki search, metadata deploy fields, fixes
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Universal: Auto Version Bump / Version Bump (push) Successful in 17s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m30s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m35s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 2m30s
PR RC Release / Build RC Release (pull_request) Failing after 2m28s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m16s
2026-06-23 17:33:35 -05:00
Jonathan Miller 5c43cf1f02 fix(metadata): support partial updates — only sent fields are changed
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 10s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m30s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m32s
PR RC Release / Build RC Release (pull_request) Failing after 2m28s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 2m32s
Previously PUT /metadata replaced all fields, wiping any not included
in the request. Now loads existing metadata first and merges only the
fields present in the JSON body.
2026-06-23 17:28:40 -05:00
Jonathan Miller c8c74c7afe fix: handle DB errors in licensing API, fix wiki API URL-decode fallback
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 10s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m12s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m46s
PR RC Release / Build RC Release (pull_request) Failing after 1m38s
- licensing/manage.go: capture Update/Delete errors instead of silently
  discarding them (UpdateLicense, UpdateTier, DeleteTier)
- wiki.go API: fix findEntryForFile to allow URL-decode fallback for
  non-ASCII page names (was returning on ErrNotExist instead of falling through)
2026-06-23 17:25:47 -05:00
Jonathan Miller 7b68963b67 feat(metadata): add deploy fields to repo metadata API (#692)
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m11s
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 10s
Universal: Build & Release / Promote to RC (pull_request) Failing after 18s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 1m16s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m23s
- Migration v360: adds deploy_host, deploy_port, deploy_user, deploy_path,
  docker_image, docker_registry, container_name, health_url to repo_manifest
- API: GET/PUT /metadata now includes deploy fields
- Settings: preserve deploy fields on web UI save
- Remove 4 unneeded workflows (gitleaks, npm-publish, notify, workflow-sync)
  - gitleaks will become built-in (#692)
  - npm-publish/notify not applicable to Go repo
  - workflow-sync moving to MokoCLI
2026-06-23 14:32:31 -05:00
jmiller 22c2a99f8b chore: remove security-audit.yml -- handled by MokoGitea
Deploy MokoGitea / deploy (push) Blocked by required conditions
Deploy MokoGitea / Verify dev environment is healthy (push) Failing after 2s
2026-06-23 18:26:46 +00:00
jmiller c974970118 chore: remove security-audit.yml -- handled by MokoGitea
Universal: Auto Version Bump / Version Bump (push) Successful in 19s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m36s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m6s
2026-06-23 18:04:41 +00:00
jmiller c9a8deee0e chore: remove deploy-manual.yml -- no longer needed
Universal: Auto Version Bump / Version Bump (push) Successful in 25s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 3m2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m33s
2026-06-23 17:59:24 +00:00
jmiller ea05851d0a chore: remove composer-publish.yml -- no longer needed
Universal: Auto Version Bump / Version Bump (push) Successful in 23s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m44s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 3m12s
2026-06-23 17:59:09 +00:00
Jonathan Miller 1178975be3 feat(wiki): full-text search across wiki pages (#550)
Universal: Auto Version Bump / Version Bump (push) Successful in 20s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m25s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m16s
- Web: WikiSearch handler with case-insensitive search of titles and content
- Web: search.tmpl with search form and results display
- Web: "Search wiki" link added to wiki dropdown menu
- API: GET /wiki/search?q=term endpoint with pagination
- Recursive traversal handles nested folder wikis
2026-06-23 12:05:48 -05:00
Jonathan Miller b283fad8bd docs: update README and changelog with wiki features documentation
Universal: Auto Version Bump / Version Bump (push) Successful in 20s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m3s
- README: rewrite with current feature set, fix stale code.mokoconsulting.tech URLs
- CHANGELOG: add missing wiki feature entries (#667, #668, #671, #673, #674, #675)
- Wiki Features page published to org wiki (standards/Wiki-Features)
2026-06-23 09:08:58 -05:00
Jonathan Miller a792772397 feat: dev environment deploy gate for production safety
Universal: Auto Version Bump / Version Bump (push) Successful in 20s
Deploy MokoGitea / deploy (push) Blocked by required conditions
Deploy MokoGitea / Verify dev environment is healthy (push) Failing after 2s
Deploy MokoGitea (Dev) / Build & Deploy to Dev (push) Failing after 1m30s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m23s
- New deploy-dev.yml: builds and deploys to dev.git.mokoconsulting.tech
  on push to dev branch
- Production deploy (deploy-mokogitea.yml): now checks dev health before
  building. If dev.git.mokoconsulting.tech/api/healthz fails, production
  deploy is blocked.
- Flow: push to dev → dev builds/deploys → verify healthy → merge to
  main → production checks dev health → builds/deploys production
2026-06-23 08:12:22 -05:00
Jonathan Miller 4d1be56bad fix: move CategoryPage type to package level to fix compile error
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 48s
Deploy MokoGitea / deploy (push) Successful in 4m51s
CategoryPage was defined inside WikiCategory() but referenced by
scanCategoryEntries() which is a top-level function. Renamed to
wikiCategoryPage and moved to package scope.
2026-06-23 07:40:19 -05:00
Jonathan Miller 2dc745c5fa feat(wiki): print view, ZIP export, and folder access control (#674, #675)
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 34s
Deploy MokoGitea / deploy (push) Failing after 4m10s
Print view: clean rendering without navigation chrome for printing.
ZIP export: download entire wiki as ZIP archive of markdown files.
Folder ACL: _access.yml per-folder write protection with role checks.
Resolve merge conflicts between #674 and #675 implementations.
2026-06-22 08:51:58 -05:00
Jonathan Miller 9dda78da7c feat(wiki): print view, ZIP export, and folder access control (#674, #675)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Print view: clean page rendering without navigation chrome for printing.
ZIP export: download entire wiki as ZIP archive of markdown files.
Folder ACL: _access.yml files control per-folder write permissions.
Both accessible from wiki pages dropdown menu.
2026-06-22 08:47:36 -05:00
jmiller 6ceef765eb feat(wiki): per-folder access control via _access.yml (#674)
Deploy MokoGitea / deploy (push) Failing after 4m8s
2026-06-22 01:12:47 +00:00
jmiller 23d3528676 feat(wiki): per-folder access control via _access.yml (#674)
Deploy MokoGitea / deploy (push) Failing after 5m28s
2026-06-22 01:08:35 +00:00
jmiller 249b639c70 feat(wiki): per-folder access control via _access.yml (#674)
Universal: Auto Version Bump / Version Bump (push) Successful in 15s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 49s
2026-06-22 01:05:43 +00:00
jmiller 5c9db551dc feat(wiki): per-folder access control via _access.yml (#674) — template
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 41s
2026-06-22 00:54:07 +00:00
jmiller 408f2329b3 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-22 00:35:06 +00:00
Jonathan Miller 827025bd17 feat(wiki): enhanced ToC — collapsible, inline, sticky, frontmatter control (#673)
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 56s
Deploy MokoGitea / deploy (push) Failing after 4m4s
ToC can be controlled via frontmatter: toc=false disables, toc=inline
shows at top of content instead of sidebar. Sidebar ToC is now
collapsible via <details> and sticky on scroll. Inline ToC also
uses collapsible <details> with "Contents" header.
2026-06-21 19:33:57 -05:00
Jonathan Miller 98da1644be feat(wiki): template transclusion — reusable content blocks (#671)
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 53s
Deploy MokoGitea / deploy (push) Failing after 3m57s
Add {{template:Name|key=val}} syntax for embedding reusable content.
Templates stored as _Template/Name.md with {{{key}}} parameter
substitution. Recursive with depth limit of 5. _Template folder
hidden from sidebar tree.
2026-06-21 19:05:12 -05:00
Jonathan Miller db596575a0 feat(wiki): page categories via YAML frontmatter (#668)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 54s
Deploy MokoGitea / deploy (push) Failing after 5m44s
Add categories support using frontmatter: categories: [arch, api, ref]
Categories render as clickable tags at the bottom of wiki pages.
Category index page lists all pages tagged with a given category.
Frontmatter is stripped before markdown rendering.
2026-06-21 18:32:07 -05:00
Jonathan Miller 3c56dc8814 feat(wiki): revision diff — view changes for any wiki commit (#667)
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m9s
Deploy MokoGitea / deploy (push) Failing after 5m31s
Compare a wiki commit against its parent showing added/removed lines
with color-coded diff view. Accessible via diff icon button in wiki
page header and from revision history. Shows commit metadata
(author, message, timestamp) alongside the diff.
2026-06-21 18:23:09 -05:00
Jonathan Miller dce712fabd Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Failing after 6m21s
2026-06-21 18:17:44 -05:00
Jonathan Miller 78b0ce9650 fix: org metadata API respects visibility for unauthenticated requests (#690)
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m28s
Add checkOrgVisibility() guard to issue-statuses, issue-priorities,
and issue-types endpoints. Public orgs remain accessible to everyone.
Private/limited orgs return 404 for unauthenticated callers.
2026-06-21 18:17:00 -05:00
jmiller 500a5be6d7 Merge pull request 'Release: Wiki redirects, code review fixes, bug fixes' (#689) from dev into main
Deploy MokoGitea / deploy (push) Failing after 7m34s
2026-06-21 23:14:07 +00:00
Jonathan Miller 95a747b1d5 docs: update changelog with post-#682 fixes and #672
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m27s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 2m43s
PR RC Release / Build RC Release (pull_request) Failing after 2m5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-21 18:13:28 -05:00
Jonathan Miller bb7e99ad40 fix: backlinks template pluralization
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m6s
Deploy MokoGitea / deploy (push) Failing after 6m34s
2026-06-21 18:10:16 -05:00
Jonathan Miller 6c6b7c888e feat(wiki): page rename with automatic redirects (#672)
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m25s
Deploy MokoGitea / deploy (push) Successful in 5m57s
When a wiki page is renamed, automatically create a redirect file at
the old path with YAML frontmatter (redirect: new/path). The redirect
is detected in renderViewPage() before markdown rendering, issuing an
HTTP redirect with a flash message "Redirected from OldPage".
2026-06-21 18:00:56 -05:00
Jonathan Miller 2a1692d599 fix: resolve code review issues in wiki and status features
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 36s
Deploy MokoGitea / deploy (push) Successful in 7m31s
- collectWikiPages: store hyphen-normalized names for flexible lookup
- Backlinks: use wiki_service.GitPathToWebPath for subdirectory URLs
- issue_statuses.tmpl: fix garbled em-dash bytes (0x97 → hyphen)
- backlinks.tmpl: fix pluralization text
2026-06-21 17:57:46 -05:00
Jonathan Miller 6984ac108f Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Successful in 4m47s
2026-06-21 17:39:36 -05:00
Jonathan Miller 3fdbe94830 fix: use commit.MessageTitle() instead of commit.Message() in wiki recent changes
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 35s
The Commit type embeds CommitMessage which provides MessageTitle(),
MessageUTF8(), and MessageBody() — not Message().
2026-06-21 17:38:43 -05:00
jmiller e937dd8d8b Merge pull request 'Release: Wiki system, licensing, issue statuses, metadata API' (#682) from dev into main
Deploy MokoGitea / deploy (push) Failing after 4m39s
2026-06-21 22:26:33 +00:00
Jonathan Miller e7b70f54ed docs: add #670 to changelog
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 21s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 1m50s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
PR RC Release / Build RC Release (pull_request) Failing after 1m52s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m11s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 32s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
2026-06-21 17:24:17 -05:00
Jonathan Miller b161561571 Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Failing after 5m8s
2026-06-21 17:21:01 -05:00
Jonathan Miller b981cf72e3 feat(wiki): recent changes page — cross-page edit activity (#670)
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m4s
Shows all recent wiki edits with page name, author, edit summary,
and timestamp. Paginated. Accessible via "Recent changes" in the
wiki pages dropdown menu.
2026-06-21 17:20:12 -05:00
jmiller 9964c7e16c chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:02:50 +00:00
Jonathan Miller ff27e77c37 docs: update changelog with session 2 changes
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m6s
2026-06-21 16:54:20 -05:00
Jonathan Miller 04ce7dc896 feat(wiki): internal wikilinks with [[Page Name]] syntax (#666)
Deploy MokoGitea / deploy (push) Successful in 4m51s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Add Wikipedia-style [[Page Name]] and [[Page|Display Text]] syntax.
Existing pages render as normal links; non-existent pages render as
red "new page" links. Supports [[folder/Page]], [[Page#Section]],
and [[#Anchor]] patterns.
2026-06-21 15:42:50 -05:00
Jonathan Miller f87f904a21 fix: remove Version field from metadata settings template
Version is no longer stored in RepoMetadata struct — it comes from
the metadata API. The template reference caused a 500 error.
2026-06-21 15:42:46 -05:00
Jonathan Miller fc72d8e90a feat(wiki): add backlinks — "What links here" for wiki pages (#669)
Deploy MokoGitea / deploy (push) Successful in 3m27s
Scan all wiki pages for references to the current page and display
them on a dedicated backlinks page. Uses content search across
markdown files (no database needed). Adds cross-reference button
to wiki page header.
2026-06-21 11:45:20 -05:00
Jonathan Miller 71d52e432e feat: enforce required baseline issue statuses (#681)
Deploy MokoGitea / deploy (push) Successful in 5m8s
Add IsRequired field to IssueStatusDef. Open and Closed statuses are
seeded as required and cannot be deleted. Delete attempts return an
error flash in the web UI and ErrStatusRequired in the model layer.
API response now includes is_required field.
2026-06-21 11:29:49 -05:00
jmiller 172303b61f chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:05:39 +00:00
Jonathan Miller bfb4b53da3 feat: add folder-based tree sidebar to org wiki (#680)
Deploy MokoGitea / deploy (push) Successful in 3m31s
Replace flat page list with hierarchical folder tree in org wiki sidebar.
_Sidebar.md takes precedence when present; otherwise auto-generates
collapsible folder menus up to 2 levels deep.
2026-06-21 11:00:18 -05:00
Jonathan Miller 9149fa100c feat: make metadata/manifest GET endpoint publicly accessible (#676)
Deploy MokoGitea / deploy (push) Successful in 3m30s
Remove reqRepoReader auth requirement from GET /repos/{owner}/{repo}/metadata
and /manifest endpoints. PUT (update) still requires token + admin.
2026-06-21 10:20:56 -05:00
jmiller 6a2c80a8f3 chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 06:35:03 +00:00
jmiller 28ee70a946 Merge pull request 'fix: resolve all compile errors in licensing endpoints' (#662) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m4s
2026-06-21 04:10:04 +00:00
Jonathan Miller 6d194f9bdf fix: sort imports in manage.go (stdlib before module imports)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
PR RC Release / Build RC Release (pull_request) Failing after 51s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m0s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m44s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-20 23:03:44 -05:00
Jonathan Miller 403db405cb fix: resolve all compile errors in licensing endpoints
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m4s
PR RC Release / Build RC Release (pull_request) Failing after 1m5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 54s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- ctx.BindJSON → json.NewDecoder(ctx.Req.Body).Decode
- ctx.Orm() → db.GetEngine(ctx)
- ctx.NotFound() → ctx.APIErrorNotFound()
- GetAttachmentsByReleaseID → db.GetEngine query
2026-06-20 22:57:19 -05:00
jmiller 39e4eb6ec8 Merge pull request 'fix: use ctx.APIError in licensing endpoints (build fix)' (#661) from dev into main
Deploy MokoGitea / deploy (push) Failing after 3m45s
2026-06-21 02:49:52 +00:00
Jonathan Miller 79cc30e9a8 fix: use ctx.APIError instead of ctx.Error in licensing endpoints
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 10s
PR RC Release / Build RC Release (pull_request) Failing after 1m13s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m19s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m0s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m1s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
APIContext doesn't have an Error method — use APIError(status, msg)
which is the correct 2-arg pattern for Gitea API error responses.
2026-06-20 21:47:02 -05:00
jmiller 78ad2c999b Merge pull request 'feat: licensing API phase 2 — validation, signed downloads, management, tier admin' (#660) from dev into main
Deploy MokoGitea / deploy (push) Failing after 4m18s
2026-06-21 02:37:26 +00:00
Jonathan Miller e3949077b0 Merge remote-tracking branch 'origin/main' into dev
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: PR Check / Secret Scan (pull_request) Successful in 43s
PR RC Release / Build RC Release (pull_request) Failing after 41s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 42s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m35s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
# Conflicts:
#	.mokogitea/manifest.xml
#	.mokogitea/workflows/issue-branch.yml
#	CHANGELOG.md
2026-06-20 21:35:55 -05:00
jmiller e469b4a857 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:28:46 +00:00
jmiller acae63f727 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-21 01:28:38 +00:00
jmiller e71ab8415f chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 01:28:29 +00:00
jmiller 03ce66a4f4 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 01:28:22 +00:00
jmiller deafaeca65 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 01:28:16 +00:00
jmiller 5e74c22609 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:28:10 +00:00
gitea-actions[bot] 03f881c746 chore(version): pre-release bump to 06.18.12-dev [skip ci] 2026-06-21 01:15:16 +00:00
Jonathan Miller 3a405033ae feat: add product tier admin UI with CRUD and license counts (#627)
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m35s
Admin page at /-/admin/license-tiers for managing product tiers:
- Tier list with key, name, repos, max domains, license count, sort order
- Create new tier form with repo input
- Delete tier (blocked if active licenses exist)
- Nav item added to admin sidebar
2026-06-20 20:14:24 -05:00
gitea-actions[bot] 034795951f chore(version): pre-release bump to 06.18.11-dev [skip ci] 2026-06-21 01:04:49 +00:00
Jonathan Miller 1d1b867df5 feat: add license management API — admin CRUD, user self-service, tier management (#624)
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m22s
Admin: POST/GET/PATCH/DELETE /api/v1/licensing/licenses (reqSiteAdmin)
User: GET /api/v1/licensing/my/licenses, manage domains (reqToken)
Tiers: GET/POST/PATCH/DELETE /api/v1/licensing/tiers (reqSiteAdmin)

Includes pagination, entitlement/activation detail in GET, tier change
triggers entitlement rebuild, delete-tier blocked if active licenses exist.
2026-06-20 20:04:15 -05:00
gitea-actions[bot] 63b599f62c chore(version): pre-release bump to 06.18.10-dev [skip ci] 2026-06-21 01:00:07 +00:00
Jonathan Miller 5bd449017c feat: add signed download endpoint with ed25519 tokens (#622)
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m20s
GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ

ed25519 keypair auto-generated on first use, stored in Gitea data dir.
Update XML endpoint now generates signed URLs with 5-minute TTL.
Download verifies signature + expiry + DLID + entitlement before serving
the release ZIP attachment. Downloads logged to audit trail.
2026-06-20 19:59:37 -05:00
gitea-actions[bot] fe3de3fbff chore(version): pre-release bump to 06.18.09-dev [skip ci] 2026-06-21 00:52:30 +00:00
Jonathan Miller 3e909df6d4 feat: add license validation API — public validate + authenticated status (#623)
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m16s
GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ (public)
GET /api/v1/licensing/{dlid}/status (authenticated, reqToken)

Public endpoint returns valid/invalid with reason codes for Joomla plugin
and external integration use. Authenticated endpoint returns full license
detail with entitlement list and domain usage for admin dashboards.
2026-06-20 19:51:50 -05:00
gitea-actions[bot] 30bb5e33e2 chore(version): pre-release bump to 06.18.08-dev [skip ci] 2026-06-20 22:20:41 +00:00
47 changed files with 3858 additions and 1005 deletions
+467 -403
View File
@@ -1,403 +1,467 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template # PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +========================================================================+ # +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+ # +=======================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
# | Platform-specific: | # | Platform-specific: |
# | joomla: XML manifest, type-prefixed packages | # | joomla: XML manifest, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +========================================================================+ # +=======================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
on: on:
pull_request: pull_request:
types: [opened, closed] types: [opened, synchronize, closed]
branches: branches:
- main - main
workflow_dispatch: paths-ignore:
inputs: - '.mokogitea/workflows/**'
action: - '*.md'
description: 'Action to perform' - 'wiki/**'
required: false - '.editorconfig'
type: choice - '.gitignore'
default: release - '.gitattributes'
options: - '.gitmessage'
- release - 'LICENSE'
- promote-rc workflow_dispatch:
inputs:
env: action:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true description: 'Action to perform'
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} required: false
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} type: choice
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} default: release
options:
permissions: - release
contents: write - promote-rc
jobs: env:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────── FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
promote-rc: GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
name: Promote to RC GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
runs-on: release GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) || permissions:
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') contents: write
steps: jobs:
- name: Checkout repository # ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 promote-rc:
with: name: Promote to RC
token: ${{ secrets.MOKOGITEA_TOKEN }} runs-on: release
fetch-depth: 1 if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
- name: Setup mokocli tools (github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
env: (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting steps:
run: | - name: Checkout repository
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
echo Using pre-installed /opt/mokocli with:
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV token: ${{ secrets.MOKOGITEA_TOKEN }}
else fetch-depth: 1
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then - name: Setup mokocli tools
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 env:
fi MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
rm -rf /tmp/mokocli MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git run: |
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
cd /tmp/mokocli echo Using pre-installed /opt/mokocli
composer install --no-dev --no-interaction --quiet echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV else
fi echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
- name: Rename branch to rc sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
run: | fi
php ${MOKO_CLI}/branch_rename.php \ rm -rf /tmp/mokocli
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ cd /tmp/mokocli
--pr "${{ github.event.pull_request.number }}" composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
- name: Checkout rc and configure git fi
run: |
git fetch origin rc - name: Rename branch to rc
git checkout rc run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" php ${MOKO_CLI}/branch_rename.php \
git config --local user.name "gitea-actions[bot]" --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
- name: Publish RC release --pr "${{ github.event.pull_request.number }}"
run: |
php ${MOKO_CLI}/release_publish.php \ - name: Checkout rc and configure git
--path . --stability rc --bump minor --branch rc \ run: |
--token "${{ secrets.MOKOGITEA_TOKEN }}" git fetch origin rc
git checkout rc
- name: Update RC release notes from CHANGELOG.md git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
run: | git config --local user.name "gitea-actions[bot]"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
- name: Publish RC release
# Extract [Unreleased] section from changelog run: |
NOTES="" php ${MOKO_CLI}/release_publish.php \
if [ -f "CHANGELOG.md" ]; then --path . --stability rc --bump minor --branch rc \
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) --token "${{ secrets.MOKOGITEA_TOKEN }}"
fi
[ -z "$NOTES" ] && NOTES="Release candidate" - name: Update RC release notes from CHANGELOG.md
run: |
# Find the RC release and update its body API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
"${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) # Extract [Unreleased] section from changelog
NOTES=""
if [ -n "$RELEASE_ID" ]; then if [ -f "CHANGELOG.md" ]; then
python3 -c " NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
import json, urllib.request fi
body = open('/dev/stdin').read() [ -z "$NOTES" ] && NOTES="Release candidate"
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request( # Find the RC release and update its body
'${API_BASE}/releases/${RELEASE_ID}', RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
data=payload, method='PATCH', "${API_BASE}/releases/tags/release-candidate" \
headers={ | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json' if [ -n "$RELEASE_ID" ]; then
}) python3 -c "
urllib.request.urlopen(req) import json, urllib.request
" <<< "$NOTES" body = open('/dev/stdin').read()
echo "RC release notes updated from CHANGELOG.md" payload = json.dumps({'body': body}).encode()
fi req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
- name: Summary data=payload, method='PATCH',
if: always() headers={
run: | 'Authorization': 'token ${TOKEN}',
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY 'Content-Type': 'application/json'
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY })
urllib.request.urlopen(req)
# ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── " <<< "$NOTES"
release: echo "RC release notes updated from CHANGELOG.md"
name: Build & Release Pipeline fi
runs-on: release
if: >- - name: Summary
github.event.pull_request.merged == true || if: always()
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
steps: echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
with: release:
token: ${{ secrets.MOKOGITEA_TOKEN }} name: Build & Release Pipeline
fetch-depth: 0 runs-on: release
if: >-
- name: Configure git for bot pushes github.event.pull_request.merged == true ||
run: | (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]" steps:
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check for merge conflict markers with:
run: | token: ${{ secrets.MOKOGITEA_TOKEN }}
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) fetch-depth: 0
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release" - name: Configure git for bot pushes
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY run: |
echo '```' >> $GITHUB_STEP_SUMMARY git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY git config --local user.name "gitea-actions[bot]"
echo '```' >> $GITHUB_STEP_SUMMARY git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
exit 1
fi - name: Check for merge conflict markers
echo "No conflict markers found" run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
- name: Setup mokocli tools if [ -n "$CONFLICTS" ]; then
env: echo "::error::Merge conflict markers found — aborting release"
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting echo '```' >> $GITHUB_STEP_SUMMARY
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
run: | echo '```' >> $GITHUB_STEP_SUMMARY
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then exit 1
echo Using pre-installed /opt/mokocli fi
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV echo "No conflict markers found"
else
echo Falling back to fresh clone - name: Setup mokocli tools
if ! command -v composer > /dev/null 2>&1; then env:
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
fi MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
rm -rf /tmp/mokocli COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git run: |
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
cd /tmp/mokocli echo Using pre-installed /opt/mokocli
composer install --no-dev --no-interaction --quiet echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV else
fi echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
- name: "Determine version bump level" sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
id: bump fi
run: | rm -rf /tmp/mokocli
# Fix/patch branches: version was already bumped by pre-release, just strip suffix CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
# Feature/dev branches: bump minor for the new stable release git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}" cd /tmp/mokocli
case "$HEAD_REF" in composer install --no-dev --no-interaction --quiet
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;; echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
*) BUMP="minor" ;; fi
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT" - name: "Detect platform"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})" id: platform
run: |
- name: "Publish stable release" php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
run: | php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then - name: "Determine version bump level"
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}" id: bump
fi run: |
php ${MOKO_CLI}/release_publish.php \ # Fix/patch branches: version was already bumped by pre-release, just strip suffix
--path . --stability stable ${BUMP_FLAG} --branch main \ # Feature/dev branches: bump minor for the new stable release
--token "${{ secrets.MOKOGITEA_TOKEN }}" HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
case "$HEAD_REF" in
- name: Update release notes and promote changelog fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
run: | *) BUMP="minor" ;;
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" esac
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \ - name: "Publish stable release"
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}') run: |
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true) BUMP_FLAG=""
# Extract version from release name (e.g. "06.17.00" or "v06.17.00") if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
VERSION=$(python3 -c " BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
import json, sys, re fi
r = json.load(sys.stdin) php ${MOKO_CLI}/release_publish.php \
name = r.get('name', '') --path . --stability stable ${BUMP_FLAG} --branch main \
m = re.search(r'(\d+\.\d+\.\d+)', name) --token "${{ secrets.MOKOGITEA_TOKEN }}"
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true) - name: "Read published version"
id: version
# Extract [Unreleased] section from changelog run: |
NOTES="" VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
if [ -f "CHANGELOG.md" ]; then VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
[ -z "$NOTES" ] && NOTES="Stable release" PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
# Update release body via API echo "tag=stable" >> "$GITHUB_OUTPUT"
if [ -n "$RELEASE_ID" ]; then echo "release_tag=stable" >> "$GITHUB_OUTPUT"
python3 -c " else
import json, urllib.request echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
body = open('/dev/stdin').read() echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
payload = json.dumps({'body': body}).encode() fi
req = urllib.request.Request( echo "branch=main" >> "$GITHUB_OUTPUT"
'${API_BASE}/releases/${RELEASE_ID}', echo "Published version: ${VERSION}"
data=payload, method='PATCH',
headers={ - name: "Create semver tag for non-Joomla repos"
'Authorization': 'token ${TOKEN}', id: semver
'Content-Type': 'application/json' if: |
}) steps.version.outputs.skip != 'true' &&
urllib.request.urlopen(req) !startsWith(steps.platform.outputs.platform, 'joomla')
" <<< "$NOTES" run: |
echo "Release notes updated from CHANGELOG.md" VERSION="${{ steps.version.outputs.version }}"
fi API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Promote [Unreleased] → [version] in CHANGELOG.md and reset SEMVER_TAG="v${VERSION}"
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d) echo "Creating semver tag: ${SEMVER_TAG}"
python3 -c "
import sys # Create the git tag via API
version, date = sys.argv[1], sys.argv[2] HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
content = open('CHANGELOG.md').read() -X POST -H "Authorization: token ${TOKEN}" \
old = '## [Unreleased]' -H "Content-Type: application/json" \
new = f'## [Unreleased]\n\n## [{version}] --- {date}' "${API_BASE}/tags" \
content = content.replace(old, new, 1) -d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE" if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
git add CHANGELOG.md echo "Created semver tag: ${SEMVER_TAG}"
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true elif [ "$HTTP_CODE" = "409" ]; then
git push origin main || true echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
echo "Changelog promoted: [Unreleased] → [${VERSION}]" else
fi echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub" echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
if: >-
steps.version.outputs.skip != 'true' && - name: Update release notes and promote changelog
secrets.GH_MIRROR_TOKEN != '' run: |
continue-on-error: true API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
run: | TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}" # Get the stable release info (version and ID)
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" "${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
php ${MOKO_CLI}/release_mirror.php \ RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
--version "$VERSION" --tag "$RELEASE_TAG" \ # Extract version from release name (e.g. "06.17.00" or "v06.17.00")
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ VERSION=$(python3 -c "
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ import json, sys, re
--branch main 2>&1 || true r = json.load(sys.stdin)
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
# -- STEP 10: Sync main branch to GitHub mirror ---------------------------- print(m.group(1) if m else '')
- name: "Step 10: Push main to GitHub mirror" " <<< "$RELEASE_JSON" 2>/dev/null || true)
if: >-
steps.version.outputs.skip != 'true' && # Extract [Unreleased] section from changelog
secrets.GH_MIRROR_TOKEN != '' NOTES=""
continue-on-error: true if [ -f "CHANGELOG.md" ]; then
run: | NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" fi
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) [ -z "$NOTES" ] && NOTES="Stable release"
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ # Update release body via API
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" if [ -n "$RELEASE_ID" ]; then
git fetch origin main --depth=1 python3 -c "
git push github origin/main:refs/heads/main --force 2>/dev/null \ import json, urllib.request
&& echo "main branch pushed to GitHub mirror" \ body = open('/dev/stdin').read()
|| echo "WARNING: GitHub mirror push failed" payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
- name: "Step 11: Delete rc branch and recreate dev from main" '${API_BASE}/releases/${RELEASE_ID}',
if: steps.version.outputs.skip != 'true' data=payload, method='PATCH',
continue-on-error: true headers={
run: | 'Authorization': 'token ${TOKEN}',
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" 'Content-Type': 'application/json'
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" })
urllib.request.urlopen(req)
# Delete rc branch (ephemeral — created by promote-rc) " <<< "$NOTES"
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ echo "Release notes updated from CHANGELOG.md"
"${API_BASE}/branches/rc" 2>/dev/null \ fi
&& echo "Deleted rc branch" || echo "rc branch not found"
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
# Delete dev branch if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ DATE=$(date +%Y-%m-%d)
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" python3 -c "
import sys
# Recreate dev from main (now includes version bump + changelog promotion) version, date = sys.argv[1], sys.argv[2]
curl -sf -X POST -H "Authorization: token ${TOKEN}" \ content = open('CHANGELOG.md').read()
-H "Content-Type: application/json" \ old = '## [Unreleased]'
"${API_BASE}/branches" \ new = f'## [Unreleased]\n\n## [{version}] --- {date}'
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY " "$VERSION" "$DATE"
git add CHANGELOG.md
- name: "Step 12: Create version branch from main" git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
if: steps.version.outputs.skip != 'true' git push origin main || true
continue-on-error: true echo "Changelog promoted: [Unreleased] → [${VERSION}]"
run: | fi
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - name: "Step 9: Mirror release to GitHub"
BRANCH_NAME="version/${VERSION}" if: >-
MAIN_SHA=$(git rev-parse HEAD) steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
# Delete old version branch if it exists (same version re-release) continue-on-error: true
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
# Create version/XX.YY.ZZ from main RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
# -- Dolibarr post-release: Reset dev version ----------------------------- --branch main 2>&1 || true
- name: "Post-release: Reset dev version" echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
if: steps.version.outputs.skip != 'true'
continue-on-error: true # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
run: | - name: "Step 10: Push main to GitHub mirror"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" if: >-
php ${MOKO_CLI}/version_reset_dev.php \ steps.version.outputs.skip != 'true' &&
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ secrets.GH_MIRROR_TOKEN != ''
--branch dev --path . 2>&1 || true continue-on-error: true
run: |
# -- Summary -------------------------------------------------------------- GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
- name: Pipeline Summary GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
if: always() GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
run: | git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
PLATFORM="${{ steps.platform.outputs.platform }}" git fetch origin main --depth=1
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then git push github origin/main:refs/heads/main --force 2>/dev/null \
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY && echo "main branch pushed to GitHub mirror" \
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY || echo "WARNING: GitHub mirror push failed"
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY - name: "Step 11: Delete rc branch and recreate dev from main"
else if: steps.version.outputs.skip != 'true'
echo "" >> $GITHUB_STEP_SUMMARY continue-on-error: true
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY run: |
echo "" >> $GITHUB_STEP_SUMMARY API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY # Delete rc branch (ephemeral — created by promote-rc)
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY "${API_BASE}/branches/rc" 2>/dev/null \
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY && echo "Deleted rc branch" || echo "rc branch not found"
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi # Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+109
View File
@@ -0,0 +1,109 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Build and deploy MokoGitea to dev environment on push to dev branch.
# Production deploy (deploy-mokogitea.yml) only succeeds if dev is healthy.
name: Deploy MokoGitea (Dev)
on:
push:
branches:
- dev
concurrency:
group: deploy-mokogitea-dev
cancel-in-progress: true
env:
REGISTRY: git.mokoconsulting.tech
IMAGE: mokoconsulting/mokogitea
DEPLOY_HOST: git.mokoconsulting.tech
DEPLOY_PORT: 2918
DEPLOY_USER: mokoconsulting
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
deploy-dev:
name: "Build & Deploy to Dev"
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: config
run: |
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev-$(git rev-parse --short HEAD)")
echo "tag=${VERSION}-dev" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}-dev"
- name: Write deploy key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: Build and deploy to dev via SSH
env:
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
TAG: ${{ steps.config.outputs.tag }}
run: |
HEALTH_FMT='${{ '{{' }}.State.Health.Status${{ '}}' }}'
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s <<DEPLOY_EOF
set -e
echo 'SSH connected to dev environment'
echo 'Cleaning Docker build cache...'
docker builder prune -af 2>/dev/null || true
docker image prune -af 2>/dev/null || true
echo 'Pulling source...'
SOURCE_DIR=/opt/gitea-dev/source
if [ ! -d \$SOURCE_DIR/.git ]; then
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git \$SOURCE_DIR
fi
cd \$SOURCE_DIR
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
git fetch origin dev
git reset --hard origin/dev
echo 'Building Docker image...'
docker build --no-cache --build-arg GOFLAGS='-p 1' \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG \
-f Dockerfile .
echo 'Pushing to registry...'
echo '\$REGISTRY_TOKEN' | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG
echo 'Restarting dev container...'
cd /opt/gitea-dev
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:\$TAG|" docker-compose.yml
docker compose up -d mokogitea-dev
echo 'Health check...'
for i in 1 2 3 4 5 6 7 8; do
sleep 15
if docker inspect --format='\$HEALTH_FMT' mokogitea-dev 2>/dev/null | grep -q healthy; then
echo 'Dev container healthy!'
exit 0
fi
echo "Waiting... (attempt \$i/8)"
done
echo 'Health check failed'
docker logs mokogitea-dev --tail 20
exit 1
DEPLOY_EOF
- name: Verify dev instance
run: |
sleep 5
curl -sf https://git.dev.mokoconsulting.tech/api/healthz && echo " Dev API healthy"
-126
View File
@@ -1,126 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+16
View File
@@ -36,7 +36,23 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
check-dev:
name: "Verify dev environment is healthy"
runs-on: ubuntu-latest
steps:
- name: Check dev health
run: |
echo "Checking git.dev.mokoconsulting.tech health..."
if curl -sf --max-time 10 https://git.dev.mokoconsulting.tech/api/healthz; then
echo " Dev environment is healthy — proceeding with production deploy"
else
echo "::error::Dev environment is NOT healthy — blocking production deploy"
echo "Deploy to dev first (push to dev branch) and verify it passes before merging to main."
exit 1
fi
deploy: deploy:
needs: check-dev
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source (for version detection) - name: Checkout source (for version detection)
-92
View File
@@ -1,92 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: "Universal: Secret Scanning"
on:
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# VERSION: 06.20.00 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
-70
View File
@@ -1,70 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
types:
- completed
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
jobs:
notify:
name: Send Notification
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
- name: Notify on success (releases only)
if: >-
github.event.workflow_run.conclusion == 'success' &&
contains(github.event.workflow_run.name, 'Release')
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} released" \
-H "Tags: white_check_mark,package" \
-H "Priority: default" \
-H "Click: ${URL}" \
-d "${WORKFLOW} completed successfully." \
"${NTFY_URL}/${NTFY_TOPIC}"
- name: Notify on failure
if: github.event.workflow_run.conclusion == 'failure'
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} workflow failed" \
-H "Tags: x,warning" \
-H "Priority: high" \
-H "Click: ${URL}" \
-d "${WORKFLOW} failed. Check the run for details." \
"${NTFY_URL}/${NTFY_TOPIC}"
-51
View File
@@ -1,51 +0,0 @@
name: Publish MCP to npm
on:
push:
branches: [main]
paths:
- '.mokogitea/mcp/**'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install and build
working-directory: .mokogitea/mcp
run: |
npm ci
npx tsc
- name: Check version change
id: version
working-directory: .mokogitea/mcp
run: |
LOCAL_VERSION=$(node -p "require('./package.json').version")
NPM_VERSION=$(npm view @mokoconsulting/mokogitea-mcp version 2>/dev/null || echo "0.0.0")
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "Version unchanged: $LOCAL_VERSION"
fi
- name: Publish to npm
if: steps.version.outputs.changed == 'true'
working-directory: .mokogitea/mcp
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to Gitea registry
if: steps.version.outputs.changed == 'true'
working-directory: .mokogitea/mcp
run: |
npm publish --registry ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ \
--//$(echo "${{ github.server_url }}" | sed 's|https://||')/api/packages/${{ github.repository_owner }}/npm/:_authToken=${{ secrets.MOKOGITEA_TOKEN }}
+45
View File
@@ -487,3 +487,48 @@ jobs:
echo "Source: ${FILE_COUNT} files" echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+18 -4
View File
@@ -49,10 +49,8 @@ jobs:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release runs-on: release
if: >- if: >-
(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
github.event_name == 'push') && github.event_name == 'push'
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]')
steps: steps:
- name: Checkout - name: Checkout
@@ -90,8 +88,20 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch # Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
@@ -168,6 +178,7 @@ jobs:
- name: Create release - name: Create release
id: release id: release
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -178,6 +189,7 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md - name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -214,6 +226,7 @@ jobs:
- name: Build package and upload - name: Build package and upload
id: package id: package
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
@@ -227,6 +240,7 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows # No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal # INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml # PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge # BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
-82
View File
@@ -1,82 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+35
View File
@@ -3,6 +3,17 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- Wiki full-text search: case-insensitive search across all wiki page titles and content (#550)
- Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550)
- Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692)
- Metadata API partial updates: PUT /metadata now merges only sent fields instead of replacing all
- Wiki revision diff: line-by-line diff view per commit in wiki page history (#667)
- Wiki categories: YAML frontmatter `categories:` with category index page (#668)
- Wiki template transclusion: `{{template:Name|key=val}}` with `_Template/` folder (#671)
- Wiki enhanced ToC: collapsible, inline via frontmatter, sticky sidebar (#673)
- Wiki folder ACL: `_access.yml` per-folder write protection (#674)
- Wiki print view and ZIP export of all wiki pages (#675)
- Wiki features documentation page in org wiki (standards/Wiki-Features)
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration) - DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
- License CRUD with CRC32-checksummed DLID generation and format validation - License CRUD with CRC32-checksummed DLID generation and format validation
- Entitlement model with tier-based rebuild and custom entitlement preservation - Entitlement model with tier-based rebuild and custom entitlement preservation
@@ -10,6 +21,30 @@
- 13 seeded product tiers from base to enterprise - 13 seeded product tiers from base to enterprise
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml - DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
- Profile repo fallback chain: .mokogitea > .profile > .github - Profile repo fallback chain: .mokogitea > .profile > .github
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
- Issue status API response includes is_required field
- Wiki recent changes page: cross-page edit activity with pagination (#670)
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
### Fixed
- Licensing API: handle DB write errors in UpdateLicense, UpdateTier, DeleteTier instead of silently discarding
- Wiki API: fix findEntryForFile URL-decode fallback for non-ASCII page names
- Metadata settings template 500 error: removed reference to deleted Version field
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
- Wiki backlinks: proper URL encoding for subdirectory pages
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
- Issue statuses template: garbled em-dash character replaced
### Changed
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
### Removed
- Workflows: gitleaks.yml, npm-publish.yml, notify.yml, workflow-sync-trigger.yml, composer-publish.yml, deploy-manual.yml, security-audit.yml (not applicable to Go repo)
## [06.19.00] --- 2026-06-20 ## [06.19.00] --- 2026-06-20
+14 -23
View File
@@ -1,38 +1,29 @@
# MokoGitea # MokoGitea
Moko fork of Gitea — adding project board REST API endpoints and custom enhancements Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, and project board API.
![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Wiki](https://img.shields.io/badge/wiki-MokoGitea-blue?style=flat-square) ![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square)
Custom Gitea fork with Project Board API
--- ---
## Pages ## Key Features
- [Branding](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Branding) - **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
- [Deployment](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Deployment) - **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
- [Project API](Project API) - **Issue Statuses** -- custom workflow statuses per org with required baseline protection
- [roadmap](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/roadmap) - **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
- **Project Board API** -- REST endpoints for project columns and cards
--- - **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
**Category:** Infrastructure | **Platform:** [MokoPlatform wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki)
---
---
## Documentation ## Documentation
Full documentation is available on the [Wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki). - [Org Wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) -- standards, CLI reference, API docs
- [Wiki Features](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features) -- all 10 wiki enhancements
- [Licensing API](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/api/Licensing-API)
## Contributing ## Contributing
See the wiki for development guidelines and contribution instructions. See the [org wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) for development guidelines, coding standards, and contribution instructions.
## License ## License
@@ -40,4 +31,4 @@ This project is licensed under the GNU General Public License v3.0 or later -- s
--- ---
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki/Home)* *[Moko Consulting](https://mokoconsulting.tech)*
+32 -6
View File
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48" Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
Description string `xorm:"TEXT"` Description string `xorm:"TEXT"`
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"` ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"` IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
@@ -56,14 +57,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
} }
// seedDefaultIssueStatuses creates the standard status presets for an org. // seedDefaultIssueStatuses creates the standard status presets for an org.
// Open and Closed are required (is_required=true) and cannot be deleted.
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error { func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
defaults := []*IssueStatusDef{ defaults := []*IssueStatusDef{
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true}, {OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true}, {OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true}, {OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true}, {OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true}, {OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
} }
for _, d := range defaults { for _, d := range defaults {
if _, err := db.GetEngine(ctx).Insert(d); err != nil { if _, err := db.GetEngine(ctx).Insert(d); err != nil {
@@ -111,13 +113,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
return err return err
} }
// ErrStatusRequired is returned when trying to delete a required status.
type ErrStatusRequired struct {
ID int64
Name string
}
func (e ErrStatusRequired) Error() string {
return "status is required and cannot be deleted"
}
// IsErrStatusRequired checks if an error is ErrStatusRequired.
func IsErrStatusRequired(err error) bool {
_, ok := err.(ErrStatusRequired)
return ok
}
// DeleteIssueStatusDef deletes a status definition and clears references on issues. // DeleteIssueStatusDef deletes a status definition and clears references on issues.
// Returns ErrStatusRequired if the status is marked as required.
func DeleteIssueStatusDef(ctx context.Context, id int64) error { func DeleteIssueStatusDef(ctx context.Context, id int64) error {
def, err := GetIssueStatusDefByID(ctx, id)
if err != nil {
return err
}
if def.IsRequired {
return ErrStatusRequired{ID: def.ID, Name: def.Name}
}
// Clear status_id on all issues that reference this definition // Clear status_id on all issues that reference this definition
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil { if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
return err return err
} }
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef)) _, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
return err return err
} }
+1
View File
@@ -436,6 +436,7 @@ func prepareMigrationTasks() []*migration {
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType), newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns), newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables), newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest),
} }
return preparedMigrations return preparedMigrations
} }
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"xorm.io/xorm"
)
// AddDeployFieldsToRepoManifest adds deploy configuration columns to repo_manifest.
func AddDeployFieldsToRepoManifest(x *xorm.Engine) error {
type RepoManifest struct {
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"`
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"`
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"`
DeployPath string `xorm:"TEXT 'deploy_path'"`
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"`
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"`
ContainerName string `xorm:"VARCHAR(100) 'container_name'"`
HealthURL string `xorm:"TEXT 'health_url'"`
}
return x.Sync(new(RepoManifest))
}
+10
View File
@@ -50,6 +50,16 @@ type RepoMetadata struct {
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
// deploy section
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918)
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"` // SSH user
DeployPath string `xorm:"TEXT 'deploy_path'"` // remote path for source/compose
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"` // e.g. mokoconsulting/mokogitea
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"` // e.g. git.mokoconsulting.tech
ContainerName string `xorm:"VARCHAR(100) 'container_name'"` // Docker container name
HealthURL string `xorm:"TEXT 'health_url'"` // health check URL after deploy
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"` UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
} }
+1
View File
@@ -165,6 +165,7 @@ type IssueStatusDef struct {
Color string `json:"color"` Color string `json:"color"`
Description string `json:"description"` Description string `json:"description"`
ClosesIssue bool `json:"closes_issue"` ClosesIssue bool `json:"closes_issue"`
IsRequired bool `json:"is_required"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
+36 -7
View File
@@ -1314,6 +1314,7 @@ func Routes() *web.Router {
m.Get("/revisions/*", repo.ListPageRevisions) m.Get("/revisions/*", repo.ListPageRevisions)
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage) m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
m.Get("/pages", repo.ListWikiPages) m.Get("/pages", repo.ListWikiPages)
m.Get("/search", repo.SearchWikiPages)
}, mustEnableWiki) }, mustEnableWiki)
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup) m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown) m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
@@ -1480,12 +1481,10 @@ func Routes() *web.Router {
Delete(reqToken(), repo.DeleteTopic) Delete(reqToken(), repo.DeleteTopic)
}, reqAdmin()) }, reqAdmin())
}, reqAnyRepoReader()) }, reqAnyRepoReader())
m.Combo("/metadata", reqRepoReader(unit.TypeCode)). m.Get("/metadata", repo.GetRepoMetadata)
Get(repo.GetRepoMetadata). m.Put("/metadata", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata) m.Get("/manifest", repo.GetRepoMetadata) // backward compat
m.Combo("/manifest", reqRepoReader(unit.TypeCode)). // backward compat m.Put("/manifest", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
Get(repo.GetRepoMetadata).
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
// MokoGitea badge engine // MokoGitea badge engine
m.Get("/badge/{type}.svg", repo.GetRepoBadge) m.Get("/badge/{type}.svg", repo.GetRepoBadge)
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates) m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
@@ -1860,9 +1859,39 @@ func Routes() *web.Router {
m.Get("/search", repo.TopicSearch) m.Get("/search", repo.TopicSearch)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
// Licensing endpoints — DLID-gated, no token required // Licensing endpoints
m.Group("/licensing", func() { m.Group("/licensing", func() {
// Public (no auth)
m.Get("/updates/{product}", licensing.ServeUpdates) m.Get("/updates/{product}", licensing.ServeUpdates)
m.Get("/validate", licensing.Validate)
m.Get("/download/{product}/{version}", licensing.ServeDownload)
// User self-service (authenticated)
m.Group("/my", func() {
m.Get("/licenses", licensing.MyLicenses)
m.Get("/licenses/{id}/domains", licensing.MyLicenseDomains)
m.Delete("/licenses/{id}/domains/{domain}", licensing.MyDeactivateDomain)
}, reqToken())
// Admin license management
m.Group("/licenses", func() {
m.Get("", licensing.ListLicenses)
m.Post("", licensing.CreateLicense)
m.Get("/{id}", licensing.GetLicense)
m.Patch("/{id}", licensing.UpdateLicense)
m.Delete("/{id}", licensing.DeleteLicense)
}, reqToken(), reqSiteAdmin())
// Admin tier management
m.Group("/tiers", func() {
m.Get("", licensing.ListTiers)
m.Post("", licensing.CreateTier)
m.Patch("/{id}", licensing.UpdateTier)
m.Delete("/{id}", licensing.DeleteTier)
}, reqToken(), reqSiteAdmin())
// Authenticated license detail
m.Get("/{dlid}/status", reqToken(), licensing.Status)
}) })
}, sudo()) }, sudo())
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"fmt"
"io"
"net/http"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
)
// ServeDownload handles GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ
func ServeDownload(ctx *context.APIContext) {
product := ctx.PathParam("product")
versionFile := ctx.PathParam("version")
token := ctx.FormString("token")
expiresStr := ctx.FormString("expires")
dlid := ctx.FormString("dlid")
version, ok := licensing_service.ParseDownloadParams(versionFile)
if !ok {
ctx.APIError(http.StatusBadRequest, "invalid version format")
return
}
expires, ok := licensing_service.ParseExpires(expiresStr)
if !ok || token == "" || dlid == "" {
ctx.APIError(http.StatusForbidden, "missing or invalid download parameters")
return
}
// Verify signed token
if !licensing_service.VerifyDownloadToken(token, product, version, dlid, expires) {
ctx.APIError(http.StatusForbidden, "invalid or expired download token")
return
}
// Verify DLID is still valid
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil || license == nil || !license.IsActive() {
ctx.APIError(http.StatusForbidden, "license invalid or expired")
return
}
// Verify entitlement
has, _ := licensing_model.HasEntitlement(ctx, license.ID, product)
if !has {
ctx.APIError(http.StatusForbidden, "no entitlement for product")
return
}
// Resolve repo from entitlement
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to get entitlements")
return
}
var repoOwner, repoName string
for _, ent := range ents {
if ent.ProductCode == product {
repoOwner = ent.RepoOwner
repoName = ent.RepoName
break
}
}
if repoName == "" {
ctx.APIError(http.StatusNotFound, "product repo not found")
return
}
// Find repo
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
if err != nil || repo == nil {
ctx.APIError(http.StatusNotFound, "repository not found")
return
}
// Find the release with matching version
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list releases")
return
}
var targetRelease *repo_model.Release
for _, rel := range releases {
relVersion := extractVersion(rel.TagName)
if relVersion == version {
targetRelease = rel
break
}
if rel.Title != "" && extractVersion(rel.Title) == version {
targetRelease = rel
break
}
}
if targetRelease == nil {
ctx.APIError(http.StatusNotFound, fmt.Sprintf("release version %s not found", version))
return
}
// Find ZIP attachment
var attachments []*repo_model.Attachment
err = db.GetEngine(ctx).Where("release_id = ?", targetRelease.ID).Find(&attachments)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to get attachments")
return
}
var zipAttachment *repo_model.Attachment
for _, att := range attachments {
if att.Name != "" && len(att.Name) > 4 && att.Name[len(att.Name)-4:] == ".zip" {
zipAttachment = att
break
}
}
if zipAttachment == nil {
ctx.APIError(http.StatusNotFound, "no zip attachment found for release")
return
}
// Log the download
licensing_model.LogLicenseAudit(ctx, license.ID, "download",
product, fmt.Sprintf("%s/%s", version, zipAttachment.Name))
// Serve the file
fr, err := storage.Attachments.Open(zipAttachment.RelativePath())
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to open attachment")
return
}
defer fr.Close()
ctx.Resp.Header().Set("Content-Type", "application/zip")
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipAttachment.Name))
ctx.Resp.WriteHeader(http.StatusOK)
if _, err := io.Copy(ctx.Resp, fr); err != nil {
log.Error("ServeDownload: io.Copy: %v", err)
}
}
+488
View File
@@ -0,0 +1,488 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"encoding/json"
"net/http"
"strconv"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
mojo_json "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// ── Admin: License CRUD ─────────────────────────────────────────────────
type createLicenseRequest struct {
UserID int64 `json:"user_id" binding:"Required"`
Tier string `json:"tier" binding:"Required"`
MaxDomains int `json:"max_domains"`
ExpiresMonths int `json:"expires_months"`
Notes string `json:"notes"`
}
// CreateLicense handles POST /api/v1/licensing/licenses
func CreateLicense(ctx *context.APIContext) {
var req createLicenseRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
// Resolve max_domains from tier if not specified
maxDomains := req.MaxDomains
if maxDomains == 0 {
tier, _ := licensing_model.GetProductTierByKey(ctx, req.Tier)
if tier != nil {
maxDomains = tier.MaxDomains
}
if maxDomains == 0 {
maxDomains = 1
}
}
var expiresAt timeutil.TimeStamp
if req.ExpiresMonths > 0 {
expiresAt = timeutil.TimeStamp(time.Now().AddDate(0, req.ExpiresMonths, 0).Unix())
}
license, err := licensing_model.CreateLicense(ctx, req.UserID, req.Tier, maxDomains, expiresAt)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to create license")
return
}
if req.Notes != "" {
license.Notes = req.Notes
// TODO: update notes field
}
// Build entitlements from tier
if err := licensing_model.RebuildEntitlements(ctx, license.ID, req.Tier); err != nil {
log.Error("CreateLicense: RebuildEntitlements: %v", err)
}
ctx.JSON(http.StatusCreated, licenseToJSON(ctx, license))
}
// ListLicenses handles GET /api/v1/licensing/licenses
func ListLicenses(ctx *context.APIContext) {
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
limit := ctx.FormInt("limit")
if limit <= 0 || limit > 50 {
limit = 20
}
// For now, get all licenses (pagination via offset)
// TODO: add proper pagination to the model layer
var licenses []*licensing_model.License
err := db.GetEngine(ctx).Limit(limit, (page-1)*limit).Find(&licenses)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
return
}
results := make([]map[string]any, 0, len(licenses))
for _, l := range licenses {
results = append(results, licenseToJSON(ctx, l))
}
ctx.JSON(http.StatusOK, results)
}
// GetLicense handles GET /api/v1/licensing/licenses/{id}
func GetLicense(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil {
ctx.APIErrorNotFound()
return
}
result := licenseToJSON(ctx, license)
// Include entitlements
ents, _ := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
entList := make([]map[string]any, 0, len(ents))
for _, e := range ents {
entList = append(entList, map[string]any{
"product_code": e.ProductCode,
"repo_owner": e.RepoOwner,
"repo_name": e.RepoName,
"is_custom": e.IsCustom,
})
}
result["entitlements"] = entList
// Include activations
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
actList := make([]map[string]any, 0, len(acts))
for _, a := range acts {
actList = append(actList, map[string]any{
"domain": a.Domain,
"ip_address": a.IPAddress,
"joomla_ver": a.JoomlaVer,
"activated_at": formatTime(a.ActivatedAt),
"last_seen_at": formatTime(a.LastSeenAt),
})
}
result["activations"] = actList
ctx.JSON(http.StatusOK, result)
}
type updateLicenseRequest struct {
Tier *string `json:"tier"`
Status *string `json:"status"`
MaxDomains *int `json:"max_domains"`
ExpiresAt *string `json:"expires_at"`
Notes *string `json:"notes"`
}
// UpdateLicense handles PATCH /api/v1/licensing/licenses/{id}
func UpdateLicense(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil {
ctx.APIErrorNotFound()
return
}
var req updateLicenseRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
if req.Tier != nil && *req.Tier != license.Tier {
if err := licensing_model.UpdateLicenseTier(ctx, id, *req.Tier); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to update tier")
return
}
license.Tier = *req.Tier
}
if req.Status != nil && *req.Status != license.Status {
if err := licensing_model.SetLicenseStatus(ctx, id, *req.Status); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to update status")
return
}
license.Status = *req.Status
}
// Update simple fields directly
cols := make([]string, 0)
if req.MaxDomains != nil {
license.MaxDomains = *req.MaxDomains
cols = append(cols, "max_domains")
}
if req.Notes != nil {
license.Notes = *req.Notes
cols = append(cols, "notes")
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err == nil {
license.ExpiresAt = timeutil.TimeStamp(t.Unix())
cols = append(cols, "expires_at")
}
}
if len(cols) > 0 {
cols = append(cols, "updated_at")
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(license); err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
}
// DeleteLicense handles DELETE /api/v1/licensing/licenses/{id}
func DeleteLicense(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
if err := licensing_model.RevokeLicense(ctx, id); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to revoke license")
return
}
ctx.Status(http.StatusNoContent)
}
// ── User: Self-service ──────────────────────────────────────────────────
// MyLicenses handles GET /api/v1/licensing/my/licenses
func MyLicenses(ctx *context.APIContext) {
licenses, err := licensing_model.GetLicensesByUser(ctx, ctx.Doer.ID)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
return
}
results := make([]map[string]any, 0, len(licenses))
for _, l := range licenses {
results = append(results, licenseToJSON(ctx, l))
}
ctx.JSON(http.StatusOK, results)
}
// MyLicenseDomains handles GET /api/v1/licensing/my/licenses/{id}/domains
func MyLicenseDomains(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
ctx.APIErrorNotFound()
return
}
acts, err := licensing_model.GetActivationsByLicense(ctx, id)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list domains")
return
}
results := make([]map[string]any, 0, len(acts))
for _, a := range acts {
results = append(results, map[string]any{
"domain": a.Domain,
"activated_at": formatTime(a.ActivatedAt),
"last_seen_at": formatTime(a.LastSeenAt),
})
}
ctx.JSON(http.StatusOK, results)
}
// MyDeactivateDomain handles DELETE /api/v1/licensing/my/licenses/{id}/domains/{domain}
func MyDeactivateDomain(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
ctx.APIErrorNotFound()
return
}
domain := ctx.PathParam("domain")
if err := licensing_model.DeactivateDomain(ctx, id, domain); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to deactivate domain")
return
}
ctx.Status(http.StatusNoContent)
}
// ── Admin: Product Tier CRUD ────────────────────────────────────────────
// ListTiers handles GET /api/v1/licensing/tiers
func ListTiers(ctx *context.APIContext) {
tiers, err := licensing_model.GetAllProductTiers(ctx)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list tiers")
return
}
results := make([]map[string]any, 0, len(tiers))
for _, t := range tiers {
results = append(results, tierToJSON(t))
}
ctx.JSON(http.StatusOK, results)
}
type createTierRequest struct {
TierKey string `json:"tier_key" binding:"Required"`
TierName string `json:"tier_name" binding:"Required"`
Repos []string `json:"repos"`
MaxDomains int `json:"max_domains"`
SortOrder int `json:"sort_order"`
}
// CreateTier handles POST /api/v1/licensing/tiers
func CreateTier(ctx *context.APIContext) {
var req createTierRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
reposJSON, _ := mojo_json.Marshal(req.Repos)
tier := &licensing_model.ProductTier{
TierKey: req.TierKey,
TierName: req.TierName,
Repos: string(reposJSON),
MaxDomains: req.MaxDomains,
SortOrder: req.SortOrder,
}
_, err := db.GetEngine(ctx).Insert(tier)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to create tier")
return
}
ctx.JSON(http.StatusCreated, tierToJSON(tier))
}
type updateTierRequest struct {
TierName *string `json:"tier_name"`
Repos []string `json:"repos"`
MaxDomains *int `json:"max_domains"`
SortOrder *int `json:"sort_order"`
}
// UpdateTier handles PATCH /api/v1/licensing/tiers/{id}
func UpdateTier(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
return
}
tier := new(licensing_model.ProductTier)
has, err := db.GetEngine(ctx).ID(id).Get(tier)
if err != nil || !has {
ctx.APIErrorNotFound()
return
}
var req updateTierRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
cols := make([]string, 0)
if req.TierName != nil {
tier.TierName = *req.TierName
cols = append(cols, "tier_name")
}
if req.Repos != nil {
reposJSON, _ := mojo_json.Marshal(req.Repos)
tier.Repos = string(reposJSON)
cols = append(cols, "repos")
}
if req.MaxDomains != nil {
tier.MaxDomains = *req.MaxDomains
cols = append(cols, "max_domains")
}
if req.SortOrder != nil {
tier.SortOrder = *req.SortOrder
cols = append(cols, "sort_order")
}
if len(cols) > 0 {
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier); err != nil {
ctx.APIErrorInternal(err)
return
}
}
ctx.JSON(http.StatusOK, tierToJSON(tier))
}
// DeleteTier handles DELETE /api/v1/licensing/tiers/{id}
func DeleteTier(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
return
}
// Check if any licenses use this tier
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.APIErrorNotFound()
return
}
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
if count > 0 {
ctx.APIError(http.StatusConflict, "cannot delete tier with active licenses")
return
}
if _, err := db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier)); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ── Helpers ─────────────────────────────────────────────────────────────
func licenseToJSON(ctx *context.APIContext, l *licensing_model.License) map[string]any {
tierName := l.Tier
tier, _ := licensing_model.GetProductTierByKey(ctx, l.Tier)
if tier != nil {
tierName = tier.TierName
}
domainCount, _ := licensing_model.CountActivations(ctx, l.ID)
result := map[string]any{
"id": l.ID,
"user_id": l.UserID,
"dlid": l.DLID,
"tier": l.Tier,
"tier_name": tierName,
"max_domains": l.MaxDomains,
"domains_used": domainCount,
"status": l.Status,
"notes": l.Notes,
"created_at": formatTime(l.CreatedAt),
"updated_at": formatTime(l.UpdatedAt),
}
if l.ExpiresAt > 0 {
result["expires_at"] = formatTime(l.ExpiresAt)
}
return result
}
func tierToJSON(t *licensing_model.ProductTier) map[string]any {
return map[string]any{
"id": t.ID,
"tier_key": t.TierKey,
"tier_name": t.TierName,
"repos": t.RepoList(),
"max_domains": t.MaxDomains,
"sort_order": t.SortOrder,
}
}
func formatTime(ts timeutil.TimeStamp) string {
if ts == 0 {
return ""
}
return time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
}
+5 -3
View File
@@ -15,6 +15,7 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
) )
// Joomla update XML structures. // Joomla update XML structures.
@@ -186,10 +187,11 @@ func ServeUpdates(ctx *context.APIContext) {
displayName = manifest.DerivedDisplayName() displayName = manifest.DerivedDisplayName()
} }
// Build download URL // Build signed download URL
baseURL := setting.AppURL baseURL := setting.AppURL
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s", token, expires := licensing_service.SignDownloadToken(productCode, version, dlid)
baseURL, productCode, version, dlid) downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s&token=%s&expires=%d",
baseURL, productCode, version, dlid, token, expires)
updates := xmlUpdates{ updates := xmlUpdates{
Updates: []xmlUpdate{ Updates: []xmlUpdate{
+194
View File
@@ -0,0 +1,194 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"net/http"
"time"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// validateResponse is the public validation result.
type validateResponse struct {
Valid bool `json:"valid"`
Tier string `json:"tier,omitempty"`
TierName string `json:"tier_name,omitempty"`
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
DomainsUsed int `json:"domains_used,omitempty"`
DomainsMax int `json:"domains_max,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// statusResponse is the full license detail for authenticated callers.
type statusResponse struct {
Valid bool `json:"valid"`
DLID string `json:"dlid"`
Tier string `json:"tier"`
TierName string `json:"tier_name"`
Status string `json:"status"`
Products []string `json:"products"`
DomainsUsed int `json:"domains_used"`
DomainsMax int `json:"domains_max"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// Validate handles GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ
// Public endpoint — no auth required. Returns minimal valid/invalid with reason.
func Validate(ctx *context.APIContext) {
dlid := ctx.FormString("dlid")
product := ctx.FormString("product")
domain := ctx.FormString("domain")
if dlid == "" {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "missing_dlid"})
return
}
if !licensing_model.ValidateDLIDFormat(dlid) {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
return
}
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil {
log.Error("Validate: GetLicenseByDLID: %v", err)
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "internal_error"})
return
}
if license == nil {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
return
}
if license.Status == "revoked" {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "revoked"})
return
}
if license.Status == "suspended" {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "suspended"})
return
}
if license.IsExpired() {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "expired"})
return
}
// Check product entitlement if product is specified
if product != "" {
has, err := licensing_model.HasEntitlement(ctx, license.ID, product)
if err != nil {
log.Error("Validate: HasEntitlement: %v", err)
}
if !has {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "no_entitlement"})
return
}
}
// Check domain limit if domain is specified
if domain != "" {
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
if license.MaxDomains > 0 && domainCount >= int64(license.MaxDomains) {
// Check if this domain is already activated
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
found := false
for _, a := range acts {
if a.Domain == domain {
found = true
break
}
}
if !found {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "domain_limit"})
return
}
}
}
// Look up tier name
tierName := license.Tier
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
if tier != nil {
tierName = tier.TierName
}
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
resp := validateResponse{
Valid: true,
Tier: license.Tier,
TierName: tierName,
Status: license.Status,
DomainsUsed: int(domainCount),
DomainsMax: license.MaxDomains,
}
if license.ExpiresAt > 0 {
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
}
ctx.JSON(http.StatusOK, resp)
}
// Status handles GET /api/v1/licensing/{dlid}/status
// Authenticated endpoint — returns full license detail with entitlement list.
func Status(ctx *context.APIContext) {
dlid := ctx.PathParam("dlid")
if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid DLID format"})
return
}
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil {
log.Error("Status: GetLicenseByDLID: %v", err)
ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if license == nil {
ctx.JSON(http.StatusNotFound, map[string]string{"error": "license not found"})
return
}
// Get entitlements
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
if err != nil {
log.Error("Status: GetEntitlementsByLicense: %v", err)
}
products := make([]string, 0, len(ents))
for _, e := range ents {
products = append(products, e.ProductCode)
}
// Get tier name
tierName := license.Tier
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
if tier != nil {
tierName = tier.TierName
}
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
resp := statusResponse{
Valid: license.IsActive(),
DLID: license.DLID,
Tier: license.Tier,
TierName: tierName,
Status: license.Status,
Products: products,
DomainsUsed: int(domainCount),
DomainsMax: license.MaxDomains,
CreatedAt: time.Unix(int64(license.CreatedAt), 0).UTC().Format(time.RFC3339),
}
if license.ExpiresAt > 0 {
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
}
ctx.JSON(http.StatusOK, resp)
}
+26
View File
@@ -11,6 +11,19 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
) )
// checkOrgVisibility returns true if the current user can view org metadata.
// Public orgs are visible to everyone. Private/limited orgs require authentication.
func checkOrgVisibility(ctx *context.APIContext) bool {
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
return true
}
if ctx.Doer == nil {
ctx.APIErrorNotFound()
return false
}
return true
}
// ListIssueStatuses returns active issue status definitions for an org. // ListIssueStatuses returns active issue status definitions for an org.
func ListIssueStatuses(ctx *context.APIContext) { func ListIssueStatuses(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses // swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
@@ -34,6 +47,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -47,6 +64,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
Color: d.Color, Color: d.Color,
Description: d.Description, Description: d.Description,
ClosesIssue: d.ClosesIssue, ClosesIssue: d.ClosesIssue,
IsRequired: d.IsRequired,
SortOrder: d.SortOrder, SortOrder: d.SortOrder,
}) })
} }
@@ -76,6 +94,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -118,6 +140,10 @@ func ListIssueTypes(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
+73 -23
View File
@@ -32,6 +32,16 @@ type apiMetadata struct {
Language string `json:"language"` Language string `json:"language"`
ExtensionType string `json:"extension_type"` ExtensionType string `json:"extension_type"`
EntryPoint string `json:"entry_point"` EntryPoint string `json:"entry_point"`
// deploy
DeployHost string `json:"deploy_host,omitempty"`
DeployPort string `json:"deploy_port,omitempty"`
DeployUser string `json:"deploy_user,omitempty"`
DeployPath string `json:"deploy_path,omitempty"`
DockerImage string `json:"docker_image,omitempty"`
DockerRegistry string `json:"docker_registry,omitempty"`
ContainerName string `json:"container_name,omitempty"`
HealthURL string `json:"health_url,omitempty"`
} }
// GetRepoMetadata returns the manifest settings for a repository. // GetRepoMetadata returns the manifest settings for a repository.
@@ -81,6 +91,14 @@ func GetRepoMetadata(ctx *context.APIContext) {
Language: m.Language, Language: m.Language,
ExtensionType: m.ExtensionType, ExtensionType: m.ExtensionType,
EntryPoint: m.EntryPoint, EntryPoint: m.EntryPoint,
DeployHost: m.DeployHost,
DeployPort: m.DeployPort,
DeployUser: m.DeployUser,
DeployPath: m.DeployPath,
DockerImage: m.DockerImage,
DockerRegistry: m.DockerRegistry,
ContainerName: m.ContainerName,
HealthURL: m.HealthURL,
}) })
} }
@@ -96,35 +114,59 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
// responses: // responses:
// "200": // "200":
// "$ref": "#/responses/Manifest" // "$ref": "#/responses/Manifest"
var req apiMetadata // Decode into a map to detect which fields were actually sent.
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { var raw map[string]any
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
ctx.APIError(http.StatusBadRequest, err) ctx.APIError(http.StatusBadRequest, err)
return return
} }
m := &repo_model.RepoMetadata{ // Load existing metadata (or create defaults).
RepoID: ctx.Repo.Repository.ID, m, _ := repo_model.GetRepoMetadata(ctx, ctx.Repo.Repository.ID)
Name: req.Name, if m == nil {
Org: req.Org, m = &repo_model.RepoMetadata{
Description: req.Description, RepoID: ctx.Repo.Repository.ID,
Name: ctx.Repo.Repository.Name,
LicenseSPDX: req.LicenseSPDX, Org: ctx.Repo.Repository.OwnerName,
LicenseName: req.LicenseName, Description: ctx.Repo.Repository.Description,
VersionPrefix: req.VersionPrefix, }
ElementName: req.ElementName,
Platform: req.Platform,
StandardsVersion: req.StandardsVersion,
StandardsSource: req.StandardsSource,
Maintainer: req.Maintainer,
MaintainerURL: req.MaintainerURL,
InfoURL: req.InfoURL,
TargetVersion: req.TargetVersion,
PHPMinimum: req.PHPMinimum,
Language: req.Language,
ExtensionType: req.ExtensionType,
EntryPoint: req.EntryPoint,
} }
// Apply only the fields present in the request.
setStr := func(key string, target *string) {
if v, ok := raw[key]; ok {
if s, ok := v.(string); ok {
*target = s
}
}
}
setStr("name", &m.Name)
setStr("org", &m.Org)
setStr("description", &m.Description)
setStr("license_spdx", &m.LicenseSPDX)
setStr("license_name", &m.LicenseName)
setStr("version_prefix", &m.VersionPrefix)
setStr("element_name", &m.ElementName)
setStr("platform", &m.Platform)
setStr("standards_version", &m.StandardsVersion)
setStr("standards_source", &m.StandardsSource)
setStr("maintainer", &m.Maintainer)
setStr("maintainer_url", &m.MaintainerURL)
setStr("info_url", &m.InfoURL)
setStr("target_version", &m.TargetVersion)
setStr("php_minimum", &m.PHPMinimum)
setStr("language", &m.Language)
setStr("extension_type", &m.ExtensionType)
setStr("entry_point", &m.EntryPoint)
setStr("deploy_host", &m.DeployHost)
setStr("deploy_port", &m.DeployPort)
setStr("deploy_user", &m.DeployUser)
setStr("deploy_path", &m.DeployPath)
setStr("docker_image", &m.DockerImage)
setStr("docker_registry", &m.DockerRegistry)
setStr("container_name", &m.ContainerName)
setStr("health_url", &m.HealthURL)
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil { if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -151,5 +193,13 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
Language: m.Language, Language: m.Language,
ExtensionType: m.ExtensionType, ExtensionType: m.ExtensionType,
EntryPoint: m.EntryPoint, EntryPoint: m.EntryPoint,
DeployHost: m.DeployHost,
DeployPort: m.DeployPort,
DeployUser: m.DeployUser,
DeployPath: m.DeployPath,
DockerImage: m.DockerImage,
DockerRegistry: m.DockerRegistry,
ContainerName: m.ContainerName,
HealthURL: m.HealthURL,
}) })
} }
+140 -1
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
@@ -461,10 +462,148 @@ func ListPageRevisions(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount)) ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
} }
// SearchWikiPages searches wiki page titles and content.
func SearchWikiPages(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/wiki/search repository repoSearchWikiPages
// ---
// summary: Search wiki pages
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: q
// in: query
// description: search query
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// description: "SearchResults"
// "404":
// "$ref": "#/responses/notFound"
query := strings.TrimSpace(ctx.FormString("q"))
if query == "" {
ctx.JSON(http.StatusOK, []interface{}{})
return
}
wikiRepo, commit := findWikiRepoCommit(ctx)
if wikiRepo != nil {
defer wikiRepo.Close()
}
if ctx.Written() {
return
}
queryLower := strings.ToLower(query)
type WikiSearchResult struct {
PageName string `json:"page_name"`
PageURL string `json:"page_url"`
Context string `json:"context,omitempty"`
}
entries, err := commit.ListEntriesRecursiveFast()
if err != nil {
ctx.APIErrorInternal(err)
return
}
var results []WikiSearchResult
for _, entry := range entries {
if !entry.IsRegular() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
baseName := strings.TrimSuffix(entry.Name(), ".md")
// Extract just the filename without path for special file check
parts := strings.Split(baseName, "/")
shortName := parts[len(parts)-1]
if shortName == "_Sidebar" || shortName == "_Footer" {
continue
}
blob := entry.Blob()
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err != nil {
continue
}
titleMatch := strings.Contains(strings.ToLower(baseName), queryLower)
contentMatch := strings.Contains(strings.ToLower(content), queryLower)
if !titleMatch && !contentMatch {
continue
}
contextLine := ""
if contentMatch {
for _, line := range strings.Split(content, "\n") {
if strings.Contains(strings.ToLower(line), queryLower) {
contextLine = strings.TrimSpace(line)
if len(contextLine) > 200 {
contextLine = contextLine[:200] + "..."
}
break
}
}
}
wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
if err != nil {
continue
}
_, displayName := wiki_service.WebPathToUserTitle(wikiName)
results = append(results, WikiSearchResult{
PageName: displayName,
PageURL: string(wikiName),
Context: contextLine,
})
}
// Pagination
page := max(ctx.FormInt("page"), 1)
limit := ctx.FormInt("limit")
if limit <= 0 {
limit = setting.API.DefaultPagingNum
}
total := len(results)
start := (page - 1) * limit
end := start + limit
if start > total {
start = total
}
if end > total {
end = total
}
ctx.SetLinkHeader(int64(total), limit)
ctx.SetTotalCountHeader(int64(total))
ctx.JSON(http.StatusOK, results[start:end])
}
// findEntryForFile finds the tree entry for a target filepath. // findEntryForFile finds the tree entry for a target filepath.
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) { func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
entry, err := commit.GetTreeEntryByPath(target) entry, err := commit.GetTreeEntryByPath(target)
if err != nil { if err != nil && !git.IsErrNotExist(err) {
return nil, err return nil, err
} }
if entry != nil { if entry != nil {
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package admin
import (
"net/http"
"strconv"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplLicenseTiers templates.TplName = "admin/license_tiers"
// LicenseTiers shows the product tier management page.
func LicenseTiers(ctx *context.Context) {
ctx.Data["Title"] = "Product Tiers"
ctx.Data["PageIsAdminLicenseTiers"] = true
tiers, err := licensing_model.GetAllProductTiers(ctx)
if err != nil {
ctx.ServerError("GetAllProductTiers", err)
return
}
type tierView struct {
*licensing_model.ProductTier
Repos []string
LicenseCount int64
}
views := make([]tierView, 0, len(tiers))
for _, t := range tiers {
count, _ := db.GetEngine(ctx).Where("tier = ?", t.TierKey).Count(new(licensing_model.License))
views = append(views, tierView{
ProductTier: t,
Repos: t.RepoList(),
LicenseCount: count,
})
}
ctx.Data["Tiers"] = views
ctx.HTML(http.StatusOK, tplLicenseTiers)
}
// LicenseTierCreate handles POST to create a new tier.
func LicenseTierCreate(ctx *context.Context) {
tierKey := ctx.FormString("tier_key")
tierName := ctx.FormString("tier_name")
repos := ctx.FormStrings("repos")
maxDomains, _ := strconv.Atoi(ctx.FormString("max_domains"))
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
if tierKey == "" || tierName == "" {
ctx.Flash.Error("Tier key and name are required")
ctx.Redirect("/admin/license-tiers")
return
}
reposJSON, _ := json.Marshal(repos)
tier := &licensing_model.ProductTier{
TierKey: tierKey,
TierName: tierName,
Repos: string(reposJSON),
MaxDomains: maxDomains,
SortOrder: sortOrder,
}
if _, err := db.GetEngine(ctx).Insert(tier); err != nil {
ctx.Flash.Error("Failed to create tier: " + err.Error())
} else {
ctx.Flash.Success("Tier '" + tierName + "' created")
}
ctx.Redirect("/admin/license-tiers")
}
// LicenseTierUpdate handles POST to update a tier.
func LicenseTierUpdate(ctx *context.Context) {
id, _ := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.NotFound(nil)
return
}
tier.TierName = ctx.FormString("tier_name")
repos := ctx.FormStrings("repos")
reposJSON, _ := json.Marshal(repos)
tier.Repos = string(reposJSON)
tier.MaxDomains, _ = strconv.Atoi(ctx.FormString("max_domains"))
tier.SortOrder, _ = strconv.Atoi(ctx.FormString("sort_order"))
if _, err := db.GetEngine(ctx).ID(id).Cols("tier_name", "repos", "max_domains", "sort_order").Update(tier); err != nil {
ctx.Flash.Error("Failed to update tier: " + err.Error())
} else {
ctx.Flash.Success("Tier '" + tier.TierName + "' updated")
}
ctx.Redirect("/admin/license-tiers")
}
// LicenseTierDelete handles POST to delete a tier.
func LicenseTierDelete(ctx *context.Context) {
id, _ := strconv.ParseInt(ctx.FormString("id"), 10, 64)
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.NotFound(nil)
return
}
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
if count > 0 {
ctx.Flash.Error("Cannot delete tier with active licenses. Reassign licenses first.")
ctx.Redirect("/admin/license-tiers")
return
}
db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
ctx.Flash.Success("Tier '" + tier.TierName + "' deleted")
ctx.Redirect("/admin/license-tiers")
}
+5
View File
@@ -103,6 +103,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
} }
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil { if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
if issues_model.IsErrStatusRequired(err) {
ctx.Flash.Error("Cannot delete required status: " + def.Name)
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
return
}
ctx.ServerError("DeleteIssueStatusDef", err) ctx.ServerError("DeleteIssueStatusDef", err)
return return
} }
+73 -25
View File
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
SubURL string SubURL string
} }
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
type OrgWikiTreeNode struct {
Name string
SubURL string
IsDir bool
Children []*OrgWikiTreeNode
}
// Wiki renders the org wiki tab. // Wiki renders the org wiki tab.
func Wiki(ctx *context.Context) { func Wiki(ctx *context.Context) {
org := ctx.Org.Organization org := ctx.Org.Organization
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
} }
ctx.Data["WikiRepoLink"] = wikiRepo.Link() ctx.Data["WikiRepoLink"] = wikiRepo.Link()
// Build page list from repo root. // Build folder tree for sidebar navigation.
entries, err := commit.ListEntries() wikiTree := buildOrgWikiTree(commit)
if err != nil { ctx.Data["WikiTree"] = wikiTree
ctx.ServerError("ListEntries", err)
return
}
pages := make([]OrgWikiPage, 0, len(entries))
for _, entry := range entries {
if !entry.IsRegular() {
continue
}
name := entry.Name()
if !isMarkdownFile(name) {
continue
}
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
pages = append(pages, OrgWikiPage{
Name: displayName,
SubURL: displayName,
})
}
ctx.Data["Pages"] = pages
// Determine which page to render. // Determine which page to render.
pageName := ctx.PathParamRaw("*") pageName := ctx.PathParamRaw("*")
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplOrgWiki) ctx.HTML(http.StatusOK, tplOrgWiki)
} }
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
// Shows up to 2 levels deep (folders and their immediate children).
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
if commit == nil {
return nil
}
entries, err := commit.ListEntries()
if err != nil {
return nil
}
var topLevel []*OrgWikiTreeNode
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
node := &OrgWikiTreeNode{
Name: name,
SubURL: name,
IsDir: true,
}
// List children of this directory (1 level deep).
subTree := entry.Tree()
if subTree != nil {
children, _ := subTree.ListEntries()
for _, child := range children {
childName := child.Name()
if child.IsDir() {
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: childName,
SubURL: name + "/" + childName,
IsDir: true,
})
} else if isMarkdownFile(childName) {
displayName := strings.TrimSuffix(childName, path.Ext(childName))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: displayName,
SubURL: name + "/" + displayName,
IsDir: false,
})
}
}
}
topLevel = append(topLevel, node)
} else if isMarkdownFile(name) {
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
topLevel = append(topLevel, &OrgWikiTreeNode{
Name: displayName,
SubURL: displayName,
IsDir: false,
})
}
}
return topLevel
}
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit. // findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git). // The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
// Tries fallback repo names (.profile, .github) if the primary doesn't exist. // Tries fallback repo names (.profile, .github) if the primary doesn't exist.
+8
View File
@@ -123,6 +123,14 @@ func saveMetadata(ctx *context.Context) {
manifest.Maintainer = existing.Maintainer manifest.Maintainer = existing.Maintainer
manifest.MaintainerURL = existing.MaintainerURL manifest.MaintainerURL = existing.MaintainerURL
manifest.Language = existing.Language manifest.Language = existing.Language
manifest.DeployHost = existing.DeployHost
manifest.DeployPort = existing.DeployPort
manifest.DeployUser = existing.DeployUser
manifest.DeployPath = existing.DeployPath
manifest.DockerImage = existing.DockerImage
manifest.DockerRegistry = existing.DockerRegistry
manifest.ContainerName = existing.ContainerName
manifest.HealthURL = existing.HealthURL
} }
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil { if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil {
+1111 -66
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -842,6 +842,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/cleanup", admin.CleanupExpiredData) m.Post("/cleanup", admin.CleanupExpiredData)
}, packagesEnabled) }, packagesEnabled)
m.Group("/license-tiers", func() {
m.Get("", admin.LicenseTiers)
m.Post("", admin.LicenseTierCreate)
m.Post("/{id}/delete", admin.LicenseTierDelete)
})
m.Group("/hooks", func() { m.Group("/hooks", func() {
m.Get("", admin.DefaultOrSystemWebhooks) m.Get("", admin.DefaultOrSystemWebhooks)
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook) m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
const (
keyFileName = "licensing_ed25519.key"
downloadTTL = 5 * time.Minute
tokenSeparator = "|"
)
var (
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
keyOnce sync.Once
)
// initKeys loads or generates the ed25519 keypair used for signing download tokens.
func initKeys() {
keyOnce.Do(func() {
keyPath := filepath.Join(setting.AppDataPath, keyFileName)
data, err := os.ReadFile(keyPath)
if err == nil && len(data) == ed25519.SeedSize {
privateKey = ed25519.NewKeyFromSeed(data)
publicKey = privateKey.Public().(ed25519.PublicKey)
log.Info("Licensing: loaded ed25519 key from %s", keyPath)
return
}
// Generate new keypair
seed := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
log.Error("Licensing: failed to generate ed25519 seed: %v", err)
return
}
privateKey = ed25519.NewKeyFromSeed(seed)
publicKey = privateKey.Public().(ed25519.PublicKey)
if err := os.WriteFile(keyPath, seed, 0600); err != nil {
log.Error("Licensing: failed to save ed25519 key to %s: %v", keyPath, err)
} else {
log.Info("Licensing: generated new ed25519 key at %s", keyPath)
}
})
}
// SignDownloadToken creates a signed, time-limited download token.
// The message format is: product|version|dlid|expires
func SignDownloadToken(product, version, dlid string) (token string, expires int64) {
initKeys()
if privateKey == nil {
return "", 0
}
expires = time.Now().Add(downloadTTL).Unix()
message := fmt.Sprintf("%s%s%s%s%s%s%d",
product, tokenSeparator,
version, tokenSeparator,
dlid, tokenSeparator,
expires)
sig := ed25519.Sign(privateKey, []byte(message))
token = base64.RawURLEncoding.EncodeToString(sig)
return token, expires
}
// VerifyDownloadToken validates a signed download token.
// Returns the parsed product, version, dlid, and any error.
func VerifyDownloadToken(token string, product, version, dlid string, expires int64) bool {
initKeys()
if publicKey == nil {
return false
}
// Check expiry
if time.Now().Unix() > expires {
return false
}
// Reconstruct message
message := fmt.Sprintf("%s%s%s%s%s%s%d",
product, tokenSeparator,
version, tokenSeparator,
dlid, tokenSeparator,
expires)
sig, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return false
}
return ed25519.Verify(publicKey, []byte(message), sig)
}
// ParseDownloadParams extracts product and version from the URL path segment.
// Expects format: "{version}.zip" with product as a separate path param.
func ParseDownloadParams(versionFile string) (version string, ok bool) {
if !strings.HasSuffix(versionFile, ".zip") {
return "", false
}
version = strings.TrimSuffix(versionFile, ".zip")
if version == "" {
return "", false
}
return version, true
}
// ParseExpires converts the expires query parameter to int64.
func ParseExpires(s string) (int64, bool) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, false
}
return v, true
}
+101
View File
@@ -0,0 +1,101 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
Product Tiers
<div class="ui right">
<button class="ui primary tiny button" id="btn-new-tier">New Tier</button>
</div>
</h4>
<div class="ui attached segment">
{{if .Tiers}}
<table class="ui very basic striped table">
<thead>
<tr>
<th>Key</th>
<th>Name</th>
<th>Repos</th>
<th>Max Domains</th>
<th>Licenses</th>
<th>Order</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Tiers}}
<tr>
<td><code>{{.TierKey}}</code></td>
<td>{{.TierName}}</td>
<td>
{{range .Repos}}
<span class="ui label">{{.}}</span>
{{end}}
</td>
<td>{{if eq .MaxDomains 0}}Unlimited{{else}}{{.MaxDomains}}{{end}}</td>
<td>{{.LicenseCount}}</td>
<td>{{.SortOrder}}</td>
<td class="right aligned">
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers/{{.ID}}/delete" style="display:inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{.ID}}">
<button class="ui tiny red button{{if gt .LicenseCount 0}} disabled{{end}}" type="submit"
{{if gt .LicenseCount 0}}title="Cannot delete tier with active licenses"{{end}}>
Delete
</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No product tiers defined. Create one to get started.</p>
{{end}}
</div>
<!-- New Tier Form (hidden by default) -->
<div id="new-tier-form" class="ui attached segment" style="display:none">
<h5>Create New Tier</h5>
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers" class="ui form">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="field">
<label>Tier Key</label>
<input type="text" name="tier_key" placeholder="e.g. pos, suite, enterprise" required>
</div>
<div class="field">
<label>Tier Name</label>
<input type="text" name="tier_name" placeholder="e.g. MokoSuite POS" required>
</div>
</div>
<div class="two fields">
<div class="field">
<label>Max Domains (0 = unlimited)</label>
<input type="number" name="max_domains" value="3" min="0">
</div>
<div class="field">
<label>Sort Order</label>
<input type="number" name="sort_order" value="50" min="0">
</div>
</div>
<div class="field">
<label>Repos (comma-separated)</label>
<input type="text" name="repos" placeholder="MokoSuite,MokoSuiteCRM,MokoSuiteERP">
<p class="help">Enter repo names separated by commas</p>
</div>
<button class="ui primary button" type="submit">Create Tier</button>
<button class="ui button" type="button" id="btn-cancel-tier">Cancel</button>
</form>
</div>
</div>
<script>
document.getElementById('btn-new-tier').addEventListener('click', function() {
document.getElementById('new-tier-form').style.display = '';
this.style.display = 'none';
});
document.getElementById('btn-cancel-tier').addEventListener('click', function() {
document.getElementById('new-tier-form').style.display = 'none';
document.getElementById('btn-new-tier').style.display = '';
});
</script>
{{template "admin/layout_footer" .}}
+3
View File
@@ -87,6 +87,9 @@
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding"> <a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
{{svg "octicon-paintbrush" 16}} Branding {{svg "octicon-paintbrush" 16}} Branding
</a> </a>
<a class="{{if .PageIsAdminLicenseTiers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/license-tiers">
{{svg "octicon-key" 16}} License Tiers
</a>
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}> <details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary> <summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
<div class="menu"> <div class="menu">
@@ -28,6 +28,7 @@
</td> </td>
<td> <td>
<strong>{{.Name}}</strong> <strong>{{.Name}}</strong>
{{if .IsRequired}}<span class="ui mini blue label" title="Required status - cannot be deleted">{{svg "octicon-lock" 10}} required</span>{{end}}
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}} {{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}} {{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
</td> </td>
@@ -40,10 +41,14 @@
</td> </td>
<td>{{.SortOrder}}</td> <td>{{.SortOrder}}</td>
<td class="tw-text-right"> <td class="tw-text-right">
{{if .IsRequired}}
<span class="ui tiny icon button disabled" title="Required - cannot be deleted">{{svg "octicon-lock" 14}}</span>
{{else}}
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline"> <form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}} {{$.CsrfTokenHtml}}
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button> <button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
</form> </form>
{{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}
+37 -12
View File
@@ -11,7 +11,7 @@
This organization doesn't have a wiki yet. This organization doesn't have a wiki yet.
</div> </div>
<p class="tw-text-center"> <p class="tw-text-center">
Enable the wiki on the <code>.profile</code> (public) or <code>.profile-private</code> (members-only) Enable the wiki on the <code>.mokogitea</code> (public) or <code>.mokogitea-private</code> (members-only)
repository to get started. repository to get started.
</p> </p>
</div> </div>
@@ -47,34 +47,59 @@
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p> <p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
</div> </div>
</div> </div>
{{if .Pages}} {{if .WikiTree}}
<h4>Available pages:</h4> <h4>Available pages:</h4>
<ul> <ul>
{{range .Pages}} {{range .WikiTree}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li> {{if .IsDir}}
{{range .Children}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
{{end}}
{{else}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
{{end}}
{{end}} {{end}}
</ul> </ul>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<div class="wiki-content-parts"> <div class="wiki-content-parts">
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .Pages}}with-sidebar{{end}}"> <div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
{{.WikiContent}} {{.WikiContent}}
</div> </div>
{{if or .WikiSidebarHTML .Pages}} {{if or .WikiSidebarHTML .WikiTree}}
<div class="render-content markup wiki-content-sidebar"> <div class="render-content markup wiki-content-sidebar">
{{if .WikiSidebarHTML}} {{if .WikiSidebarHTML}}
{{.WikiSidebarHTML}} {{.WikiSidebarHTML}}
<div class="ui divider"></div> {{else if .WikiTree}}
{{end}}
{{if .Pages}}
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong> <strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
<ul class="wiki-tree-list"> <ul class="wiki-tree-list">
{{range .Pages}} {{range .WikiTree}}
<li> <li>
{{svg "octicon-file" 14}} {{if .IsDir}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a> <details open>
<summary>{{svg "octicon-file-directory" 14}} <strong>{{.Name}}</strong></summary>
{{if .Children}}
<ul>
{{range .Children}}
<li>
{{if .IsDir}}
{{svg "octicon-file-directory" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li>
{{end}}
</ul>
{{end}}
</details>
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li> </li>
{{end}} {{end}}
</ul> </ul>
+1 -5
View File
@@ -21,11 +21,7 @@
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization"> <input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
</div> </div>
</div> </div>
<div class="four fields"> <div class="three fields">
<div class="field">
<label>Version</label>
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
</div>
<div class="field"> <div class="field">
<label>Version Prefix</label> <label>Version Prefix</label>
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko."> <input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
+42
View File
@@ -0,0 +1,42 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row">
<div class="tw-flex tw-items-center tw-gap-2">
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}">
{{svg "octicon-arrow-left" 14}} Back to {{.title}}
</a>
</div>
</div>
<h2>{{svg "octicon-cross-reference" 20}} What links here: {{.title}}</h2>
{{if .Backlinks}}
<div class="ui relaxed divided list">
{{range .Backlinks}}
<div class="item">
<div class="content">
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{if .Context}}
<div class="description">
<code class="tw-text-sm">{{.Context}}</code>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
<p class="tw-mt-4 text grey">{{.BacklinkCount}} {{if eq .BacklinkCount 1}}page{{else}}pages{{end}} linking here.</p>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-unlink" 48}}
<br>
No pages link to "{{.title}}"
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+36
View File
@@ -0,0 +1,36 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<a class="ui small button" href="{{.RepoLink}}/wiki/">
{{svg "octicon-arrow-left" 14}} Back to wiki
</a>
</div>
</div>
<h2>{{svg "octicon-tag" 20}} Category: {{.CategoryName}}</h2>
{{if .CategoryPages}}
<div class="ui relaxed divided list">
{{range .CategoryPages}}
<div class="item">
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
</div>
{{end}}
</div>
<p class="tw-mt-4 text grey">{{.CategoryCount}} {{if eq .CategoryCount 1}}page{{else}}pages{{end}} in this category.</p>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-tag" 48}}
<br>
No pages in category "{{.CategoryName}}"
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+51
View File
@@ -0,0 +1,51 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<a href="{{.RepoLink}}/wiki/{{.PageURL}}">{{svg "octicon-arrow-left" 14}} {{.title}}</a>
&middot;
<a href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision">Revision history</a>
</div>
</div>
<div class="ui segment">
<h3>{{svg "octicon-diff" 20}} Changes in <code>{{.CommitID}}</code></h3>
<p>
<strong>{{.CommitAuthor}}</strong> &mdash; {{.CommitMessage}}
<br>
<small class="text grey">{{DateUtils.TimeSince .CommitWhen}}</small>
</p>
{{if .IsNewPage}}
<div class="ui info message">New page created</div>
{{else if .IsDeletedPage}}
<div class="ui warning message">Page deleted</div>
{{else if not .HasDiff}}
<div class="ui info message">No content changes in this revision</div>
{{end}}
{{if .HasDiff}}
<div class="diff-file-box" style="overflow-x: auto;">
<table class="chroma" style="width: 100%; border-collapse: collapse; font-family: monospace; font-size: 13px;">
{{range .DiffLines}}
<tr class="{{if eq .Type "add"}}diff-line-add{{else if eq .Type "del"}}diff-line-del{{else}}diff-line-context{{end}}">
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
{{if .OldNum}}{{.OldNum}}{{end}}
</td>
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
{{if .NewNum}}{{.NewNum}}{{end}}
</td>
<td style="padding: 0 8px; white-space: pre-wrap; word-break: break-all; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #fff;{{end}}">
{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}} {{.Content}}
</td>
</tr>
{{end}}
</table>
</div>
{{end}}
</div>
</div>
</div>
{{template "base/footer" .}}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
color: #333;
line-height: 1.6;
}
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 85%; }
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f6f8fa; }
img { max-width: 100%; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
a { color: #0366d6; }
@media print {
body { padding: 0; }
a { color: inherit; text-decoration: none; }
}
</style>
</head>
<body>
<h1>{{.Title}}</h1>
{{.WikiContentHTML}}
<hr>
<p style="font-size: 12px; color: #999;">
Printed from wiki &middot; {{.Title}}
</p>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<h2>{{svg "octicon-history" 20}} Recent changes</h2>
</div>
<a class="ui small button" href="{{.RepoLink}}/wiki/">
{{svg "octicon-arrow-left" 14}} Back to wiki
</a>
</div>
{{if .RecentChanges}}
<table class="ui compact table">
<thead>
<tr>
<th>Page</th>
<th>Author</th>
<th>Edit summary</th>
<th>When</th>
<th>Commit</th>
</tr>
</thead>
<tbody>
{{range .RecentChanges}}
<tr>
<td>
{{if .PageURL}}
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{else if .PageName}}
{{svg "octicon-file" 14}} {{.PageName}}
{{else}}
<span class="text grey">—</span>
{{end}}
</td>
<td>{{.Author}}</td>
<td class="gt-ellipsis" style="max-width: 400px;">{{.Message}}</td>
<td>{{DateUtils.TimeSince .When}}</td>
<td><code class="tw-text-xs">{{.SHA}}</code></td>
</tr>
{{end}}
</tbody>
</table>
<div class="tw-flex tw-justify-between tw-mt-4">
{{if .HasPrevPage}}
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "-" 1}}">
{{svg "octicon-chevron-left" 14}} Newer
</a>
{{else}}
<span></span>
{{end}}
{{if .HasNextPage}}
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "+" 1}}">
Older {{svg "octicon-chevron-right" 14}}
</a>
{{end}}
</div>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-history" 48}}
<br>
No recent changes
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+52
View File
@@ -0,0 +1,52 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row">
<div class="tw-flex tw-items-center tw-gap-2">
<a class="ui small button" href="{{.RepoLink}}/wiki/">
{{svg "octicon-arrow-left" 14}} Back to wiki
</a>
</div>
</div>
<h2>{{svg "octicon-search" 20}} Wiki search</h2>
<form class="ui form tw-mb-4" action="{{.RepoLink}}/wiki/" method="get">
<input type="hidden" name="action" value="_search">
<div class="ui action input tw-w-full">
<input type="text" name="q" value="{{.Query}}" placeholder="Search wiki pages..." autofocus>
<button class="ui primary button" type="submit">{{svg "octicon-search" 14}} Search</button>
</div>
</form>
{{if .Query}}
{{if .Results}}
<div class="ui relaxed divided list">
{{range .Results}}
<div class="item">
<div class="content">
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{if .Context}}
<div class="description">
<code class="tw-text-sm">{{.Context}}</code>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
<p class="tw-mt-4 text grey">{{.ResultCount}} {{if eq .ResultCount 1}}result{{else}}results{{end}} for "{{.Query}}"</p>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-search" 48}}
<br>
No results for "{{.Query}}"
</div>
</div>
{{end}}
{{end}}
</div>
</div>
{{template "base/footer" .}}
+33 -2
View File
@@ -20,6 +20,10 @@
</div> </div>
<div class="scrolling menu"> <div class="scrolling menu">
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a> <a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_search">{{svg "octicon-search" 14}} Search wiki</a>
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_recent">{{svg "octicon-history" 14}} Recent changes</a>
t <a class="item muted" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_print" target="_blank">{{svg "octicon-browser" 14}} Print view</a>
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_export&format=zip">{{svg "octicon-download" 14}} Export wiki (ZIP)</a>
<div class="divider"></div> <div class="divider"></div>
{{range .Pages}} {{range .Pages}}
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a> <a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
@@ -34,6 +38,8 @@
<div class="flex-text-block tw-flex-wrap tw-justify-end"> <div class="flex-text-block tw-flex-wrap tw-justify-end">
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]"> <div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a> <a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
<a class="ui basic button tw-px-3" title="What links here" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_backlinks">{{svg "octicon-cross-reference"}}</a>
{{if .LastCommitID}}<a class="ui basic button tw-px-3" title="View last change" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_diff&commit={{.LastCommitID}}">{{svg "octicon-diff"}}</a>{{end}}
<div class="tw-flex-1 gt-ellipsis"> <div class="tw-flex-1 gt-ellipsis">
{{$title}} {{$title}}
<div class="ui sub header gt-ellipsis"> <div class="ui sub header gt-ellipsis">
@@ -47,7 +53,7 @@
<a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a> <a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a>
<a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a> <a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a>
{{end}} {{end}}
{{if and .CanWriteWiki (not .Repository.IsMirror)}} {{if and .CanWriteWiki (not .Repository.IsMirror) (not .WikiFolderProtected)}}
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a> <a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a>
<a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a> <a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a>
<a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a> <a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a>
@@ -69,6 +75,12 @@
{{end}} {{end}}
{{end}} {{end}}
{{if .WikiFolderProtected}}
<div class="ui warning message">
<p>{{svg "octicon-lock" 14}} This page is in a protected folder. Only users with the required role can edit it.</p>
</div>
{{end}}
{{if .FormatWarning}} {{if .FormatWarning}}
<div class="ui negative message"> <div class="ui negative message">
<p>{{.FormatWarning}}</p> <p>{{.FormatWarning}}</p>
@@ -103,13 +115,30 @@
<div class="wiki-content-parts"> <div class="wiki-content-parts">
{{if .WikiSidebarTocHTML}} {{if .WikiSidebarTocHTML}}
<div class="render-content markup wiki-content-sidebar wiki-content-toc"> <div class="render-content markup wiki-content-sidebar wiki-content-toc">
{{.WikiSidebarTocHTML}} <details open>
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
{{.WikiSidebarTocHTML}}
</details>
</div> </div>
{{end}} {{end}}
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}"> <div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{if .WikiInlineTocHTML}}
<details open class="wiki-toc-inline tw-mb-4">
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
{{.WikiInlineTocHTML}}
</details>
{{end}}
{{.WikiContentHTML}} {{.WikiContentHTML}}
{{if .WikiCategories}}
<div class="tw-mt-4 tw-pt-2" style="border-top: 1px solid var(--color-secondary);">
{{svg "octicon-tag" 14}} Categories:
{{range .WikiCategories}}
<a class="ui small label" href="{{$.RepoLink}}/wiki/?action=_category&name={{.}}">{{.}}</a>
{{end}}
</div>
{{end}}
</div> </div>
{{if .WikiTree}} {{if .WikiTree}}
@@ -121,6 +150,7 @@
{{if .IsDir}} {{if .IsDir}}
{{svg "octicon-file-directory" 14}} {{svg "octicon-file-directory" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
{{if .Children}} {{if .Children}}
<ul> <ul>
{{range .Children}} {{range .Children}}
@@ -128,6 +158,7 @@
{{if .IsDir}} {{if .IsDir}}
{{svg "octicon-file-directory" 14}} {{svg "octicon-file-directory" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
{{else}} {{else}}
{{svg "octicon-file" 14}} {{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
+31
View File
@@ -86,3 +86,34 @@
max-width: unset; max-width: unset;
} }
} }
/* Wikilinks: red links for non-existent pages */
.wiki .wiki-link-new {
color: var(--color-red);
}
.wiki .wiki-link-new:hover {
color: var(--color-red);
text-decoration: underline;
}
/* Wiki inline ToC */
.wiki .wiki-toc-inline {
border: 1px solid var(--color-secondary);
border-radius: 4px;
padding: 8px 16px;
background: var(--color-box-body);
}
.wiki .wiki-toc-inline summary {
cursor: pointer;
user-select: none;
}
/* Sticky sidebar ToC */
.wiki .wiki-content-toc {
position: sticky;
top: 16px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}