Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50beed4c02 |
@@ -13,7 +13,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }}
|
IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||||
run: |
|
run: |
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||||
docker build -t "$IMAGE" .
|
docker build -t "$IMAGE" .
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ name: Mirror
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['**']
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Registry cleanup
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '43 3 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prune-stale-images:
|
|
||||||
runs-on: docker-host
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4.2.2
|
|
||||||
- name: Delete hash images that are neither release-tagged nor a branch head
|
|
||||||
env:
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
|
||||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
|
||||||
REPO_TOKEN: ${{ github.token }}
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
SERVER_URL: ${{ github.server_url }}
|
|
||||||
run: |
|
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
|
||||||
node:24.18.0-alpine3.24 node registry-cleanup/cleanup.ts
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
name: Release
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
retag-image:
|
|
||||||
runs-on: docker-host
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4.2.2
|
|
||||||
- name: Promote the commit-hash image to semver + latest
|
|
||||||
env:
|
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
|
||||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
|
||||||
REPO: gitea.larvit.se/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
|
||||||
VERSION=${GIT_TAG#v}
|
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
|
||||||
docker pull "$REPO:$COMMIT" \
|
|
||||||
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
|
||||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
|
||||||
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
|
||||||
docker push "$REPO:$TAG"
|
|
||||||
done
|
|
||||||
- name: Sync the release tags to Docker Hub
|
|
||||||
env:
|
|
||||||
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
|
||||||
REPO: gitea.larvit.se/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
|
||||||
VERSION=${GIT_TAG#v}
|
|
||||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
|
||||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
|
||||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
|
||||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
|
||||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
|
||||||
docker push "$DOCKERHUB_REPO:$TAG"
|
|
||||||
done
|
|
||||||
- name: Log out of the registries
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker logout gitea.larvit.se
|
|
||||||
docker logout docker.io
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
name: Renovate
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '17 4 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
renovate:
|
|
||||||
runs-on: docker-host
|
|
||||||
steps:
|
|
||||||
- name: Run Renovate against this repo
|
|
||||||
env:
|
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
|
||||||
run: |
|
|
||||||
docker run --rm \
|
|
||||||
-e LOG_LEVEL=info \
|
|
||||||
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
|
||||||
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
|
||||||
-e RENOVATE_PLATFORM=gitea \
|
|
||||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
|
||||||
-e RENOVATE_TOKEN \
|
|
||||||
renovate/renovate:43.251.0
|
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
|
||||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
|
||||||
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
|
||||||
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
|
||||||
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
|
||||||
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
|
||||||
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
|
||||||
auto-release:
|
|
||||||
runs-on: docker-host
|
|
||||||
needs: renovate
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Tag a release for what Renovate merged
|
|
||||||
env:
|
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git fetch --force --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*'
|
|
||||||
if [ "$(git log -1 --format='%ae' origin/main)" != "renovate@larvit.se" ]; then
|
|
||||||
echo "main tip not authored by Renovate — a human owns this release; skipping"; exit 0
|
|
||||||
fi
|
|
||||||
LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
|
|
||||||
LATEST=${LATEST:-v0.0.0}
|
|
||||||
if [ -z "$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' --format='%H')" ]; then
|
|
||||||
echo "No untagged renovate commits since ${LATEST} — nothing to release"; exit 0
|
|
||||||
fi
|
|
||||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
|
||||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.16.0-alpine3.24 \
|
|
||||||
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
|
||||||
git tag "$NEXT" origin/main
|
|
||||||
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
|
||||||
@@ -86,11 +86,6 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
||||||
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
||||||
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
|
||||||
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
|
||||||
and tokens sit in that file between login and logout. Accepted for a single-maintainer
|
|
||||||
cadence; serialize with a workflow `concurrency` group if it ever bites.
|
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.18.0-alpine3.24
|
FROM node:24.16.0-alpine3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
# Plainpages
|
|
||||||
|
|
||||||
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
|
||||||
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
|
||||||
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
|
|
||||||
|
|
||||||
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
|
||||||
([GitHub mirror](https://github.com/larvit/plainpages))
|
|
||||||
|
|
||||||
## Tags
|
|
||||||
|
|
||||||
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
|
||||||
Pin the exact `X.Y.Z` you deploy.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
This image is the Plainpages web app plus its one-shot bootstrap seeder. It runs
|
|
||||||
alongside its Ory sidecars (Kratos, Keto) and Postgres — and it **ships their config**,
|
|
||||||
so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
web:
|
|
||||||
image: larvit/plainpages:0.0.2
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
|
||||||
APP_URL: http://localhost:3000
|
|
||||||
depends_on:
|
|
||||||
bootstrap:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
kratos:
|
|
||||||
condition: service_healthy
|
|
||||||
keto:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
|
||||||
- ./plugins:/app/plugins
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
|
||||||
bootstrap:
|
|
||||||
image: larvit/plainpages:0.0.2
|
|
||||||
command: node src/auth/bootstrap.ts
|
|
||||||
depends_on:
|
|
||||||
kratos:
|
|
||||||
condition: service_healthy
|
|
||||||
keto:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
|
||||||
- ./plugins:/app/plugins:ro
|
|
||||||
restart: "on-failure:5"
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:18.4-alpine3.23
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ory
|
|
||||||
POSTGRES_PASSWORD: ory
|
|
||||||
POSTGRES_USER: ory
|
|
||||||
volumes:
|
|
||||||
- ./ory/postgres/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
- pgdata:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ory -d ory"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
kratos-migrate:
|
|
||||||
image: oryd/kratos:v26.2.0
|
|
||||||
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
|
||||||
volumes:
|
|
||||||
- ./ory/kratos:/etc/config/kratos:ro
|
|
||||||
restart: on-failure
|
|
||||||
|
|
||||||
kratos:
|
|
||||||
image: oryd/kratos:v26.2.0
|
|
||||||
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
|
||||||
ports:
|
|
||||||
- "4433:4433" # the login form POSTs straight to Kratos from the browser
|
|
||||||
depends_on:
|
|
||||||
kratos-migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
environment:
|
|
||||||
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
|
||||||
volumes:
|
|
||||||
- ./ory/kratos:/etc/config/kratos:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
keto-migrate:
|
|
||||||
image: oryd/keto:v26.2.0
|
|
||||||
command: -c /etc/config/keto/keto.yml migrate up -y
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
|
||||||
volumes:
|
|
||||||
- ./ory/keto:/etc/config/keto:ro
|
|
||||||
restart: on-failure
|
|
||||||
|
|
||||||
keto:
|
|
||||||
image: oryd/keto:v26.2.0
|
|
||||||
command: serve -c /etc/config/keto/keto.yml
|
|
||||||
depends_on:
|
|
||||||
keto-migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
environment:
|
|
||||||
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
|
||||||
volumes:
|
|
||||||
- ./ory/keto:/etc/config/keto:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
|
||||||
mailpit:
|
|
||||||
image: axllent/mailpit:v1.30.1
|
|
||||||
ports:
|
|
||||||
- "8025:8025"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
```
|
|
||||||
|
|
||||||
Extract the Ory config the image ships, then start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
|
|
||||||
mkdir -p plugins
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open <http://localhost:3000> and sign in as `admin@plainpages.local` / `admin`.
|
|
||||||
|
|
||||||
This quick start runs http-on-localhost with dev-throwaway secrets, and omits Hydra (the
|
|
||||||
OAuth2 provider — only needed when other apps log in *through* Plainpages). For
|
|
||||||
production — https, real secrets (`CSRF_SECRET`, Postgres credentials, a fresh JWT
|
|
||||||
signing key), Hydra — see the repo README → Production & deployment.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The common ones:
|
|
||||||
|
|
||||||
| Var | Default | What |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
|
||||||
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
|
||||||
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
|
||||||
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
|
|
||||||
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
|
||||||
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
|
||||||
| `OTLP_ENDPOINT` | unset | export logs + traces to an OpenTelemetry Collector |
|
|
||||||
| `REQUIRE_SECURE_SECRETS` | `false` | `true` ⇒ refuse to boot on a missing/throwaway `CSRF_SECRET` |
|
|
||||||
| `SECURE_COOKIES` | `false` | mark cookies `Secure` (`true` behind https) |
|
|
||||||
|
|
||||||
Full list (JWT/JWKS, timeouts, instant revoke): repo README → Configuration.
|
|
||||||
|
|
||||||
## Your first plugin
|
|
||||||
|
|
||||||
Everything domain-specific is a plugin folder — the compose above mounts `./plugins`
|
|
||||||
into the app. Create `plugins/hello/plugin.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { definePlugin } from "#plugin-api";
|
|
||||||
|
|
||||||
export default definePlugin({
|
|
||||||
apiVersion: "1.0.0",
|
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
|
||||||
routes: [
|
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
|
||||||
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
|
||||||
@@ -1174,10 +1174,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
|||||||
| Workflow | Trigger | Does |
|
| Workflow | Trigger | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`), then build + push the app image |
|
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`), then build + push the app image |
|
||||||
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
|
| `mirror.yml` | push to `main`, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
|
||||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
|
||||||
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut one release tag for the run |
|
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
@@ -1194,41 +1191,15 @@ merges are fast-forward-only, the image for any `main` commit already exists —
|
|||||||
and pushed by that exact commit's branch gate; nothing is rebuilt after merge (build once,
|
and pushed by that exact commit's branch gate; nothing is rebuilt after merge (build once,
|
||||||
promote by re-tagging). One-time setup: on an account with package write in the `larvit` org,
|
promote by re-tagging). One-time setup: on an account with package write in the `larvit` org,
|
||||||
create a Gitea access token with `read:package` + `write:package`, and store the account name
|
create a Gitea access token with `read:package` + `write:package`, and store the account name
|
||||||
as the Actions **variable** `DOCKER_REGISTRY_USER` and the token as the Actions **secret**
|
and token as the Actions secrets `DOCKER_REGISTRY_USER` and `DOCKER_REGISTRY_TOKEN`
|
||||||
`DOCKER_REGISTRY_TOKEN` (a `GITEA_` prefix is rejected — reserved, like `GITHUB_`). The
|
(a `GITEA_` prefix is rejected — reserved, like `GITHUB_`). Because this step runs
|
||||||
package is **org-owned** (the image path starts with `larvit/`), so it lists under
|
|
||||||
`larvit/-/packages`, not the repo — link it once to the repo's Packages tab:
|
|
||||||
`POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`. Because
|
|
||||||
this step runs
|
|
||||||
inside the required gate, a missing/expired token (or registry outage) fails every branch's
|
inside the required gate, a missing/expired token (or registry outage) fails every branch's
|
||||||
gate and blocks **all** merges until restored — set the secrets before this lands, and use a
|
gate and blocks **all** merges until restored — set the secrets before this lands, and use a
|
||||||
non-expiring token or track its expiry. Retention: hash tags accumulate one image per gated
|
non-expiring token or track its expiry. Retention: hash tags
|
||||||
push, so the nightly `registry-cleanup.yml` prunes them precisely
|
accumulate one image per gated push, so an org-level package **cleanup rule**
|
||||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts), run in a `node:24` container):
|
(org Settings → Packages, applied by Gitea's daily cleanup cron) prunes them — type
|
||||||
a hash tag survives only while its commit is a **branch head** or carries a **`vX.Y.Z`
|
`container`, remove versions matching `^[0-9a-f]{40}$` older than 30 days, keep the 10 most
|
||||||
release tag**; deleted alongside are the untagged `sha256:…` child manifests (arch image +
|
recent and anything matching `^v?\d+\.\d+\.\d+$|^latest$` (semver tags are never pruned).
|
||||||
provenance) that no surviving tag references. Named tags (`1.2.3`, `latest`, …) are never
|
|
||||||
touched. It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't
|
|
||||||
add a pattern-based org cleanup rule for this package (and remove it if one exists): its
|
|
||||||
age/count heuristics can't see branch heads or release tags and would delete images the
|
|
||||||
workflow protects.
|
|
||||||
|
|
||||||
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
|
||||||
`release.yml`, which pulls that commit's hash image from the registry and re-tags it as
|
|
||||||
`1.2.3`, `1.2`, `1`, and `latest` — nothing is rebuilt, the released image is byte-identical
|
|
||||||
to the gated one. It fails loud if no hash image exists: release tags must point at a commit
|
|
||||||
that went through the gate (in practice, any `main` commit). The same four tags are then
|
|
||||||
synced to [Docker Hub](https://hub.docker.com/r/larvit/plainpages) (`larvit/plainpages`) —
|
|
||||||
releases only, no hash tags. One-time setup: create the public `larvit/plainpages`
|
|
||||||
repository on Docker Hub, generate a read/write access token **scoped to that repository**
|
|
||||||
(an organization access token, or a token on a dedicated single-purpose account — an
|
|
||||||
account-wide PAT can push to every repo under the account), and store the account name as
|
|
||||||
the Actions **variable** `DOCKERHUB_USER` and the token as the Actions **secret**
|
|
||||||
`DOCKERHUB_TOKEN`. Until they exist, a release run fails at the Docker Hub step — after the
|
|
||||||
Gitea re-tag has succeeded — so set them, then re-run the workflow. The Docker Hub
|
|
||||||
repository **description** is maintained by hand: its source is
|
|
||||||
[`README-dockerhub.md`](README-dockerhub.md) — paste it into the repository overview on
|
|
||||||
Docker Hub when it changes.
|
|
||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
||||||
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
||||||
@@ -1239,46 +1210,13 @@ as the Gitea Actions secret `MIRROR_GITHUB_TOKEN` (repo Settings → Actions →
|
|||||||
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
||||||
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
||||||
|
|
||||||
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com)
|
|
||||||
nightly (self-hosted, this repo only) against [`renovate.json`](renovate.json), opening PRs
|
|
||||||
that bump npm deps (both `package.json`s), Docker base images (both Dockerfiles +
|
|
||||||
`compose*.yml`), Gitea action versions, and the image tags pinned inside workflow `run:`
|
|
||||||
steps (a custom regex manager, so nothing pinned drifts unmanaged). Version-locked sets move
|
|
||||||
together in one PR — the Ory images (kratos/keto/hydra) and the Playwright runner + its
|
|
||||||
browser image — and every bump keeps the existing **exact semver pin** exact, never widening
|
|
||||||
to a range or adding a digest. Each PR
|
|
||||||
runs through the normal gate on its `renovate/*` branch and, with `"automerge": true`,
|
|
||||||
Renovate merges it once `CI / full-gate (push)` is green (rebasing stale branches so the
|
|
||||||
fast-forward-only merge still holds) — routine bumps land untouched; only a red gate needs a
|
|
||||||
human. One-time setup: reuse the shared `renovate@larvit.se` bot — give it write access to
|
|
||||||
this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Until it
|
|
||||||
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_` prefix is
|
|
||||||
rejected).
|
|
||||||
|
|
||||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
|
|
||||||
renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
|
|
||||||
since the last tag (it targets `origin/main`, and **skips** when the tip isn't a Renovate commit —
|
|
||||||
a human owns that release — or when nothing new merged). Renovate stamps every commit with a
|
|
||||||
`Release-Bump: <updateType>` trailer (`commitBody` in `renovate.json`); the job takes the highest
|
|
||||||
trailer on those commits — any dependency's `major`/`minor`/`patch` maps straight through,
|
|
||||||
defaulting to `patch`.
|
|
||||||
**Pre-1.0 the level shifts down** — a dep major bumps the `0.x` minor, dep minor/patch bump the
|
|
||||||
`0.x` patch (see [`auto-release/next-version.ts`](auto-release/next-version.ts), unit-tested) — so
|
|
||||||
routine bumps never auto-cross into `1.0.0`; `1.0.0` stays a deliberate hand-cut tag. It's
|
|
||||||
**tag-only** (no source commits): the tag hands off to `release.yml`, which promotes the
|
|
||||||
already-built image, and is pushed with renovate-bot's PAT so `release.yml` actually fires (a tag
|
|
||||||
pushed by the built-in Actions token wouldn't trigger it). The plugin-contract version
|
|
||||||
(`HOST_API_VERSION`) is deliberately **not** touched here — it moves only when the plugin API
|
|
||||||
itself changes, by hand.
|
|
||||||
|
|
||||||
**One-time server setup** — register an
|
**One-time server setup** — register an
|
||||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label
|
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label
|
||||||
`docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine +
|
`docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine +
|
||||||
Compose, git, and Node + github.com access (for `actions/checkout`). Runs must **never
|
Compose, git, and Node + github.com access (for `actions/checkout`). Runs must **never
|
||||||
overlap** — the e2e stacks use fixed compose project names and the devstack suite uses host
|
overlap** — the e2e stacks use fixed compose project names and the devstack suite uses host
|
||||||
networking, and the workflows share the Docker daemon's registry login (`ci.yml` and
|
networking — so register exactly **one** `docker-host` runner, keep its capacity at 1, and
|
||||||
`release.yml` each log in and log out) — so register exactly **one** `docker-host` runner,
|
keep host ports 3000/4433 free.
|
||||||
keep its capacity at 1, and keep host ports 3000/4433 free.
|
|
||||||
|
|
||||||
## Production & deployment
|
## Production & deployment
|
||||||
|
|
||||||
@@ -1496,7 +1434,6 @@ e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + aut
|
|||||||
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
||||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||||
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
||||||
README-dockerhub.md The Docker Hub repository description (docker.io/larvit/plainpages) — pasted into the Docker Hub overview by hand when it changes; see CI/CD
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending the core
|
## Extending the core
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { bumpFromUpdateType, maxLevel, nextVersion } from "./next-version.ts";
|
|
||||||
|
|
||||||
test("bumpFromUpdateType: only major/minor keep their level; everything else is patch", () => {
|
|
||||||
assert.equal(bumpFromUpdateType("major"), "major");
|
|
||||||
assert.equal(bumpFromUpdateType("minor"), "minor");
|
|
||||||
assert.equal(bumpFromUpdateType("patch"), "patch");
|
|
||||||
assert.equal(bumpFromUpdateType("digest"), "patch");
|
|
||||||
assert.equal(bumpFromUpdateType("pin"), "patch");
|
|
||||||
assert.equal(bumpFromUpdateType("lockFileMaintenance"), "patch");
|
|
||||||
assert.equal(bumpFromUpdateType(""), "patch");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxLevel: defaults to patch, escalates on the highest level present", () => {
|
|
||||||
assert.equal(maxLevel([]), "patch");
|
|
||||||
assert.equal(maxLevel(["patch"]), "patch");
|
|
||||||
assert.equal(maxLevel(["patch", "minor"]), "minor");
|
|
||||||
assert.equal(maxLevel(["minor", "major", "patch"]), "major");
|
|
||||||
assert.equal(maxLevel(["digest", "pin"]), "patch");
|
|
||||||
assert.equal(maxLevel(["", "bogus"]), "patch"); // unknown → patch, never throws
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nextVersion pre-1.0 (major===0): shift down so we never auto-cross into 1.0.0", () => {
|
|
||||||
// dep major → 0.x minor (the 0.x "breaking" slot); dep minor/patch → 0.x patch
|
|
||||||
assert.equal(nextVersion("v0.0.2", "major"), "v0.1.0");
|
|
||||||
assert.equal(nextVersion("v0.0.2", "minor"), "v0.0.3");
|
|
||||||
assert.equal(nextVersion("v0.0.2", "patch"), "v0.0.3");
|
|
||||||
assert.equal(nextVersion("v0.3.4", "major"), "v0.4.0");
|
|
||||||
assert.equal(nextVersion("v0.3.4", "minor"), "v0.3.5");
|
|
||||||
assert.equal(nextVersion("v0.3.4", "patch"), "v0.3.5");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nextVersion at/after 1.0.0: literal semver", () => {
|
|
||||||
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
|
||||||
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
|
||||||
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
|
||||||
assert.throws(() => nextVersion("1.2.3", "patch"), /vX\.Y\.Z/);
|
|
||||||
assert.throws(() => nextVersion("vx.y.z", "patch"), /vX\.Y\.Z/);
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
|
||||||
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
|
||||||
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
|
||||||
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
|
||||||
|
|
||||||
export type Bump = "major" | "minor" | "patch";
|
|
||||||
|
|
||||||
// A dependency change is always at least a patch; only a real major/minor escalates.
|
|
||||||
export function bumpFromUpdateType(updateType: string): Bump {
|
|
||||||
if (updateType === "major") return "major";
|
|
||||||
if (updateType === "minor") return "minor";
|
|
||||||
return "patch";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maxLevel(updateTypes: string[]): Bump {
|
|
||||||
let level: Bump = "patch";
|
|
||||||
for (const updateType of updateTypes) {
|
|
||||||
const bump = bumpFromUpdateType(updateType);
|
|
||||||
if (bump === "major") return "major";
|
|
||||||
if (bump === "minor") level = "minor";
|
|
||||||
}
|
|
||||||
return level;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-1.0 (major===0) shifts every level down one notch, so a dependency major only bumps the 0.x
|
|
||||||
// minor and we never auto-cross into 1.0.0 — that stays a deliberate human milestone.
|
|
||||||
export function nextVersion(latestTag: string, level: Bump): string {
|
|
||||||
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(latestTag);
|
|
||||||
if (!match) throw new Error(`latest tag must be vX.Y.Z, got ${JSON.stringify(latestTag)}`);
|
|
||||||
const major = Number(match[1]);
|
|
||||||
const minor = Number(match[2]);
|
|
||||||
const patch = Number(match[3]);
|
|
||||||
const effective: Bump = major === 0 ? (level === "major" ? "minor" : "patch") : level;
|
|
||||||
if (effective === "major") return `v${major + 1}.0.0`;
|
|
||||||
if (effective === "minor") return `v${major}.${minor + 1}.0`;
|
|
||||||
return `v${major}.${minor}.${patch + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
|
||||||
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
|
||||||
const [, , latestTag, ...updateTypes] = process.argv;
|
|
||||||
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.18.0-alpine3.24
|
image: node:24.16.0-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.30.3
|
image: axllent/mailpit:v1.30.1
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||||
FROM mcr.microsoft.com/playwright:v1.61.1-noble
|
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
||||||
|
|
||||||
WORKDIR /e2e-tests
|
WORKDIR /e2e-tests
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.18.0-alpine3.24
|
image: node:24.16.0-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.18.0-alpine3.24
|
image: node:24.16.0-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -82,7 +82,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.18.0-alpine3.24
|
image: node:24.16.0-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.61.1"
|
"@playwright/test": "1.49.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
// Prunes the app's container package in the Gitea registry: a commit-hash tag survives only
|
|
||||||
// while its commit is a branch head or carries a vX.Y.Z release tag. Untagged sha256:* package
|
|
||||||
// versions are CHILD manifests (arch image + provenance) of the tagged OCI indexes — deleting
|
|
||||||
// one that a surviving tag still references breaks that image, so only the ones no kept tag
|
|
||||||
// references are removed. Named tags (1.2.3, latest, …) are never touched.
|
|
||||||
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
|
||||||
|
|
||||||
function env(name: string): string {
|
|
||||||
const value = process.env[name];
|
|
||||||
if (value === undefined || value === "") throw new Error(`Missing env var ${name}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const registryToken = env("REGISTRY_TOKEN");
|
|
||||||
const registryUser = env("REGISTRY_USER");
|
|
||||||
const repoToken = env("REPO_TOKEN");
|
|
||||||
const repository = env("REPOSITORY");
|
|
||||||
const serverUrl = env("SERVER_URL").replace(/\/+$/, "");
|
|
||||||
|
|
||||||
const [owner, name] = repository.split("/");
|
|
||||||
if (owner === undefined || name === undefined || owner === "" || name === "") {
|
|
||||||
throw new Error(`REPOSITORY must be owner/name, got "${repository}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOk(url: string, init: RequestInit): Promise<Response> {
|
|
||||||
const res = await fetch(url, init);
|
|
||||||
if (!res.ok) throw new Error(`${init.method ?? "GET"} ${url} -> ${res.status}: ${await res.text()}`);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiGetAllPages<T>(path: string, token: string): Promise<T[]> {
|
|
||||||
const all: T[] = [];
|
|
||||||
const limit = 50;
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const sep = path.includes("?") ? "&" : "?";
|
|
||||||
const res = await fetchOk(`${serverUrl}/api/v1${path}${sep}limit=${limit}&page=${page}`, {
|
|
||||||
headers: { authorization: `token ${token}` },
|
|
||||||
});
|
|
||||||
const batch = (await res.json()) as T[];
|
|
||||||
// Stop on an empty page, not a short one — a lowered server page-size cap would otherwise
|
|
||||||
// silently truncate the list, and a truncated branch/tag list deletes protected images.
|
|
||||||
if (batch.length === 0) return all;
|
|
||||||
all.push(...batch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function childDigests(tag: string): Promise<string[]> {
|
|
||||||
const res = await fetchOk(`${serverUrl}/v2/${owner}/${name}/manifests/${tag}`, {
|
|
||||||
headers: {
|
|
||||||
accept: [
|
|
||||||
"application/vnd.oci.image.index.v1+json",
|
|
||||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
||||||
"application/vnd.oci.image.manifest.v1+json",
|
|
||||||
"application/vnd.docker.distribution.manifest.v2+json",
|
|
||||||
].join(", "),
|
|
||||||
authorization: `Basic ${Buffer.from(`${registryUser}:${registryToken}`).toString("base64")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const manifest = (await res.json()) as { manifests?: { digest: string }[] };
|
|
||||||
return (manifest.manifests ?? []).map((m) => m.digest);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteVersion(version: string): Promise<void> {
|
|
||||||
const url = `${serverUrl}/api/v1/packages/${owner}/container/${name}/${encodeURIComponent(version)}`;
|
|
||||||
const res = await fetch(url, { headers: { authorization: `token ${registryToken}` }, method: "DELETE" });
|
|
||||||
if (res.status === 404) {
|
|
||||||
console.log(`already gone: ${version}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(`DELETE ${url} -> ${res.status}: ${await res.text()}`);
|
|
||||||
console.log(`deleted: ${version}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const branches = await apiGetAllPages<{ commit: { id: string } }>(`/repos/${owner}/${name}/branches`, repoToken);
|
|
||||||
if (branches.length === 0) throw new Error("no branches returned — refusing to prune with an empty keep-set");
|
|
||||||
const tags = await apiGetAllPages<{ commit: { sha: string }; name: string }>(`/repos/${owner}/${name}/tags`, repoToken);
|
|
||||||
const packages = await apiGetAllPages<{ name: string; version: string }>(
|
|
||||||
`/packages/${owner}?type=container&q=${encodeURIComponent(name)}`,
|
|
||||||
registryToken,
|
|
||||||
);
|
|
||||||
|
|
||||||
const versions = packages.filter((p) => p.name === name).map((p) => p.version);
|
|
||||||
const plan = planHashTagDeletions({
|
|
||||||
branchHeadShas: branches.map((b) => b.commit.id),
|
|
||||||
releaseTagShas: tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t.name)).map((t) => t.commit.sha),
|
|
||||||
versions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const referenced = new Set<string>();
|
|
||||||
for (const tag of plan.keptTags) {
|
|
||||||
for (const digest of await childDigests(tag)) referenced.add(digest);
|
|
||||||
}
|
|
||||||
const orphans = selectOrphanedManifests({ referencedDigests: [...referenced], versions });
|
|
||||||
|
|
||||||
for (const version of [...plan.deletions, ...orphans]) await deleteVersion(version);
|
|
||||||
console.log(
|
|
||||||
`kept ${plan.keptTags.length} tags; deleted ${plan.deletions.length} hash tags and ${orphans.length} orphaned manifests`,
|
|
||||||
);
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
|
||||||
|
|
||||||
const headSha = "0582809000000000000000000000000000000001";
|
|
||||||
const releaseSha = "50006dd000000000000000000000000000000002";
|
|
||||||
const staleSha = "c8981c1000000000000000000000000000000003";
|
|
||||||
|
|
||||||
test("planHashTagDeletions deletes hash tags that are neither a branch head nor release-tagged", () => {
|
|
||||||
const plan = planHashTagDeletions({
|
|
||||||
branchHeadShas: [headSha],
|
|
||||||
releaseTagShas: [releaseSha],
|
|
||||||
versions: [headSha, releaseSha, staleSha],
|
|
||||||
});
|
|
||||||
assert.deepEqual(plan.deletions, [staleSha]);
|
|
||||||
assert.deepEqual(plan.keptTags, [headSha, releaseSha]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("planHashTagDeletions keeps named tags and excludes sha256 manifest versions from keptTags", () => {
|
|
||||||
const plan = planHashTagDeletions({
|
|
||||||
branchHeadShas: [],
|
|
||||||
releaseTagShas: [],
|
|
||||||
versions: ["0.0.1", "0", "latest", "some-manual-tag", `sha256:${"a".repeat(64)}`, staleSha],
|
|
||||||
});
|
|
||||||
assert.deepEqual(plan.deletions, [staleSha]);
|
|
||||||
assert.deepEqual(plan.keptTags, ["0.0.1", "0", "latest", "some-manual-tag"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("planHashTagDeletions with no versions plans nothing", () => {
|
|
||||||
const plan = planHashTagDeletions({ branchHeadShas: [headSha], releaseTagShas: [], versions: [] });
|
|
||||||
assert.deepEqual(plan.deletions, []);
|
|
||||||
assert.deepEqual(plan.keptTags, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selectOrphanedManifests picks only sha256 versions unreferenced by kept tags", () => {
|
|
||||||
const referenced = `sha256:${"b".repeat(64)}`;
|
|
||||||
const orphaned = `sha256:${"c".repeat(64)}`;
|
|
||||||
const orphans = selectOrphanedManifests({
|
|
||||||
referencedDigests: [referenced],
|
|
||||||
versions: ["0.0.1", "latest", headSha, referenced, orphaned],
|
|
||||||
});
|
|
||||||
assert.deepEqual(orphans, [orphaned]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selectOrphanedManifests with nothing referenced orphans every sha256 version", () => {
|
|
||||||
const manifest = `sha256:${"d".repeat(64)}`;
|
|
||||||
assert.deepEqual(selectOrphanedManifests({ referencedDigests: [], versions: [manifest, "latest"] }), [manifest]);
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const hashTag = /^[0-9a-f]{40}$/;
|
|
||||||
const manifestVersion = /^sha256:[0-9a-f]{64}$/;
|
|
||||||
|
|
||||||
export type HashTagPlan = {
|
|
||||||
deletions: string[];
|
|
||||||
keptTags: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function planHashTagDeletions(input: {
|
|
||||||
branchHeadShas: string[];
|
|
||||||
releaseTagShas: string[];
|
|
||||||
versions: string[];
|
|
||||||
}): HashTagPlan {
|
|
||||||
const keep = new Set([...input.branchHeadShas, ...input.releaseTagShas]);
|
|
||||||
const deletions: string[] = [];
|
|
||||||
const keptTags: string[] = [];
|
|
||||||
for (const version of input.versions) {
|
|
||||||
if (manifestVersion.test(version)) continue;
|
|
||||||
if (hashTag.test(version) && !keep.has(version)) deletions.push(version);
|
|
||||||
else keptTags.push(version);
|
|
||||||
}
|
|
||||||
return { deletions, keptTags };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectOrphanedManifests(input: {
|
|
||||||
referencedDigests: string[];
|
|
||||||
versions: string[];
|
|
||||||
}): string[] {
|
|
||||||
const referenced = new Set(input.referencedDigests);
|
|
||||||
return input.versions.filter((v) => manifestVersion.test(v) && !referenced.has(v));
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": ["config:recommended"],
|
|
||||||
"automerge": true,
|
|
||||||
"commitBody": "Release-Bump: {{{updateType}}}",
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"description": "Ory services share one release train - update kratos, keto and hydra together",
|
|
||||||
"matchDatasources": ["docker"],
|
|
||||||
"matchPackageNames": ["oryd/kratos", "oryd/keto", "oryd/hydra"],
|
|
||||||
"groupName": "Ory stack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Playwright runner and its browser image are version-locked - bump together",
|
|
||||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
|
||||||
"groupName": "Playwright"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"customManagers": [
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"description": "Pin the Renovate image the Renovate workflow runs",
|
|
||||||
"managerFilePatterns": [".gitea/workflows/renovate.yml"],
|
|
||||||
"matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"],
|
|
||||||
"depNameTemplate": "renovate/renovate",
|
|
||||||
"datasourceTemplate": "docker"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
|
||||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
|
||||||
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
|
||||||
"depNameTemplate": "node",
|
|
||||||
"datasourceTemplate": "docker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,10 @@
|
|||||||
- [x] CI/CD - Test on push to any branch except main. (`.gitea/workflows/ci.yml` runs `bash ci.sh`; the one-time act_runner setup it needs is documented in README → CI/CD.)
|
- [x] CI/CD - Test on push to any branch except main. (`.gitea/workflows/ci.yml` runs `bash ci.sh`; the one-time act_runner setup it needs is documented in README → CI/CD.)
|
||||||
- [x] CI/CD - Require PR to main and don't allow merge if tests does not pass. Only allow linear history and history that leaves the last commit hash on main the exact same as on the branch we just merged in. (Gitea branch protection on main + fast-forward-only merge style, set via API; documented in README → CI/CD.)
|
- [x] CI/CD - Require PR to main and don't allow merge if tests does not pass. Only allow linear history and history that leaves the last commit hash on main the exact same as on the branch we just merged in. (Gitea branch protection on main + fast-forward-only merge style, set via API; documented in README → CI/CD.)
|
||||||
- [x] CI/CD - Sync up to github after every successful merge to main, URL: git@github.com:larvit/plainpages.git - also note the true home top of the README. Force push to github, it should only ever be a mirror of the gitea.larvit.se repository. (`.gitea/workflows/mirror.yml` force-pushes main + tags over HTTPS with a dedicated account's PAT in the `MIRROR_GITHUB_TOKEN` secret; setup documented in README → CI/CD.)
|
- [x] CI/CD - Sync up to github after every successful merge to main, URL: git@github.com:larvit/plainpages.git - also note the true home top of the README. Force push to github, it should only ever be a mirror of the gitea.larvit.se repository. (`.gitea/workflows/mirror.yml` force-pushes main + tags over HTTPS with a dedicated account's PAT in the `MIRROR_GITHUB_TOKEN` secret; setup documented in README → CI/CD.)
|
||||||
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via the `DOCKER_REGISTRY_USER` variable + `DOCKER_REGISTRY_TOKEN` secret, retention via an org cleanup rule; documented in README → CI/CD.)
|
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` secrets, retention via an org cleanup rule; documented in README → CI/CD.)
|
||||||
- [x] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (`release.yml` on a `vX.Y.Z` tag pulls the commit-hash image and re-tags it `X.Y.Z`/`X.Y`/`X`/`latest`, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
|
- [ ] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed.
|
||||||
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
- [ ] CI/CD - Sync docker images to docker hub after each re-tag to git tags.
|
||||||
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
|
- [ ] CI/CD - Setup renovate bot.
|
||||||
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
|
|
||||||
- [ ] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret.
|
|
||||||
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
|
|
||||||
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
|
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
|
||||||
- [ ] Build and publish docker image as CI/CD.
|
- [ ] Build and publish docker image as CI/CD.
|
||||||
- [ ] Add i18n support.
|
- [ ] Add i18n support.
|
||||||
|
|||||||
+1
-1
@@ -24,5 +24,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["auto-release", "config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"]
|
"include": ["config", "examples/config", "examples/plugins", "plugins", "src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user