Compare commits
5
Commits
v1.5.6
...
63ee248695
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ee248695
|
||
|
|
91eb30b222
|
||
|
|
c4b2b5ccff
|
||
|
|
a4f694a966
|
||
|
|
b9f7c96ce6 |
@@ -149,6 +149,50 @@ jobs:
|
||||
{ print }
|
||||
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
|
||||
|
||||
# main requires signed commits, and Gitea refuses to merge a pull request
|
||||
# that carries an unsigned one. The key Gitea signs merge commits with
|
||||
# lives on the server and is not reachable from a runner, so the bump
|
||||
# commit is signed here with a dedicated release-bot key that the instance
|
||||
# trusts via TRUSTED_SSH_KEYS. Generating that key, trusting it and storing
|
||||
# the secret is documented in docs/ci.md.
|
||||
- name: Configure signing as Release Bot
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -z "${SIGNING_KEY}" ]; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not set - the bump commit would be unsigned and unmergeable." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ssh-keygen > /dev/null; then
|
||||
echo "ssh-keygen is missing from the runner image; git cannot make SSH signatures without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The secret holds an OpenSSH private key ("-----BEGIN OPENSSH PRIVATE
|
||||
# KEY-----"). git signs by shelling out to ssh-keygen, which wants that
|
||||
# key on disk next to the .pub it is pointed at, readable only by us,
|
||||
# and rejects it unless the final newline survived the round trip.
|
||||
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
|
||||
install -m 700 -d "${keydir}"
|
||||
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
|
||||
chmod 600 "${keydir}/key"
|
||||
# Doubles as a format check: a truncated or re-wrapped key fails here,
|
||||
# with a clearer cause than "gpg failed to sign the data" later on.
|
||||
if ! ssh-keygen -y -f "${keydir}/key" < /dev/null > "${keydir}/key.pub"; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No Gitea account backs this address; TRUSTED_SSH_KEYS verifies the
|
||||
# signature without an account lookup, so it is a label, not an identity.
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
# Named gpg.format for historical reasons; "ssh" is what switches git
|
||||
# over to signing with the SSH key above rather than a GPG key.
|
||||
git config gpg.format ssh
|
||||
git config user.signingkey "${keydir}/key.pub"
|
||||
git config commit.gpgsign true
|
||||
|
||||
- name: Open pull request
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -157,10 +201,14 @@ jobs:
|
||||
branch="release/bump-${next}"
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
git checkout -b "${branch}"
|
||||
git commit -am "Bump version to ${next} and open changelog section"
|
||||
# A commit that came out unsigned would otherwise go unnoticed until
|
||||
# someone tried to merge the PR, so fail here instead.
|
||||
if ! git cat-file commit HEAD | grep -q '^gpgsig'; then
|
||||
echo "Bump commit is unsigned; refusing to push it." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "${branch}"
|
||||
|
||||
curl -fsS -X POST "${api}/pulls" \
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer test # PHPUnit — run after every code change
|
||||
composer lint # PHPStan (level 10, `src/` only)
|
||||
composer cs # PHPCS (WordPress standard + exclusions in phpcs.xml.dist)
|
||||
composer cs:fix # auto-fix coding standards
|
||||
composer build # -> dist/unsupervised-schedular-<version>.zip
|
||||
|
||||
./vendor/bin/phpunit tests/Unit/Offering/OfferingRepositoryTest.php
|
||||
./vendor/bin/phpunit --filter testInsertReturnsId
|
||||
```
|
||||
|
||||
CI (`.gitea/workflows/ci.yml`): `phpcs`, `phpstan`, `test` (PHP 8.1/8.2/8.3/8.5), `no-debug`. Write only PHP 8.1-compatible syntax. No `var_dump|var_export|print_r|error_log|dd|dump(` in `src/` — CI greps and fails.
|
||||
|
||||
## Architecture
|
||||
|
||||
- WordPress plugin, no front-end build (vanilla JS/CSS in `assets/`). PSR-4 `Unsupervised\Schedular\` -> `src/`.
|
||||
- **Package-by-domain:** `src/<Domain>/` (Auth, Availability, Booking, GroupClass, Guardian, Offering, Payment, Policy, Registration) owns its repos, services, endpoints, pages. Cross-cutting wiring lives directly in `src/`: `Plugin`, `Installer`, `Schema`, `AdminMenu`, `RestRegistrar`, `ShortcodeRegistrar`, `BlockRegistrar`, `Val`.
|
||||
- Entry: `unsupervised-schedular.php` -> `Plugin::boot()` (wires all dependencies). **Slug is `schedular`, not `scheduler`** — filename, text domain (`unsupervised-schedular`), option `us_schedular_version`, table prefix `us_`. Never "fix" the spelling.
|
||||
- REST: `/wp-json/us-scheduler/v1/`, `permission_callback` uses capability checks, never role names.
|
||||
- DB: custom `us_*` tables via `dbDelta`; `Schema::tables()` is the source of truth. **All `$wpdb` access inside repository classes only.**
|
||||
- `src/Val.php` coerces untyped WP input (`Val::int()`, `Val::string()`, `...OrNull`, etc.). For PHPCS, `Val::int/float/bool/...` count as unslashing passthrough only — still wrap with a real sanitizer: `absint( Val::int( $_GET['id'] ?? 0 ) )`.
|
||||
|
||||
## Schema changes (gotcha)
|
||||
|
||||
- `Plugin::boot()` only re-runs `Installer`/migrations when stored `us_schedular_version !== USC_VERSION`. **Bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php` or the change never reaches existing sites.**
|
||||
- `dbDelta` does not reliably relax column NULL-ability. Follow the existing pattern in `Plugin::boot()`: repository repair method + own `us_*` option flag (e.g. `us_questions_offering_nullable`), not the version gate.
|
||||
|
||||
## Tests
|
||||
|
||||
- Brain Monkey + Mockery, no live WP. All test classes extend `tests/Unit/TestCase.php` (handles `Monkey\setUp/tearDown`, stubs translations/escaping/`checked`/`selected`).
|
||||
- Mirror layout: `tests/Unit/<Domain>/` mirrors `src/<Domain>/`.
|
||||
- `Functions\when('fn')->alias(fn() => ...)` (never `returnUsing()`); `->justReturn($v)` for constants.
|
||||
- Use `when()` not `expect()` for argument-dependent routing.
|
||||
- No `\Mockery::type()` inside plain arrays passed to `with()` — use `\Mockery::on()` or `\Mockery::any()`.
|
||||
- `$wpdb` mock needs `$mock->prefix = 'wp_'` as a property.
|
||||
|
||||
## Adding a feature
|
||||
|
||||
1. Spec first: `docs/features/<feature-name>.md` (data model, API, classes, test paths).
|
||||
2. Code in `src/<Domain>/`; templates in `templates/` if needed.
|
||||
3. Tests in `tests/Unit/<Domain>/`.
|
||||
4. `composer test` must pass (also `composer lint` + `composer cs` before finishing).
|
||||
@@ -11,6 +11,8 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.5.7]
|
||||
|
||||
## [1.5.6]
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,31 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
**Run `composer test` after every code change before considering a task complete.**
|
||||
|
||||
## Architecture
|
||||
|
||||
### Code organisation
|
||||
**Code is organised package-by-domain.** Each domain package under `src/<Domain>/` contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
|
||||
|
||||
### Data Storage
|
||||
Custom database tables are created via `dbDelta` on activation; `Schema.php` holds the SQL.
|
||||
|
||||
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
|
||||
|
||||
### REST API Namespace
|
||||
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
|
||||
|
||||
### Testing Approach
|
||||
Tests stub WordPress with Brain\Monkey rather than booting a real WP install. The setup and the Brain\Monkey/Mockery API gotchas are in `tests/CLAUDE.md`.
|
||||
|
||||
### Adding a Feature
|
||||
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
|
||||
1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
|
||||
2. Create a domain package under `src/<Domain>/` containing all classes for that feature.
|
||||
3. Add template(s) under `templates/` if needed.
|
||||
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
|
||||
5. Run `composer test` — all tests must pass before the feature is complete.
|
||||
See `AGENTS.md` — it is the single source of truth for working in this repo.
|
||||
|
||||
+56
@@ -43,3 +43,59 @@ The Composer download cache lives at `/composer/cache` — `COMPOSER_HOME` is
|
||||
The image has to exist first. Add the version to the `php` matrix in
|
||||
`ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test`
|
||||
matrix in `.gitea/workflows/ci.yml` here.
|
||||
|
||||
## Signing the version bump commit
|
||||
|
||||
`main` is a protected branch that requires signed commits, and Gitea will not
|
||||
merge a pull request containing an unsigned one. The `bump-version` job in
|
||||
`release.yml` therefore signs the commit it makes, using a dedicated
|
||||
`release-bot` SSH key rather than the key Gitea signs merge commits with —
|
||||
that one is `[repository.signing] SIGNING_KEY` on the server and no runner can
|
||||
reach it. Keeping the CI key separate also means it can be rotated on its own
|
||||
if the secret ever leaks.
|
||||
|
||||
There is deliberately no `release-bot` Gitea account. A key attached to an
|
||||
account is only consulted for signature checking after it has been through the
|
||||
web *Verify* flow, and that flow has no API — a bot account would need an
|
||||
interactive login to be worth anything. Listing the key under
|
||||
`TRUSTED_SSH_KEYS` instead makes Gitea verify commits signed with it without
|
||||
any account lookup, which is all the protected branch asks for.
|
||||
|
||||
Set up once for the instance, and again only if the key is rotated:
|
||||
|
||||
1. Generate a passphrase-less key (it has to be usable unattended):
|
||||
|
||||
```
|
||||
ssh-keygen -t ed25519 -C 'release-bot@unsupervised.ca' -f release-bot -N ''
|
||||
```
|
||||
|
||||
2. Add the public half to `app.ini` and restart Gitea:
|
||||
|
||||
```ini
|
||||
[repository.signing]
|
||||
TRUSTED_SSH_KEYS = ssh-ed25519 AAAAC3Nza... release-bot@unsupervised.ca
|
||||
```
|
||||
|
||||
3. Store the private half as the **organisation** Actions secret
|
||||
`RELEASE_BOT_SIGNING_KEY` (Org → Settings → Actions → Secrets): the whole
|
||||
`release-bot` file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` header
|
||||
and footer included — not the `.pub`, and not a GPG export. Organisation
|
||||
secrets are readable as `secrets.RELEASE_BOT_SIGNING_KEY` from every
|
||||
repository in the org, so no repository-level copy is needed. Delete both
|
||||
local files afterwards.
|
||||
|
||||
Two consequences of trusting the key instance-wide are worth knowing. Any
|
||||
commit signed with it verifies in *every* repository on the instance, not just
|
||||
these — the trust is in the key, not in a user with permissions you can scope.
|
||||
And the signature is attributed to `SIGNING_NAME` / `SIGNING_EMAIL`, not to the
|
||||
`Release Bot <[email protected]>` committer the job sets; that
|
||||
address backs no account and is only a label.
|
||||
|
||||
The job fails fast if the secret is missing or `ssh-keygen` is absent from the
|
||||
runner image, and it re-reads the commit it just made to confirm a signature
|
||||
is attached before pushing — an unsigned bump commit would otherwise look fine
|
||||
until someone tried to merge the PR.
|
||||
|
||||
Nothing else in the pipeline signs anything: release tags are made by a human
|
||||
through Gitea's release UI, and the merge commit is signed by the server when
|
||||
the PR is merged.
|
||||
|
||||
+1
-12
@@ -1,14 +1,3 @@
|
||||
# Writing tests
|
||||
|
||||
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
|
||||
|
||||
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
|
||||
|
||||
**Brain\Monkey API notes:**
|
||||
|
||||
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
|
||||
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
|
||||
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
|
||||
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
|
||||
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
|
||||
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
|
||||
See `../../AGENTS.md` (Tests section) — it is the single source of truth for working in this repo.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Unsupervised Scheduler
|
||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||
* Description: Instructor/student lesson scheduling for WordPress.
|
||||
* Version: 1.5.6
|
||||
* Version: 1.5.7
|
||||
* Requires at least: 6.2
|
||||
* Requires PHP: 8.1
|
||||
* Author: Unsupervised
|
||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('USC_VERSION', '1.5.6');
|
||||
define('USC_VERSION', '1.5.7');
|
||||
define('USC_PLUGIN_FILE', __FILE__);
|
||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user