From 0deb44d679600b71d7e4d98c60cbc3e0c8a04e65 Mon Sep 17 00:00:00 2001 From: repeat98 Date: Sun, 25 Oct 2026 08:20:16 +0100 Subject: [PATCH] Improvement: safeguards for automatic releases, the review queue and community expectations - Author auto-release: an update that names a 32-bit address its published source never named goes to owner review, with the reason posted on the PR. - The owner is mentioned on every automatic release, with the exact diff. - Followers hear about an automatic release 48 hours after it goes live (authorRelease in module-releases.json, notify_after hold, migration 0082); a newer version published during the hold replaces the held one. - Hourly PR triage: red PRs get needs-work and leave the review queue, close after 14 quiet days; a second module PR from someone without a published module waits as one-at-a-time. - PR template keeps only what no check can prove (18 boxes to 6). - Report forms, forum tips and community rules say what to expect from module authors and how moderation escalates; CODE_OF_CONDUCT.md for GitHub. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 37 ++++------ .github/workflows/pages.yml | 4 + .github/workflows/pr-triage.yml | 30 ++++++++ CODE_OF_CONDUCT.md | 9 +++ changelog/2026-10-25-maintainer-safeguards.md | 1 + docs/COMMUNITY_OPERATIONS.md | 11 +++ docs/DEVELOPER_WORKFLOW.md | 1 + docs/FORUM.md | 2 +- docs/MODULE_AUTHOR_UPDATES.md | 10 ++- migrations/0082_author_release_hold.sql | 3 + scripts/author-update-git.mjs | 9 +++ scripts/author-update-git.test.mjs | 15 +++- scripts/auto-merge-author-update.mjs | 17 ++++- scripts/module-releases.ts | 3 +- scripts/pr-triage.mjs | 73 +++++++++++++++++++ scripts/pr-triage.test.mjs | 26 +++++++ scripts/sync-module-releases.mjs | 2 +- scripts/verify-merge-approval.mjs | 3 + server/module-updates.ts | 10 ++- src/community/BugReportNotice.tsx | 4 + src/community/ConfigurationReport.tsx | 3 +- src/community/DigiIssueReport.tsx | 3 +- src/community/ForumPage.tsx | 2 +- src/community/IssueReport.tsx | 3 +- src/community/module-release-contract.ts | 8 +- .../module-update-notifications.test.ts | 17 +++++ src/legal/LegalPage.tsx | 4 +- 27 files changed, 269 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/pr-triage.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 changelog/2026-10-25-maintainer-safeguards.md create mode 100644 migrations/0082_author_release_hold.sql create mode 100644 scripts/pr-triage.mjs create mode 100644 scripts/pr-triage.test.mjs diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0d71bab77..3e8857926 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,36 +1,29 @@ -**A fix or improvement outside a module?** Title it `Fix: …`, `Improvement: …` or `Docs: …` (it is labelled from that), keep it to one change, delete everything below and answer three questions in plain words: +**A fix or improvement outside a module?** Title it `Fix: …`, `Improvement: …` or `Docs: …` (it is labelled from that), keep it to one change, delete everything below and answer four questions in plain words: - **What was wrong or hard:** - **What this changes:** - **How I checked it:** (`npm run pr:ready` and anything else you ran) - **What the guides lacked or got wrong, and where I fixed it:** ("nothing" is a fine answer) -**A module or module update:** describe the change in behavior, module version(s), compatibility and source/media attribution. Then say what the guides lacked or got wrong while you built it and link the `Docs:` pull request that fixes it, or write "nothing"; the module release itself stays free of guide edits. +**A module or module update:** describe the change in behaviour, module version(s), compatibility and source/media attribution. Then say what the guides lacked or got wrong while you built it and link the `Docs:` pull request that fixes it, or write "nothing"; the module release itself stays free of guide edits. -Existing module authors can opt into [automatic publication](../docs/MODULE_AUTHOR_UPDATES.md) after reviewing all evidence: +The checks do the rest. `npm run pr:ready` (a registered author: `npm run pr:ready -- --author `) runs what CI runs: the module contract, version and release notes, compatibility and UI claims, resource and qualification records, documentation and licences. A pull request with a red check is labelled `needs-work` and leaves the review queue until it is green; after 14 days without a new commit it is closed, and you can reopen it once it is green. Until your first module is published, one module pull request is reviewed at a time. + +No check can prove these, so you vouch for them: + +- [ ] I read the guide for its category in `docs/module-guides/` (and `sequencing.md` if it acts in time), and TESTING.md lists what I did not test. +- [ ] Source and media are mine or properly licensed, with authors and full licence texts kept. Nothing comes from Elektron firmware: no extracted code or tables, upgrade files or factory content. +- [ ] Screenshots are real captures of the current module UI. Hardware results are what a named person saw on this exact build; emulator results are labelled as emulator results. + +Registered module authors can opt into [automatic publication](../docs/MODULE_AUTHOR_UPDATES.md) after reviewing all the evidence. An update that reaches firmware or hardware addresses the published version did not use goes to owner review instead, and followers hear about an automatic release 48 hours after it goes live: - [ ] Automatically merge and publish my module update after checks pass. - [ ] I verified the source, licences, UI and qualification reports, including instance isolation and Part/project/reboot persistence where applicable; no required acceptance check is failed or untested. -- [ ] New or changed module: I read the guide for its category in `docs/module-guides/` (and `sequencing.md` if it acts in time), walked its checklists, and TESTING.md lists what I did not test. `npm run module:doctor -- ` is green. -- [ ] The module builds, installs and works when it is the only module chosen. It depends on no other module: no `requires`, no hooks into another module, not stacked on another module's pull request. Its key gestures and stock screens are in `compatibility.uiClaims` (`[]` for none). -- [ ] Every code change to a module has a greater semantic version and exact catalog pin (documentation and media edits need none). -- [ ] Source and media are original or properly licensed; authors and full licence texts are preserved. -- [ ] No Elektron firmware, extracted routines/tables, upgrade files or other infringing material is included. -- [ ] Every new/changed module with OT UI has real screenshots of its location and relevant control pages, manifest `access` instructions, and `media[].otUi` version/build/setup provenance. Automatic USB modules without OT UI use the narrow `access.noUiReason` declaration for reviewer verification. -- [ ] I verified the captions, button/menu steps, screenshot authenticity and page coverage against the current module UI. -- [ ] TESTING.md records commands, results, exact source, conditions and limitations; emulator and hardware evidence are separate. -- [ ] CPU, DSP core and memory gauges are populated in `resources.impact` with rough load tiers, workload, rationale and source records. Reviewer: I checked the estimates; they do not imply exact hardware headroom percentages. -- [ ] Runtime/resource changes and new modules: `tests.qualification` contains worst-case cycle counts for every processor used, under parameter extremes, simultaneous modulation, mode changes and maximum load, within the declared real-time budget. -- [ ] Runtime/resource changes and new modules: exact memory regions include code, state, tables, buffers, stack/heap, padding and shared allocations; words, word widths, bytes and maximum-instance totals agree with the native allocation/build report. -- [ ] Runtime/resource changes and new modules: this version/source/build has attributed real-hardware test evidence verified by the reviewer (the owner for first releases; the registered author for scoped updates). Record tester, date, source/build identity, observed behavior, workload and limitations. A reported functional test must stay labelled as reported; unknown model, duration and maximum load must stay explicit. -- [ ] Reviewer: for new modules and runtime/resource changes, I verified the actual cycle/memory reports, coverage of modulation and maximum load, and attributed hardware evidence and its actual coverage against the submitted source/build. A declaration or emulator-only result is insufficient. -- [ ] Complete README/TESTING/licence/control/compatibility documentation, at least three practical tutorial steps and real black-and-white PNG screenshots document the current behavior with honest version/build provenance; README and the applicable qualification/retained-evidence documentation record are synchronized. Yellow captures do not qualify. -- [ ] Reviewer: I verified tutorial usefulness, all relevant control/page coverage, screenshot authenticity, exact access instructions and full documentation completeness. -- [ ] I rebased onto current main and ran module/version checks on this revision. +Reviewer (the owner for a first release, the registered author for their own update): -Validation commands and results: +- [ ] I checked the cycle and memory reports against the worst case, the attributed hardware evidence against the submitted source and build, and the screenshots, tutorial and access steps against the current UI. -For an editorial-only update, use `tests.retainedEvidence` referencing the original published version in approved main history. CI compares protected source, defaults, compatibility, resource claims, reports and shared runtime/build inputs before permitting reuse; preserve original measurement/hardware status and capture provenance. All version, documentation and media/rights requirements still apply; reviewer verification follows the author/owner scope above. +Validation commands and results: -Owner merge or the verified author-release workflow approves the exact source version. Link issues with “Related to #...”; close them only after successful deployment and live download verification. Failed source builds retain the previous publication. Metadata validation and successful assembly are not hardware qualification. +Link issues with "Related to #…"; they are closed after the release is live and its download is verified. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6a64b95a1..77664c08d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -21,6 +21,7 @@ jobs: actions: read outputs: modules: ${{ steps.changes.outputs.modules }} + author-modules: ${{ steps.approval.outputs.author-modules }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -30,6 +31,7 @@ jobs: with: node-version: 24 - name: Verify owner or scoped module-author approval for this exact source commit + id: approval run: node scripts/verify-merge-approval.mjs env: GITHUB_TOKEN: ${{ github.token }} @@ -137,6 +139,8 @@ jobs: - run: npm run licenses:check && npm run machines:check && npm run modules:check && npm run elekloader:check && npm run sdk:check - run: npm run build env: + # Followers hear about an author's own release 48 hours after it goes live (server/module-updates.ts). + MODWERK_AUTHOR_RELEASE_MODULES: ${{ needs.approval.outputs.author-modules }} VITE_COMMUNITY_API_URL: ${{ vars.COMMUNITY_API_URL }} VITE_REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 000000000..428143d0f --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -0,0 +1,30 @@ +name: Triage pull requests +# Keeps red and queued pull requests out of the owner's review list (scripts/pr-triage.mjs). +# Checks out trusted main only and reads PR metadata through the API: no PR code, dependency or cache. +on: + schedule: + - cron: '41 * * * *' + workflow_dispatch: +permissions: + contents: read + pull-requests: write + issues: write + checks: read +concurrency: + group: pr-triage + cancel-in-progress: false +jobs: + triage: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: node scripts/pr-triage.mjs + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..b5ec73f3d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of conduct + +The [Modwerk community rules](https://modwerk.app/#community-rules) apply here too: in issues, pull requests and reviews. + +- Modules are free and experimental, and most are made by one person in their spare time. Authors and maintainers owe no fix, feature or reply time. +- Criticise the work, not the person. A change to someone else's module needs its author's agreement. +- Credit the people whose work you build on, and keep licences intact. + +For rude or hostile behaviour the usual steps are a reminder, then hidden comments, then a temporary block, then a permanent one. Threats, harassment and unlawful content can skip steps. Report a problem to [support@modwerk.app](mailto:support@modwerk.app). diff --git a/changelog/2026-10-25-maintainer-safeguards.md b/changelog/2026-10-25-maintainer-safeguards.md new file mode 100644 index 000000000..2a1224e58 --- /dev/null +++ b/changelog/2026-10-25-maintainer-safeguards.md @@ -0,0 +1 @@ +Module releases and community: a registered author's automatic release now goes to owner review when it reaches firmware or hardware addresses its published version never used, the owner is told about every automatic release, and followers hear about one 48 hours after it goes live, so a bad or hijacked update can be replaced first. Pull requests with red checks leave the review queue (`needs-work`, closed after 14 quiet days, reopen any time), the pull request template keeps only what no check can prove, and the report forms, forum tips and community rules now say what to expect from module authors and how moderation escalates. diff --git a/docs/COMMUNITY_OPERATIONS.md b/docs/COMMUNITY_OPERATIONS.md index 6ff4d4e08..55fe8e6f7 100644 --- a/docs/COMMUNITY_OPERATIONS.md +++ b/docs/COMMUNITY_OPERATIONS.md @@ -92,6 +92,17 @@ For suspected credential compromise, pause registrations and affected writes thr Developer OAuth setup and report-consent boundaries are documented in [DEVELOPER_WORKSPACE.md](DEVELOPER_WORKSPACE.md). Rotating the GitHub OAuth client secret invalidates developer sessions; claims are retained for independent revocation/review. GitHub-only identities use the support contact for removal requests rather than a password-based member request. +## Review queue and saved replies + +`.github/workflows/pr-triage.yml` keeps red and queued pull requests out of the owner's list (see [Make it mergeable](DEVELOPER_WORKFLOW.md#make-it-mergeable)). The owner's review list is `is:pr is:open draft:false -label:needs-work -label:one-at-a-time`. `PR_TRIAGE_DRY_RUN=1 GITHUB_REPOSITORY=repeat98/modwerk GITHUB_TOKEN=$(gh auth token) node scripts/pr-triage.mjs` prints what the next run would do without writing. + +Steps for rude or hostile behaviour are in the community rules: a reminder, hidden content, a temporary suspension, then a permanent one. Threats, harassment and unlawful content can skip steps. These replies keep a rule from sounding like a brush-off; adapt them, keep them short and first-person: + +- **Someone else's module:** "Thanks for working on this. Changes to a module go through its author, so please open this as a suggestion on their module, or ask them first and send it with their OK. If you want to take it in another direction, a module of your own is welcome: Start developing walks you through it." +- **A demand for a fix or feature:** "Thanks for the report. Modules are free and made in spare time, so there is no fix date. The steps and log you gave are exactly what helps whoever picks it up." +- **Rude message (first step):** "I want to help with this, and I will once we keep it about the module. The community rules ask everyone to criticise the work, not the person." +- **Duplicate report:** "This is the same problem as #N, so I am closing this one to keep the discussion in one place. Please add anything new there." + ## Administrator role A verified, unsuspended member whose `users.is_admin` is `1` sees the Admin workspace and passes every `/api/admin/*` check; nobody else sees it, and the page tells other visitors it is for administrators only. The role cannot be set through the API. The owner grants or removes it directly in D1 (`UPDATE users SET is_admin=1 WHERE username='…'`). Migration 0022 adds the column and must be applied **before** the Worker that reads it is deployed. The separate administrator key (`ADMIN_KEY_SHA256`, `POST /api/auth/admin`) still works for API access as break-glass access, but the website no longer offers a key form. diff --git a/docs/DEVELOPER_WORKFLOW.md b/docs/DEVELOPER_WORKFLOW.md index aa1f18d70..962847690 100644 --- a/docs/DEVELOPER_WORKFLOW.md +++ b/docs/DEVELOPER_WORKFLOW.md @@ -53,6 +53,7 @@ These stumbling blocks have each cost a contributor and the owner a repair round - **Merged before green.** The `main` ruleset requires the `module-pr-ok` check, which passes only when every other PR check passed or was skipped by scope. The owner approves a first-time contributor's run and turns on auto-merge, and GitHub merges once it is green; a red or unfinished PR does not merge. - **Compiled packages.** The `octatrack-source` and `elemod-source` jobs rebuild packages in the pinned Docker toolchain and require the committed ones to match. Reproduce them before you push: `bash scripts/build-modules-isolated.sh ` for the Octatrack, `bash scripts/build-elemod-isolated.sh` and `node scripts/verify-elemod-release-exports.mjs ` for the Digitakt and Digitone, from a clean, committed tree. - **Local Python helpers.** Use `python3 -B` or `PYTHONDONTWRITEBYTECODE=1`: bytecode caches under preserved imports fail the import-integrity check, even when the authored source is unchanged. +- **Red checks leave the review queue.** An hourly job labels a pull request with a failing check `needs-work` and comments once; the label goes away when the checks are green. A PR still red 14 days after the label, without a new commit, is closed; reopen it once `pr:ready` says Ready. Until your first module is published, a second open module PR is labelled `one-at-a-time` and waits for the first. - **Your first PR waits for the owner.** GitHub holds a first-time contributor's CI runs until the owner approves them, after every push. Run `pr:ready` locally and push once, rather than often, so each approval counts. diff --git a/docs/FORUM.md b/docs/FORUM.md index 5da8a6299..a31752190 100644 --- a/docs/FORUM.md +++ b/docs/FORUM.md @@ -125,7 +125,7 @@ One private `notifications` store (migration 0024, which replaces `forum_notific Module pages offer **Get update notifications** and **Following updates · Unfollow**. This follows releases independently of discussion replies. The report form offers a checked release-follow option; declining it leaves an existing follow alone. Migration 0034 follows modules reported by existing verified members, starting at the first observed live version without replaying historical releases. Account export includes these follows; account deletion removes them. -Vite publishes `module-releases.json` with available Octatrack, Digitakt and Digitone module IDs, versions, names, page links and required version-matched changelogs. After a successful frontend deployment, the Pages job calls `POST /api/module-releases/sync` using its short-lived GitHub OIDC identity. The Worker accepts only the configured immutable repository/owner IDs, the main-branch Pages workflow and its `github-pages` environment, and re-reads this inventory from `APP_URL`. Its hash must match the inventory built by that deployment before follower notifications are queued; rollout delays are retried. This makes bell updates available after publication while the hourly Worker retains the same inventory sync as a fallback. A merged PR or failed deployment cannot announce an unavailable release. The deployment hook never sends email; emails keep each member's existing module follows, opt-outs, topic choices, daily module batching and activity digest frequency. It rejects new versions with missing or invalid notes before advancing release state or followers. Published module versions automatically notify their followers, including eligible prior downloaders. Update email is enabled by default and uses the existing daily module digest; members can unfollow a module or opt out of update/activity email. No per-release announcement opt-in is required, and legacy `announce` flags do not suppress notifications. Equal-version media or documentation edits do not replay an update, and superseded inventories never announce an older release. Before pushing a module update, the coding agent must tell the maintainer in chat that successful publication will notify followers, including by email according to their preferences; a push alone does not send mail. Existing subscriptions and opt-outs remain intact. The first poll establishes a baseline for historical reports. Fanout is bounded and retry-safe, and paused Octatrack modules are omitted. Update emails include the full authored change list and limitations in HTML and plain text, with a link to the Changelog tab. Migration 0062 saves the deployed notes alongside each version so delayed digests still describe that version after newer releases appear. Equal-version polls can fill missing legacy notes without replaying notifications; older pending entries without saved notes retain their module link. A new release says that an update is available; it does not imply that a particular bug is fixed. Status notifications are inserted atomically with local status changes; repeats and GitHub echoes of a local change stay quiet. +Vite publishes `module-releases.json` with available Octatrack, Digitakt and Digitone module IDs, versions, names, page links and required version-matched changelogs. After a successful frontend deployment, the Pages job calls `POST /api/module-releases/sync` using its short-lived GitHub OIDC identity. The Worker accepts only the configured immutable repository/owner IDs, the main-branch Pages workflow and its `github-pages` environment, and re-reads this inventory from `APP_URL`. Its hash must match the inventory built by that deployment before follower notifications are queued; rollout delays are retried. This makes bell updates available after publication while the hourly Worker retains the same inventory sync as a fallback. A merged PR or failed deployment cannot announce an unavailable release. The deployment hook never sends email; emails keep each member's existing module follows, opt-outs, topic choices, daily module batching and activity digest frequency. It rejects new versions with missing or invalid notes before advancing release state or followers. Published module versions automatically notify their followers, including eligible prior downloaders. A registered author's automatic release carries `authorRelease` in the inventory; migration 0082 stores a 48-hour `notify_after` hold the first time it is seen, and its followers are queued by the first sync after that ([safeguards](MODULE_AUTHOR_UPDATES.md#prepare-the-release)). Update email is enabled by default and uses the existing daily module digest; members can unfollow a module or opt out of update/activity email. No per-release announcement opt-in is required, and legacy `announce` flags do not suppress notifications. Equal-version media or documentation edits do not replay an update, and superseded inventories never announce an older release. Before pushing a module update, the coding agent must tell the maintainer in chat that successful publication will notify followers, including by email according to their preferences; a push alone does not send mail. Existing subscriptions and opt-outs remain intact. The first poll establishes a baseline for historical reports. Fanout is bounded and retry-safe, and paused Octatrack modules are omitted. Update emails include the full authored change list and limitations in HTML and plain text, with a link to the Changelog tab. Migration 0062 saves the deployed notes alongside each version so delayed digests still describe that version after newer releases appear. Equal-version polls can fill missing legacy notes without replaying notifications; older pending entries without saved notes retain their module link. A new release says that an update is available; it does not imply that a particular bug is fixed. Status notifications are inserted atomically with local status changes; repeats and GitHub echoes of a local change stay quiet. Operator announcements are separate from personal activity and are never mailed. Signed-in announcements appear in the bell of verified members who joined before sending. Public announcements, including newly added modules, appear as a quiet, dismissible floating card for everyone. Public news never contributes to bell unread counts, and marking the bell read never dismisses it. Member acknowledgement state is private; signed-out dismissal stays in the browser. See [Public and signed-in announcements](COMMUNITY_OPERATIONS.md#public-and-signed-in-announcements). diff --git a/docs/MODULE_AUTHOR_UPDATES.md b/docs/MODULE_AUTHOR_UPDATES.md index dc2012852..d70e16211 100644 --- a/docs/MODULE_AUTHOR_UPDATES.md +++ b/docs/MODULE_AUTHOR_UPDATES.md @@ -69,6 +69,14 @@ The privileged `Publish module author updates` workflow handles successful sourc The publisher independently checks the bot's merge event, both merge parents, the original ownership/registry, current successful CI and exact change scope. Merely merging with another account or inserting approval in a package is insufficient. Compilation, version, licence, documentation, qualification and package reproduction gates remain in place. A failed release keeps the previous published release available. +Three safeguards cover a stolen account or a bad update, because an automatic release reaches instruments without the owner reading it first: + +- **New addresses go to owner review.** If your update's source names a 32-bit address (`0x` and eight hex digits) that the published version's source never named, such as a new hook, stock routine, register or flash location, the bot does not merge it and says why on the PR. Bug fixes and tuning inside the code you already have stay automatic; about one in four past updates added hooks and would have waited for the owner. It is a tripwire, not a sandbox: an address computed at run time passes it. +- **The owner is told about every automatic release.** After the merge the bot comments on the PR, mentioning the owner, with a link to the exact changes. +- **Followers hear about it 48 hours after it goes live.** The site marks the release, and the community API stores a hold the first time it sees it; the hourly check queues the follower updates once the hold ends, and the daily digest timing still applies. A newer version published during the hold replaces the held one, so followers are only told about the replacement. `/modwerk resolve` works during the hold and reports no queued updates yet. + +Keep two-factor authentication on your GitHub account while you are in `.github/module-authors.json`. GitHub cannot show the owner whether it is on, so this is a condition of registration rather than a check. + Because `GITHUB_TOKEN` merges do not trigger push workflows, the author workflow explicitly dispatches both site and community API publication. Dispatch failure is visible in the author workflow; rerun the site's and Worker's normal workflows on main to retry publication. Successful merge alone is not confirmation that deployment finished. ## Repository setup @@ -77,4 +85,4 @@ This implementation needs no new personal access token, repository-wide collabor If main requires an approving review for every PR, GitHub will block automatic author merges until that rule is adjusted. Keep required `module-contract` and relevant source-build checks and require branches to be up to date; do not give the bot a general bypass of branch protections. The scope policy supplies owner review for changes beyond authors' own modules. Review repository-wide consequences before changing a ruleset. -All current published primary authors are registered with numeric IDs from their public GitHub repository owner records. On the first owner-reviewed addition by a new author, register that account ID in the same PR. Handle renames, maintainer appointments and revocations also need owner review of the registry/manifests. Website module claims and developer sign-in do not change this release authority. +All current published primary authors are registered with numeric IDs from their public GitHub repository owner records. On the first owner-reviewed addition by a new author, register that account ID in the same PR. Release authority grows slowly and stays narrow: a registered author can release only their own modules, and nobody gains wider rights for being the most eager helper. Handle renames, maintainer appointments and revocations also need owner review of the registry/manifests. Website module claims and developer sign-in do not change this release authority. diff --git a/migrations/0082_author_release_hold.sql b/migrations/0082_author_release_hold.sql new file mode 100644 index 000000000..612aa4229 --- /dev/null +++ b/migrations/0082_author_release_hold.sql @@ -0,0 +1,3 @@ +-- A registered author's own release reaches followers only once notify_after has passed, +-- so the owner can look at it, or replace it, before anyone is told to install it. +ALTER TABLE module_releases ADD COLUMN notify_after TEXT; diff --git a/scripts/author-update-git.mjs b/scripts/author-update-git.mjs index a2f877e8c..5bd5fa729 100644 --- a/scripts/author-update-git.mjs +++ b/scripts/author-update-git.mjs @@ -38,6 +38,15 @@ export function inspectAuthorUpdate(root, base, head, author) { vendor = { catalog, next, lock: JSON.parse(text(base, 'vendor/elekloader/elekloader.lock.json')), artifacts } } const authorization = authorizeAuthorUpdate(registry, author, modules, changes, vendor) + // An update that names a 32-bit address its published source never named (a new hook, stock routine, + // register or flash location) needs owner review. shortcut: a tripwire, not a sandbox; an address built + // at run time passes, so the follower hold and the owner notice stay the safety net. + const addresses = (ref, entries, folder) => new Set([...entries.keys()].filter(path => path.startsWith(folder + '/') && /\.(c|h|s|asm|inc|cpp|py)$/i.test(path)) + .flatMap(path => (text(ref, path).match(/\b0x[0-9a-f]{8}\b/gi) ?? []).map(value => value.toLowerCase()))) + for (const module of modules.filter(module => changes.some(change => change.path.startsWith(module.folder + '/')))) { + const known = addresses(base, oldTree, module.folder), added = [...addresses(head, newTree, module.folder)].filter(value => !known.has(value)) + if (added.length) throw new Error('New addresses in ' + module.folder + ' (' + added.slice(0, 5).join(', ') + '): reaching firmware or hardware the published version did not touch needs owner review.') + } const scope = readChangeScope({ root, base, head }) if (scope.elemod && authorization.elemod && !authorization.vendorPackages) throw new Error('A Digitakt/Digitone runtime update needs matching authored elekloader packages; source or version changes alone cannot replace the downloaded code.') return { ...authorization, elemodCompile: scope.elemod } diff --git a/scripts/author-update-git.test.mjs b/scripts/author-update-git.test.mjs index d6775bb11..269da47d2 100644 --- a/scripts/author-update-git.test.mjs +++ b/scripts/author-update-git.test.mjs @@ -9,7 +9,7 @@ import { ACTIONS_BOT_ID, AUTHOR_RELEASE_EVIDENCE, AUTHOR_RELEASE_REQUEST } from const roots = [], ownerId = 67785539, author = { login: 'devilfish707', id: 86663946, type: 'User' }, repository = 'repeat98/modwerk' afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); vi.unstubAllGlobals() }) -function fixture({ foreign = false, symlink = false, registryChange = false } = {}) { +function fixture({ foreign = false, symlink = false, registryChange = false, source = 'fixed code\n', files = {} } = {}) { const root = mkdtempSync(join(tmpdir(), 'modwerk-author-policy-')); roots.push(root) const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() const write = (path, data) => { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), typeof data === 'object' ? JSON.stringify(data) + '\n' : data) } @@ -18,11 +18,12 @@ function fixture({ foreign = false, symlink = false, registryChange = false } = write('.github/module-authors.json', { schemaVersion: 1, accounts: { devilfish707: author.id } }) write('sdk/catalog.json', { schemaVersion: 1, sourceRevision: 'd'.repeat(40), modules: [{ id: 'playmodes', version: doc.version }] }) write('sdk/octabam/modules/playmodes/octamod.module.json', doc) - write('sdk/octabam/modules/playmodes/source.c', 'original code\n') + write('sdk/octabam/modules/playmodes/source.c', 'original code jsr 0x4000D562\n') write('sdk/octabam/modules/playmodes/media/location.png', 'fixture only') git('add', '.'); git('commit', '-qm', 'trusted base'); const base = git('rev-parse', 'HEAD') write('sdk/octabam/modules/playmodes/octamod.module.json', { ...doc, version: '0.1.1-experimental' }) - write('sdk/octabam/modules/playmodes/source.c', 'fixed code\n') + write('sdk/octabam/modules/playmodes/source.c', source) + for (const [path, data] of Object.entries(files)) write('sdk/octabam/modules/playmodes/' + path, data) if (foreign) write('sdk/octabam/tools/build/build_bus.py', 'malicious compiler') if (registryChange) write('.github/module-authors.json', { schemaVersion: 1, accounts: { devilfish707: 1 } }) git('add', '.') @@ -53,6 +54,14 @@ describe('trusted Git object inspection', () => { f.git('add', '.'); f.git('commit', '-qm', 'source-only fix'); const head = f.git('rev-parse', 'HEAD') expect(() => inspectAuthorUpdate(f.root, base, head, author)).toThrow('matching authored elekloader packages') }) + it('keeps addresses the published source already named, in any file or case', () => { + const f = fixture({ files: { 'hooks.s': 'jmp 0x4000d562\n' } }) + expect(inspectAuthorUpdate(f.root, f.base, f.head, author)).toMatchObject({ modules: ['playmodes'] }) + }) + it('sends an update that reaches a new firmware or hardware address to owner review', () => { + const f = fixture({ source: 'fixed code jsr 0x4000d562\nmove.l d0,0xF0001234\n' }) + expect(() => inspectAuthorUpdate(f.root, f.base, f.head, author)).toThrow('New addresses in sdk/octabam/modules/playmodes (0xf0001234)') + }) it.each([{ foreign: true }, { symlink: true }, { registryChange: true }])('rejects unsafe Git trees: %j', options => { const f = fixture(options) expect(() => inspectAuthorUpdate(f.root, f.base, f.head, author)).toThrow() diff --git a/scripts/auto-merge-author-update.mjs b/scripts/auto-merge-author-update.mjs index f18c10f1b..e55be7c21 100644 --- a/scripts/auto-merge-author-update.mjs +++ b/scripts/auto-merge-author-update.mjs @@ -20,8 +20,23 @@ for (const candidate of pulls.filter(pr => pr.head?.sha === event.head && pr.bas if (git('rev-parse', 'FETCH_HEAD') !== head) throw new Error('Author updated the PR while it was being inspected.') let scope try { scope = inspectAuthorUpdate(root, base, head, pr.user) } - catch (error) { console.log('PR #' + pr.number + ' needs owner review: ' + error.message); continue } + catch (error) { + console.log('PR #' + pr.number + ' needs owner review: ' + error.message) + // Say why once on the PR, so the author is not left waiting for an automatic release that will not come. + const body = '\nThis update needs owner review instead of automatic publication: ' + error.message + await comment(pr.number, body, comments => comments.some(item => item.body === body)) + continue + } // Expected head SHA and GitHub branch protection are enforced by the merge endpoint. const runId = await mergeAuthorPull(api, repository, pr.number, head, base, scope, event.runId) console.log('Author PR #' + pr.number + ' merged after CI run ' + runId + '; site and API publication requested for ' + scope.modules.join(', ') + '.') + // The mention notifies the owner of every release made without owner review, while the follower hold runs. + await comment(pr.number, '@' + repository.split('/')[0] + ' ' + scope.modules.join(', ') + ' was published by @' + pr.user.login + ' without owner review: https://github.com/' + repository + '/compare/' + base + '...' + head + + '\n\nFollowers hear about it 48 hours after it goes live. To stop that, publish a corrected version before then. To stop further automatic releases by this author, remove them from `.github/module-authors.json`.') +} + +async function comment(number, body, posted = () => false) { + // A notice never undoes a merge or blocks the next candidate. + try { if (!posted(await pages(api, '/issues/' + number + '/comments'))) await api('/issues/' + number + '/comments', 'POST', { body }) } + catch (error) { console.log('::warning::Could not comment on PR #' + number + ': ' + error.message) } } diff --git a/scripts/module-releases.ts b/scripts/module-releases.ts index 78bfc7968..006dd8a18 100644 --- a/scripts/module-releases.ts +++ b/scripts/module-releases.ts @@ -10,10 +10,11 @@ export function moduleReleases(): Plugin { return { name: 'modwerk-module-release-inventory', apply: 'build', async generateBundle() { + const authorReleases = new Set((process.env.MODWERK_AUTHOR_RELEASE_MODULES ?? '').split(',').filter(Boolean)) const manifest: ModuleReleaseManifest = { format: 'modwerk-module-releases-v1', modules: COMMUNITY_MODULES.filter(module => module.machine !== 'octatrack' || !isModulePaused(module.id) || isBetaModule(module.id)).map(({ id, name, version, href }) => { const notes = moduleChangelogs[id]?.find(entry => entry.version === version) if (!notes) throw new Error('Missing release notes for ' + id + ' v' + version + '. Add them to src/community/module-changelogs/' + id + '.json.') - return { id, name, version, href, notes, ...(isBetaModule(id) ? { beta: true } : {}) } + return { id, name, version, href, notes, ...(isBetaModule(id) ? { beta: true } : {}), ...(authorReleases.has(id) ? { authorRelease: true } : {}) } }) } this.emitFile({ type: 'asset', fileName: 'module-releases.json', source: JSON.stringify(manifest) }) this.emitFile({ type: 'asset', fileName: 'firmware-builder.json', source: JSON.stringify(await readFirmwareBuildManifest(fileURLToPath(new URL('../', import.meta.url)))) }) diff --git a/scripts/pr-triage.mjs b/scripts/pr-triage.mjs new file mode 100644 index 000000000..575f08c65 --- /dev/null +++ b/scripts/pr-triage.mjs @@ -0,0 +1,73 @@ +// Keeps red and queued pull requests out of the owner's review list. Reads GitHub metadata only: +// no PR checkout, dependency or code runs here. +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { githubApi, pages } from './author-release-api.mjs' + +export const NEEDS_WORK = 'needs-work', ONE_AT_A_TIME = 'one-at-a-time', CLOSE_AFTER_DAYS = 14 +const MARKER = '' +export const MESSAGES = { + [NEEDS_WORK]: MARKER + '\nA required check failed, so this pull request is out of the review queue for now. Run `npm run pr:ready` (a registered author: `npm run pr:ready -- --author `) until it says Ready, then push: the label goes away by itself once the checks are green. If nothing changes for ' + CLOSE_AFTER_DAYS + ' days it is closed, and you can reopen it whenever it is green. See [Make it mergeable](https://github.com/repeat98/modwerk/blob/main/docs/DEVELOPER_WORKFLOW.md#make-it-mergeable).', + [ONE_AT_A_TIME]: MARKER + '\nThanks for this. Until your first module is published, one module pull request is reviewed at a time, so this one waits until your earlier one is merged or closed. Nothing to do: the label goes away by itself.', + close: MARKER + '\nClosing this because its checks have been red for ' + CLOSE_AFTER_DAYS + ' days without a new commit. Nothing is lost: push a fix and reopen it whenever `npm run pr:ready` says Ready.', +} + +/** What to do with one open, non-draft PR. Pure, so the policy is tested without GitHub. */ +export function decide({ red, labels, labeledAt, headDate, now, queued }) { + const has = new Set(labels), actions = [] + if (red && !has.has(NEEDS_WORK)) actions.push({ add: NEEDS_WORK }) + if (!red && has.has(NEEDS_WORK)) actions.push({ remove: NEEDS_WORK }) + if (queued && !has.has(ONE_AT_A_TIME)) actions.push({ add: ONE_AT_A_TIME }) + if (!queued && has.has(ONE_AT_A_TIME)) actions.push({ remove: ONE_AT_A_TIME }) + const stale = (now - Date.parse(labeledAt ?? '')) / 86400000 >= CLOSE_AFTER_DAYS && Date.parse(headDate) <= Date.parse(labeledAt) + if (red && has.has(NEEDS_WORK) && stale) actions.push({ close: true }) + return actions +} + +/** Contributors without a published module get one module PR reviewed at a time: every newer one waits. */ +export function queuedPulls(pulls, registered) { + const waiting = new Set(), byAuthor = new Map() + for (const pr of [...pulls].sort((a, b) => a.number - b.number)) { + if (!pr.module || registered.has(pr.author)) continue + if (byAuthor.has(pr.author)) waiting.add(pr.number); else byAuthor.set(pr.author, pr.number) + } + return waiting +} + +async function main() { + const repository = process.env.GITHUB_REPOSITORY, api = githubApi(repository, process.env.GITHUB_TOKEN), owner = repository.split('/')[0].toLowerCase() + // PR_TRIAGE_DRY_RUN=1 reads the live repository and prints the writes instead of making them. + const write = process.env.PR_TRIAGE_DRY_RUN === '1' ? async (path, method) => console.log('dry run: ' + method + ' ' + path) : api + // Anyone with a published module (author or maintainer) or release authority skips the queue. + const json = path => JSON.parse(readFileSync(new URL('../' + path, import.meta.url), 'utf8')) + const documents = [...json('src/catalog/module-documents.json').modules, ...['digitakt', 'digitone', 'analog-rytm-mkii'].flatMap(machine => { + const folder = 'sdk/' + machine + '/modules/' + return readdirSync(new URL('../' + folder, import.meta.url)).filter(id => existsSync(new URL('../' + folder + id + '/modwerk.module.json', import.meta.url))).map(id => json(folder + id + '/modwerk.module.json')) + })] + const registered = new Set([...Object.keys(json('.github/module-authors.json').accounts), ...documents.flatMap(doc => [doc.author?.github, ...(doc.maintainers ?? [])])].filter(Boolean).map(login => login.toLowerCase())) + for (const [name, color] of [[NEEDS_WORK, 'b60205'], [ONE_AT_A_TIME, 'fbca04']]) { + try { await write('/labels', 'POST', { name, color, description: name === NEEDS_WORK ? 'A required check failed; out of the review queue until green' : 'Waits for the contributor’s earlier module PR' }) } + catch (error) { if (!/HTTP 422/.test(error.message)) throw error } + } + const open = (await pages(api, '/pulls?state=open')).filter(pr => !pr.draft && pr.user.type === 'User' && pr.user.login.toLowerCase() !== owner) + const pulls = [] + for (const pr of open) { + const files = await pages(api, '/pulls/' + pr.number + '/files') + pulls.push({ pr, number: pr.number, author: pr.user.login.toLowerCase(), module: files.some(file => /^sdk\/[^/]+\/modules\//.test(file.filename)) }) + } + const queued = queuedPulls(pulls, registered), now = Date.now() + for (const { pr } of pulls) { + const runs = await pages(api, '/commits/' + pr.head.sha + '/check-runs?filter=latest', 'check_runs') + const red = runs.some(run => ['failure', 'timed_out', 'startup_failure'].includes(run.conclusion)) + const labeled = (await pages(api, '/issues/' + pr.number + '/events')).filter(event => event.event === 'labeled' && event.label?.name === NEEDS_WORK).at(-1) + const actions = decide({ red, labels: pr.labels.map(label => label.name), labeledAt: labeled?.created_at, headDate: (await api('/commits/' + pr.head.sha)).commit.committer.date, now, queued: queued.has(pr.number) }) + for (const action of actions) { + if (action.add) { await write('/issues/' + pr.number + '/labels', 'POST', { labels: [action.add] }); await write('/issues/' + pr.number + '/comments', 'POST', { body: MESSAGES[action.add] }) } + if (action.remove) await write('/issues/' + pr.number + '/labels/' + action.remove, 'DELETE') + if (action.close) { await write('/issues/' + pr.number + '/comments', 'POST', { body: MESSAGES.close }); await write('/pulls/' + pr.number, 'PATCH', { state: 'closed' }) } + console.log('#' + pr.number + ' ' + JSON.stringify(action)) + } + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) await main() diff --git a/scripts/pr-triage.test.mjs b/scripts/pr-triage.test.mjs new file mode 100644 index 000000000..4928cf0b4 --- /dev/null +++ b/scripts/pr-triage.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { CLOSE_AFTER_DAYS, NEEDS_WORK, ONE_AT_A_TIME, decide, queuedPulls } from './pr-triage.mjs' + +const day = 86400000, now = Date.parse('2026-10-25T12:00:00Z'), ago = days => new Date(now - days * day).toISOString() +const base = { red: false, labels: [], labeledAt: undefined, headDate: ago(30), now, queued: false } + +describe('PR triage policy', () => { + it('labels a red PR once and unlabels it when the checks go green', () => { + expect(decide({ ...base, red: true })).toEqual([{ add: NEEDS_WORK }]) + expect(decide({ ...base, red: true, labels: [NEEDS_WORK], labeledAt: ago(1) })).toEqual([]) + expect(decide({ ...base, labels: [NEEDS_WORK], labeledAt: ago(1) })).toEqual([{ remove: NEEDS_WORK }]) + }) + it('closes only after the label has stood for the whole period with no new commit', () => { + const red = { ...base, red: true, labels: [NEEDS_WORK] } + expect(decide({ ...red, labeledAt: ago(CLOSE_AFTER_DAYS), headDate: ago(CLOSE_AFTER_DAYS + 1) })).toEqual([{ close: true }]) + expect(decide({ ...red, labeledAt: ago(CLOSE_AFTER_DAYS - 1), headDate: ago(30) })).toEqual([]) + expect(decide({ ...red, labeledAt: ago(CLOSE_AFTER_DAYS), headDate: ago(2) })).toEqual([]) + expect(decide({ ...red, labeledAt: undefined })).toEqual([]) + }) + it('queues every newer module PR from a contributor without a published module', () => { + const pulls = [{ number: 12, author: 'new', module: true }, { number: 10, author: 'new', module: true }, { number: 11, author: 'new', module: false }, { number: 13, author: 'known', module: true }, { number: 14, author: 'known', module: true }] + expect([...queuedPulls(pulls, new Set(['known']))]).toEqual([12]) + expect(decide({ ...base, queued: true })).toEqual([{ add: ONE_AT_A_TIME }]) + expect(decide({ ...base, labels: [ONE_AT_A_TIME] })).toEqual([{ remove: ONE_AT_A_TIME }]) + }) +}) diff --git a/scripts/sync-module-releases.mjs b/scripts/sync-module-releases.mjs index 9f7de0668..b69208c9a 100644 --- a/scripts/sync-module-releases.mjs +++ b/scripts/sync-module-releases.mjs @@ -29,7 +29,7 @@ export async function syncPublishedReleases({ inventoryPath, apiUrl, requestUrl, if (result?.ok) { const summary = await result.json() if (!Number.isInteger(summary.checked) || summary.checked < 0 || !Number.isInteger(summary.notified) || summary.notified < 0) throw new Error('The release sync response was invalid.') - log('Release notifications: checked ' + summary.checked + ' published modules; queued ' + summary.notified + ' follower updates. Email preferences and digest timing are unchanged.') + log('Release notifications: checked ' + summary.checked + ' published modules; queued ' + summary.notified + ' follower updates' + (summary.held ? '; holding ' + summary.held + ' author releases for 48 hours' : '') + '. Email preferences and digest timing are unchanged.') return summary } if (result && ![403, 404, 409, 429, 500, 502, 503, 504].includes(result.status)) break diff --git a/scripts/verify-merge-approval.mjs b/scripts/verify-merge-approval.mjs index c24942524..0430e2d8b 100644 --- a/scripts/verify-merge-approval.mjs +++ b/scripts/verify-merge-approval.mjs @@ -1,4 +1,7 @@ +import {appendFileSync} from 'node:fs' import {fetchReleaseApproval} from './release-approval.mjs' // Fails the release job unless GitHub records an authorized merge of this exact commit. const approval=await fetchReleaseApproval(process.env.GITHUB_REPOSITORY??'',process.env.GITHUB_SHA??'',Number(process.env.OCTAMOD_APPROVER_ID),process.env.GITHUB_TOKEN??'') +// The build marks a registered author's own release so the community API holds its follower notices. +if(process.env.GITHUB_OUTPUT)appendFileSync(process.env.GITHUB_OUTPUT,'author-modules='+(approval.authorization==='module-author'?approval.modules.join(','):'')+'\n') console.log('GitHub release authorization verified for PR #'+approval.pullRequest+' at '+approval.sourceCommit+'.') diff --git a/server/module-updates.ts b/server/module-updates.ts index 364deef2c..7616af9d4 100644 --- a/server/module-updates.ts +++ b/server/module-updates.ts @@ -81,7 +81,7 @@ export async function recordModuleReleases(db: Database, releases: ModuleRelease states.set(release.id, previous?.version ?? null) if ((!previous || compareModuleVersions(release.version, previous.version) > 0) && !release.notes) throw new Error('Missing release notes for ' + release.id + ' v' + release.version + '.') } - let notified = 0 + let notified = 0, held = 0 // The first live inventory establishes a baseline; it must not announce the whole existing library. const initialized = !!await db.prepare('SELECT 1 AS initialized FROM module_release_inventory WHERE singleton=1').first() for (const release of releases) { @@ -92,6 +92,12 @@ export async function recordModuleReleases(db: Database, releases: ModuleRelease // A catch-up needs an explicit operator action after its content is approved, never an ordinary poll. if (compareModuleVersions(release.version, previous) < 0 || !release.notes || !recoverMissed) continue } + if (previous) { + // A registered author's own release reaches followers 48 hours after it goes live, so the owner can look at it + // or replace it first. The hold is stored on first sight; a newer version supersedes a held one and is announced instead. + if (release.authorRelease) await db.prepare("INSERT INTO module_releases(module_id,version,name,href,notes,notify_after) VALUES(?,?,?,?,?,datetime('now','+48 hours')) ON CONFLICT DO NOTHING").bind(release.id, release.version, release.name, release.href, JSON.stringify(release.notes)).run() + if (await db.prepare("SELECT 1 AS waiting FROM module_releases WHERE module_id=? AND version=? AND notify_after>datetime('now')").bind(release.id, release.version).first()) { held++; continue } + } const followers = (await db.prepare('SELECT s.user_id,s.after_version FROM module_update_subscriptions s JOIN users u ON u.id=s.user_id JOIN auth_users a ON a.id=u.id WHERE s.module_id=? AND (?=0 OR u.beta_tester=1 OR u.is_admin=1) AND u.email_verified=1 AND a.emailVerified=1 AND u.suspended=0 AND u.username IS NOT NULL AND NOT EXISTS(SELECT 1 FROM social_pending_accounts p WHERE p.user_id=u.id)').bind(release.id,Number(release.beta || isBetaModule(release.id))).all<{ user_id: string; after_version: string | null }>()).results const recipients = followers.filter(member => member.after_version !== null && compareModuleVersions(release.version, member.after_version) > 0) const statements = [ @@ -112,7 +118,7 @@ export async function recordModuleReleases(db: Database, releases: ModuleRelease notified += recipients.length } if (initializeInventory && releases.length) await db.prepare('INSERT INTO module_release_inventory(singleton) VALUES(1) ON CONFLICT DO NOTHING').run() - return { checked: releases.length, notified } + return { checked: releases.length, notified, ...(held ? { held } : {}) } } /** Read the live site's published inventory, rather than the Worker's independently deployed source catalog. */ diff --git a/src/community/BugReportNotice.tsx b/src/community/BugReportNotice.tsx index 4664ee697..771b2a921 100644 --- a/src/community/BugReportNotice.tsx +++ b/src/community/BugReportNotice.tsx @@ -10,6 +10,10 @@ export function BugReportNotice({ tracker }: { tracker: IssueTracker | null }) { const published = tracker?.tracker === 'github' ? 'The title, description, device, module version, configuration, build fingerprint and any attached log are public there under your username, and the developers reply there.' : 'The title, description, device and module version are public under your username; your configuration, build fingerprint and any attached log stay private to you, the module’s maintainers and the administrator.' return

Posting {where}. {published} Leave out firmware and personal information.

} +/** Before a report: modules are free, and their authors owe no fix or reply time (the community rules say the same). */ +export function AuthorExpectations() { + return

Modules are free, and most are made by one person in their spare time. Their authors owe no fix or reply time; clear steps and a log get a fix fastest. Community rules

+} /** Beta testers report on Discord (the beta tester note asks them to); the form stays usable, this only reminds them. */ export function BetaReportReminder() { const { session } = useCommunity() diff --git a/src/community/ConfigurationReport.tsx b/src/community/ConfigurationReport.tsx index 6de272df2..e9a7cb818 100644 --- a/src/community/ConfigurationReport.tsx +++ b/src/community/ConfigurationReport.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Icon } from '../components/Icon' import { DEVICES_BY_ID } from '../devices/registry' import { post } from './api' -import { BetaReportReminder, BugReportSuccess } from './BugReportNotice' +import { AuthorExpectations, BetaReportReminder, BugReportSuccess } from './BugReportNotice' import { catalogModules, configurationSummary } from './configuration-report' import { useCommunity } from './context' import { FLASH_STATES, OT_MODELS, type FlashState, type IssueContext, type OtModel } from './issue-context' @@ -80,6 +80,7 @@ export function ConfigurationReportDialog({ machine, configurationId, onClose }:

For a problem in the combination, or when you do not know which module is to blame. If one module clearly misbehaves, report it on that module’s page.

+
Configuration{file.log ? 'Read from ' + OT_LOG_NAME : resolved.name || 'Not saved'}{count}
{catalogued.length > 0 diff --git a/src/community/DigiIssueReport.tsx b/src/community/DigiIssueReport.tsx index 419b03bfc..6ad3a47ce 100644 --- a/src/community/DigiIssueReport.tsx +++ b/src/community/DigiIssueReport.tsx @@ -6,7 +6,7 @@ import { FLASH_STATES, type DigiIssueContext, type FlashState } from './issue-co import { useWorkspaceReportContext, type WorkspaceReportContext } from './report-context' import { communityModule } from './modules' import { DEVICES_BY_ID } from '../devices/registry' -import { BetaReportReminder, BugReportNotice, BugReportSuccess, ExistingIssues } from './BugReportNotice' +import { AuthorExpectations, BetaReportReminder, BugReportNotice, BugReportSuccess, ExistingIssues } from './BugReportNotice' import { ReportNotifications } from './ReportNotifications' import { refreshModuleIssues, useIssueTracker, type BugReportResult } from './issue-tracker' import { useOpenIssueReport } from './useOpenIssueReport' @@ -71,6 +71,7 @@ export function DigiIssueReport({ id, openRequest = 0, embedded = false, workspa {embedded?
Downloaded build {workspace.modules.length} modules attached
:} {isPreview?

Local preview — nothing is sent.

:embedded?

{tracker?.tracker === 'github' ? 'Your report and configuration are public on GitHub and notify the module’s developers.' : 'Your report is public and notifies the module’s developers. The configuration stays private to you, the maintainers and the administrator.'}

:} + {!isPreview && } } diff --git a/src/community/ForumPage.tsx b/src/community/ForumPage.tsx index b3264db1e..f0461ab50 100644 --- a/src/community/ForumPage.tsx +++ b/src/community/ForumPage.tsx @@ -163,7 +163,7 @@ function NewThread({configuration:active,configurations,query}:{configuration?:C {category==='configs'&&} -
{session.forumMedia&&}

Posts are public. Share only original or properly licensed content, including samples in sound clips. Do not post firmware, dumps, passwords or personal information.

}{pendingForm&&setPendingForm(null)} onPost={()=>{const form=pendingForm;setPendingForm(null);void submit(form)}} onReportIssue={reportIssue}/>} {error&&

{error}

} +
{session.forumMedia&&}

Posts are public. Share only original or properly licensed content, including samples in sound clips. Do not post firmware, dumps, passwords or personal information.

}{pendingForm&&setPendingForm(null)} onPost={()=>{const form=pendingForm;setPendingForm(null);void submit(form)}} onReportIssue={reportIssue}/>} {error&&

{error}

} } export function ForumPage({route,configuration,configurations,onCopy}:{route:string;configuration?:Configuration;configurations:Configuration[];onCopy:(config:SharedConfiguration)=>void}){ const [path,search='']=route.split('?'),query=new URLSearchParams(search),segments=path.split('/') diff --git a/src/community/IssueReport.tsx b/src/community/IssueReport.tsx index 0d58a7466..b681daf44 100644 --- a/src/community/IssueReport.tsx +++ b/src/community/IssueReport.tsx @@ -1,4 +1,4 @@ -import { BetaReportReminder, BugReportNotice, BugReportSuccess, ExistingIssues } from './BugReportNotice' +import { AuthorExpectations, BetaReportReminder, BugReportNotice, BugReportSuccess, ExistingIssues } from './BugReportNotice' import { ReportNotifications } from './ReportNotifications' import { refreshModuleIssues, useIssueTracker, type BugReportResult } from './issue-tracker' import { useEffect, useId, useRef, useState } from 'react' @@ -127,6 +127,7 @@ export function IssueReport({id,author,openRequest=0,embedded=false,workspaceCon {embedded&&!log?
Downloaded build {workspace.modules.length} modules attached
:} {isPreview?

Local preview — nothing is sent.

:embedded?

{tracker?.tracker==='github'?'Your report, configuration and any log are public on GitHub and notify the module’s developers.':'Your report is public and notifies the module’s developers. The configuration and any log stay private to you, the maintainers and the administrator.'}

:} + {!isPreview&&} } diff --git a/src/community/module-release-contract.ts b/src/community/module-release-contract.ts index f13d77265..64bc89089 100644 --- a/src/community/module-release-contract.ts +++ b/src/community/module-release-contract.ts @@ -4,7 +4,8 @@ import { compareModuleVersions } from '../catalog/versions' import { parseReleaseNotes, type ReleaseNotes } from './module-release-notes' // Older sites have no notes; they remain readable during deployment, but cannot queue new releases. -export type ModuleRelease = { id: string; name: string; version: string; href: string; notes?: ReleaseNotes; beta?: boolean } +// authorRelease: a registered author published this version without owner review; followers hear about it later. +export type ModuleRelease = { id: string; name: string; version: string; href: string; notes?: ReleaseNotes; beta?: boolean; authorRelease?: boolean } export type ModuleReleaseManifest = { format: 'modwerk-module-releases-v1'; modules: ModuleRelease[] } export type ModuleUpdateSubscription = { enabled: boolean; emailEnabled: boolean; emailAvailable: boolean; version: string | null } @@ -14,13 +15,14 @@ export function parseModuleReleases(value: unknown): ModuleRelease[] { const ids = new Set() return value.modules.map((item: unknown) => { if (!item || typeof item !== 'object') throw new Error('Invalid module release.') - const { id, name, version, href, notes, beta } = item as Partial + const { id, name, version, href, notes, beta, authorRelease } = item as Partial if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,99}$/.test(id) || ids.has(id) || typeof name !== 'string' || !name.trim() || name.length > 120 || typeof version !== 'string' || version.length > 80 || typeof href !== 'string' || !/^#(?:module\/[a-z0-9-]+|[a-z0-9][a-z0-9-]{0,99}\/module\/[a-z0-9-]+)$/.test(href)) throw new Error('Invalid module release.') if (beta !== undefined && typeof beta !== 'boolean') throw new Error('Invalid beta release flag.') + if (authorRelease !== undefined && authorRelease !== true) throw new Error('Invalid author release flag.') compareModuleVersions(version, version) const releaseNotes = notes === undefined ? undefined : parseReleaseNotes(notes, id) if (releaseNotes && releaseNotes.version !== version) throw new Error('Release notes must match the published version for ' + id + '.') ids.add(id) - return { id, name, version, href, ...(releaseNotes ? { notes: releaseNotes } : {}), ...(beta === undefined ? {} : { beta }) } + return { id, name, version, href, ...(releaseNotes ? { notes: releaseNotes } : {}), ...(beta === undefined ? {} : { beta }), ...(authorRelease ? { authorRelease } : {}) } }) } diff --git a/src/community/module-update-notifications.test.ts b/src/community/module-update-notifications.test.ts index 7cb96f0f4..37342db44 100644 --- a/src/community/module-update-notifications.test.ts +++ b/src/community/module-update-notifications.test.ts @@ -64,6 +64,23 @@ describe('module update subscriptions', () => { expect(await f.publish(release('miniverb', '12.0.0'))).toEqual({ checked: 1, notified: 0 }) }) + it('holds an author release for 48 hours, then notifies; a newer version replaces a held one', async () => { + const f = await fixture(), follower = await f.member('heldfan') + await f.publish(release('miniverb', '9.0.0')) + await f.call('/modules/miniverb/updates', 'PATCH', { enabled: true }, follower.session) + const authored = { ...release('miniverb', '10.0.0'), authorRelease: true } + expect(parseModuleReleases(manifest([authored]))[0].authorRelease).toBe(true) + expect(await f.publish(authored)).toEqual({ checked: 1, notified: 0, held: 1 }) + // A later deploy no longer carries the flag; the stored hold still applies. + expect(await f.publish(release('miniverb', '10.0.0'))).toEqual({ checked: 1, notified: 0, held: 1 }) + f.db.prepare("UPDATE module_releases SET notify_after=datetime('now','-1 minute') WHERE module_id='miniverb' AND version='10.0.0'").run() + expect(await f.publish(authored)).toEqual({ checked: 1, notified: 1 }) + expect(await f.publish({ ...release('miniverb', '11.0.0'), authorRelease: true })).toEqual({ checked: 1, notified: 0, held: 1 }) + // The owner replaces the held version: followers hear about the replacement only. + expect(await f.publish(release('miniverb', '12.0.0'))).toEqual({ checked: 1, notified: 1 }) + expect((await f.items(follower.session)).filter(item => item.kind === 'module_update').map(item => item.module_version).sort()).toEqual(['10.0.0', '12.0.0']) + }) + it('recovers missed current releases once and consolidates them without changing opt-outs', async () => { const f = await fixture(), follower = await f.member('catchupfan'), silent = { id: 'catchupsilent' }, unfollowed = { id: 'catchupunfollowed' } // Only the follower needs an HTTP session; seed the other recipients without unrelated password hashing. diff --git a/src/legal/LegalPage.tsx b/src/legal/LegalPage.tsx index 9ead5c50d..f0f96bfb1 100644 --- a/src/legal/LegalPage.tsx +++ b/src/legal/LegalPage.tsx @@ -16,7 +16,7 @@ export function LegalPage({route}:{route:string}){

Meldung per E-Mail

Bitte senden Sie die genaue URL und Beitrags-, Kommentar- oder Modul-ID, eine Begründung der Rechtswidrigkeit, Ihren Namen und eine Kontakt-E-Mail-Adresse. Bestätigen Sie, dass Sie nach bestem Wissen von der Richtigkeit und Vollständigkeit Ihrer Meldung überzeugt sind. Für Meldungen zu Inhalten nach Artikeln 3–7 der Richtlinie 2011/93/EU sind Kontaktdaten nicht erforderlich. Senden Sie solche Inhalte niemals als Anhang.

Wenn Kontaktdaten vorliegen, bestätigen wir den Eingang und teilen die Entscheidung mit. Meldungen werden ohne unangemessene Verzögerung von einer Person geprüft. Betroffene erhalten, soweit zulässig, eine Begründung und können sich per E-Mail gegen eine Entscheidung wenden. Die Meldung bleibt vertraulich.

:<>

MODWERK / COMMUNITY

Community rules

Community-Regeln · Version {COMMUNITY_RULES_VERSION}

-

Using the community

The community is free to use. Reading does not require an account. Posting, likes, working reports and private issue reports require a verified account. Choose a public username; keep your email and password private. We send verification and recovery emails, a welcome after signup, and activity digests you can turn off. Further news emails are optional. Usage counts are optional.

Contributions and privacy

Post only original or properly licensed material and preserve source attribution. Do not upload firmware, extracted stock content, credentials, personal information about others, unlawful material, harassment or spam. Never put private device logs in public discussions. Published posts, comments and configuration snapshots can be read and copied by anyone. Public contributions remain subject to their stated licence; deletion requests are assessed individually.

Module submissions and updates use GitHub pull requests and need owner review. Review does not provide automatic legal clearance. The separate module contribution requirements continue to apply.

Moderation and decisions

A person reviews reports and can hide content, lock a discussion or suspend an account for unlawful content or breaches of these rules. Technical limits restrict abuse and repeated requests. We do not use automated content classifiers to decide removals. Restrictions should be proportionate and take account of the context and contributors’ rights. Where contact details are available, we provide the reason, scope and available way to contest a restriction, subject to legal exceptions. Contact {SUPPORT_EMAIL} to request review by a person. Report unlawful content without an account.

Your account and changes

You can revoke sessions, download your account data or request removal from Your account. Other privacy rights can be exercised through the contact address. We explain material changes to these rules before they apply. Nothing in these rules restricts mandatory consumer or data-protection rights.

-

Teilnahme und Inhalte

Lesen ist ohne Konto möglich; die kostenlose Teilnahme setzt ein verifiziertes Konto voraus. Nutzername und Beiträge sind öffentlich, Ihre E-Mail-Adresse bleibt privat. Wir versenden Bestätigungs- und Wiederherstellungs-E-Mails, eine Willkommensnachricht nach der Registrierung und abstellbare Aktivitäts-E-Mails. Weitere News-E-Mails sind freiwillig. Nutzungszählung ist freiwillig.

Veröffentlichen Sie nur eigene oder ordnungsgemäß lizenzierte Inhalte und erhalten Sie Urheberangaben. Firmware, extrahierte Originalinhalte, Zugangsdaten, fremde personenbezogene Daten, rechtswidrige Inhalte, Belästigung und Spam sind nicht erlaubt. Private Gerätelogs gehören nicht in öffentliche Diskussionen. Öffentliche Inhalte können von Dritten kopiert werden; ihre Lizenz bleibt maßgeblich. Modulbeiträge benötigen einen GitHub-Pull-Request und die Prüfung des Betreibers.

Moderation und Rechte

Meldungen werden von einer Person geprüft. Inhalte können ausgeblendet, Diskussionen gesperrt und Konten bei Rechts- oder Regelverstößen suspendiert werden. Es gibt technische Missbrauchsbegrenzungen, aber keine automatischen Klassifizierer für Inhaltsentscheidungen. Einschränkungen müssen verhältnismäßig sein. Soweit rechtlich zulässig und Kontaktdaten verfügbar sind, erläutern wir Gründe und Umfang sowie den Weg zur Überprüfung. Eine persönliche Überprüfung können Sie unter {SUPPORT_EMAIL} verlangen. Rechtswidrige Inhalte melden ist ohne Konto möglich.

Unter Ihr Konto können Sie Sitzungen beenden, Daten herunterladen und die Löschung beantragen. Weitere Datenschutzrechte können Sie per E-Mail ausüben. Wesentliche Regeländerungen teilen wir vor ihrer Anwendung mit. Gesetzliche Verbraucher- und Datenschutzrechte bleiben unberührt.

+

Using the community

The community is free to use. Reading does not require an account. Posting, likes, working reports and private issue reports require a verified account. Choose a public username; keep your email and password private. We send verification and recovery emails, a welcome after signup, and activity digests you can turn off. Further news emails are optional. Usage counts are optional.

Contributions and privacy

Post only original or properly licensed material and preserve source attribution. Do not upload firmware, extracted stock content, credentials, personal information about others, unlawful material, harassment or spam. Never put private device logs in public discussions. Published posts, comments and configuration snapshots can be read and copied by anyone. Public contributions remain subject to their stated licence; deletion requests are assessed individually.

Module submissions and updates use GitHub pull requests and need owner review. Review does not provide automatic legal clearance. The separate module contribution requirements continue to apply.

What to expect from module authors

Modules are free and experimental, and most are made by one person in their spare time. Authors and maintainers owe no fix, feature or reply time. Clear reports with steps and a log get fixed fastest. Criticise the work, not the person. A change to someone else’s module needs its author’s agreement.

Moderation and decisions

A person reviews reports and can hide content, lock a discussion or suspend an account for unlawful content or breaches of these rules. Technical limits restrict abuse and repeated requests. We do not use automated content classifiers to decide removals. Restrictions should be proportionate and take account of the context and contributors’ rights. For rude or hostile behaviour the usual steps are a reminder, then hidden content, then a temporary suspension, then a permanent one; threats, harassment and unlawful content can skip steps. Where contact details are available, we provide the reason, scope and available way to contest a restriction, subject to legal exceptions. Contact {SUPPORT_EMAIL} to request review by a person. Report unlawful content without an account.

Your account and changes

You can revoke sessions, download your account data or request removal from Your account. Other privacy rights can be exercised through the contact address. We explain material changes to these rules before they apply. Nothing in these rules restricts mandatory consumer or data-protection rights.

+

Teilnahme und Inhalte

Lesen ist ohne Konto möglich; die kostenlose Teilnahme setzt ein verifiziertes Konto voraus. Nutzername und Beiträge sind öffentlich, Ihre E-Mail-Adresse bleibt privat. Wir versenden Bestätigungs- und Wiederherstellungs-E-Mails, eine Willkommensnachricht nach der Registrierung und abstellbare Aktivitäts-E-Mails. Weitere News-E-Mails sind freiwillig. Nutzungszählung ist freiwillig.

Veröffentlichen Sie nur eigene oder ordnungsgemäß lizenzierte Inhalte und erhalten Sie Urheberangaben. Firmware, extrahierte Originalinhalte, Zugangsdaten, fremde personenbezogene Daten, rechtswidrige Inhalte, Belästigung und Spam sind nicht erlaubt. Private Gerätelogs gehören nicht in öffentliche Diskussionen. Öffentliche Inhalte können von Dritten kopiert werden; ihre Lizenz bleibt maßgeblich. Modulbeiträge benötigen einen GitHub-Pull-Request und die Prüfung des Betreibers.

Was Modulautoren leisten

Module sind kostenlos und experimentell; die meisten entstehen in der Freizeit einer einzelnen Person. Wer ein Modul erstellt oder pflegt, schuldet weder Fehlerbehebungen noch neue Funktionen oder Antwortzeiten. Klare Meldungen mit Schritten und Log werden am schnellsten behoben. Kritik gilt der Arbeit, nicht der Person. Änderungen an einem fremden Modul brauchen die Zustimmung seines Autors.

Moderation und Rechte

Meldungen werden von einer Person geprüft. Inhalte können ausgeblendet, Diskussionen gesperrt und Konten bei Rechts- oder Regelverstößen suspendiert werden. Es gibt technische Missbrauchsbegrenzungen, aber keine automatischen Klassifizierer für Inhaltsentscheidungen. Einschränkungen müssen verhältnismäßig sein. Bei unhöflichem oder feindseligem Verhalten folgen in der Regel ein Hinweis, das Ausblenden von Inhalten, eine vorübergehende und schließlich eine dauerhafte Suspendierung; bei Drohungen, Belästigung oder rechtswidrigen Inhalten können Stufen übersprungen werden. Soweit rechtlich zulässig und Kontaktdaten verfügbar sind, erläutern wir Gründe und Umfang sowie den Weg zur Überprüfung. Eine persönliche Überprüfung können Sie unter {SUPPORT_EMAIL} verlangen. Rechtswidrige Inhalte melden ist ohne Konto möglich.

Unter Ihr Konto können Sie Sitzungen beenden, Daten herunterladen und die Löschung beantragen. Weitere Datenschutzrechte können Sie per E-Mail ausüben. Wesentliche Regeländerungen teilen wir vor ihrer Anwendung mit. Gesetzliche Verbraucher- und Datenschutzrechte bleiben unberührt.

} }