Welcome! This document covers the day-to-day workflow for anyone
contributing to the GW2Analytics monorepo (libs/gw2_core,
libs/gw2_evtc_parser, libs/gw2_analytics, libs/gw2_api_client,
apps/api, web).
The fastest path is the one-shot developer-onboarding target, which
checks your tools, bootstraps .env, brings up the infrastructure
with a per-service health-wait, syncs all monorepo deps (Python +
Node), applies Alembic migrations, and regenerates the OpenAPI
TypeScript client -- all in one command:
make dev-onboardEquivalent manual steps (only use these if you don't have GNU Make or prefer fine-grained control):
# 1. Install uv (Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Configure local app env (operator-side .env file)
cp .env.example .env
# 3. Bring up the infrastructure for end-to-end tests
docker compose up -d
# 4. Sync all monorepo deps (libs + apps + dev) and apply migrations
uv sync
uv run --directory apps/api alembic upgrade head
# 5. Install web deps (frozen lockfile) and regenerate the OpenAPI client
(cd web && pnpm install --frozen-lockfile && pnpm generate:api)
# 6. Install git hooks (pre-commit + pre-push)
uv run pre-commit install --hook-type pre-commit --hook-type pre-pushThe ordering differs from the historical manual list because
alembic upgrade head needs the running Postgres plus the
uv-synced Python env (alembic is installed via uv sync), and
pnpm generate:api needs the web/ deps present. make dev-onboard
sequences these explicitly; the manual list above mirrors that order.
If make dev-onboard turns out to be too aggressive for your local
workflow (e.g. you only need the Postgres driver, not MinIO), revert
all of the dev-onboard commits — not just the most recent one.
The SHA list is self-locating and the loop handles 1+ commits
transparently:
# Revert every commit whose subject line contains `dev-onboard` and
# that touched `Makefile`, newest first. Capped at 5 to prevent runaway
# loops on a corrupted history:
for _ in 1 2 3 4 5; do
sha=$(git log --oneline --grep='dev-onboard' Makefile | head -1 | cut -d' ' -f1)
[ -z "$sha" ] && break
git revert --no-edit "$sha" \
|| { echo >&2 "conflict on $sha — resolve manually, then continue the loop"; break; }
doneAfter the cascade-squash followup lands, every dev-onboard touch
collapses into ONE combined commit whose subject line typically does
not contain \u2018dev-onboard\u2019 (the new subject is whatever you
chose for the merged commit, e.g. \u2018ci: green-up cumulative suite\u2019).
In that state, the loop above returns without reverting anything —
fall back to reverting the most-recent Makefile commit:
# Post-cascade-squash fallback: pick the most-recent commit touching Makefile
# (which contains the merged dev-onboard changes among everything else):
git revert --no-edit $(git log --oneline -1 Makefile | cut -d' ' -f1)If neither variant reverts what you expected, run git log --oneline Makefile manually to see the history directly. The revert is safe to
discard later (git revert --abort or drop the follow-up commit
before pushing); the underlying behaviour (the manual 5-step setup
above) is unchanged.
The 60s health-wait per Docker service is implemented as a pure
POSIX date +%s-arithmetic loop (start=$$(date +%s); until <probe>; do [ $$(($$(date +%s) - start)) -ge 60 ] && exit 1; sleep 2; done).
It does not call GNU timeout — GNU timeout is not on stock
macOS (requires brew install coreutils + a gtimeout alias). This
keeps the target usable on a fresh macOS checkout with zero extra
dependencies, at the cost of two extra shell invocations per iteration
(date +%s once per loop).
libs/gw2_coreis the single source of truth for data shapes. Everything depends on it; it depends on nothing but Pydantic.- The parser is replaceable behind the
EvtcParserProtocol. We currently ship a pure-Python implementation (PythonEvtcParser); a Rust + PyO3 binding is anticipated but not in scope for the current slice. - The frontend never knows about EVTC internals, the parser, or the
database schema -- only the OpenAPI surface from
apps/api. - Each component evolves independently (
pyproject.tomlper lib/app).
The backend follows a strict dependency inversion: routes depend on services, services depend on repositories, repositories encapsulate SQLAlchemy queries. No layer reaches across boundaries.
Routes (routes/) — thin FastAPI declarations, no business logic
↓
Services (services/) — business logic orchestration
↓
Repositories (repositories/) — persistence abstraction
↓
ORM models (models/) — SQLAlchemy declarative models
Repository conventions:
get_by_*— returns a single ORM instance orNone.find_by_*— returns a collection.add_*—session.add()(caller commits).delete_*—session.delete()orsession.execute(delete(...)).- Repositories NEVER call
commit()— that's the service's responsibility (keeps transaction boundaries at the use-case level).
Service conventions:
- Each service function accepts a
Sessionas first parameter. - Services orchestrate multiple repository calls within one transaction.
- No ORM types leak into route responses — models are converted to Pydantic schemas in the route layer.
ADR process:
Architecture Decision Records live in docs/adr/. Each ADR
follows this template:
# ADR NNN — Title
## Contexte
Why the decision is needed.
## Décision
What was decided, including code examples or CLI commands.
## Conséquences
Trade-offs, migration notes, operational impact.
## Status
Accepté / Proposed / Deprecated
```Existing ADRs:
- ``001-repository-pattern.md`` — why and how we introduced
repositories between services and the ORM.
- ``002-service-layer.md`` — business logic extraction from
routes.
- ``003-boon-normalization.md`` — normalised ``fight_player_boons``
table design.
- ``004-keyset-pagination.md`` — cursor-based pagination for the
player list.
- ``005-streaming-performance.md`` — streaming uploads, iterator
parsing, JSONL streaming, and batch UPSERT.
### Migration chain invariants (one-way design)
Some alembic upgrades are intentionally not reversible. The
canonical cases:
- ``0014_strip_account_colon``: strips the parser-side ``:``
prefix from ``fight_agents.account_name`` and
``fight_player_summaries.account_name``. Downgrading would
require knowing whether each row originally came from the EVTC
parser (which warrants the prefix) vs a manual insert (which
doesn't), and that signal is lost on write since the persistence
layer normalises on insert. The migration's module docstring
spells this out and its ``downgrade()`` raises
``NotImplementedError`` so a careless ``alembic downgrade base``
fails loud instead of silently corrupting data.
- ``8b674a6a9cfc_phase3_schema_changes``: drops 9 SCAFFOLD /
transformation-output columns unconditionally. Restore on
``downgrade -1`` (all 9 are recreated), so the cost of a
single-step backward move is bounded; full-chain reversal
past this point is also bounded by the 0014 ``NotImplemented``
above.
If you need a full chain reversal (e.g. to recover from a botched
deployment), do it in **two moves**:
1. ``alembic downgrade 8b674a6a9cfc`` -- the rollback chain hits
``ea3023c87c0f`` first (the most-recent revision reverts
before ``8b674a6a9cfc`` walks back), so the ``Numeric`` agent_id
state and the 9 Phase 3 dropped columns are both restored in
a single command.
2. Pre-0014 recovery is operator-driven by design: the ``:``
prefix vs. bare-form distinction was lost on the original 0014
write (no per-row source marker survives), so re-prefixing from
a post-0014 backup risks double-prefixing or mis-attributing
manually-inserted rows. Use a known-good pre-v0.10.x snapshot
and treat any re-added prefixes as cosmetic; no automatic
``UPDATE`` recipe is safe here.
## Commit conventions
We follow [Conventional Commits](https://www.conventionalcommits.org/).
### Format
():
```| Type | When |
|---|---|
feat |
New feature visible to users |
fix |
Bug fix |
refactor |
Code change that neither fixes nor adds |
perf |
Performance improvement |
test |
Adding or fixing tests |
docs |
Documentation only |
chore |
Tooling, dependencies, build config |
ci |
CI configuration only |
revert |
Revert a previous commit |
Common: parser, analytics, api, web, core, hooks, ci,
infra, deps, readme, pyproject, gitignore.
- Imperative mood ("Add X", not "Added X").
- No trailing period.
- ≤72 characters; wrap the detail in the body.
- Wrap at 100 characters (matches
line-length = 100inpyproject.toml). - Explain what and why, not how (the diff shows the how).
We ship both for tooling redundancy:
!after type/scope. e.g.feat(api)!: ...BREAKING CHANGE:footer explaining the migration.
Either signal alone satisfies CC v1.0.0 §8; using both keeps changelog generators / commit parsers consistent.
Example:
feat(api)!: require credentials via env vars (S3_* and DATABASE_URL)
Operators must now set DATABASE_URL + S3_* env vars before running
uvicorn. The previous sentinel defaults have been removed; pytest-env
injects dev credentials at test time via [tool.pytest_env] in
pyproject.toml.
BREAKING CHANGE: callers previously relying on the hardcoded
localhost defaults will see a fail-fast ValueError on app startup
until they configure .env via `cp .env.example .env`.
Status: applied (2026-10-02). Set on main through the classic
branch-protection API — PUT /repos/:owner/:repo/branches/main/protection
— because gh ruleset is a GitHub-Enterprise CLI and the free plan
only exposes the equivalent Rulesets UI by hand. The table below is the
effective configuration; re-apply it with that endpoint if it is ever
reset (the private-flip appendix at the end of this file walks the
manual UI path).
| Setting | Value |
|---|---|
| Target branches | main only |
| Require a pull request before merging | Yes, 0 required approvals (PR-only) |
| Required status checks (9) | Lint Python, Test Python, Lint Web, Playwright (chromium), ARQ worker integration, DCO check, Build API image, Build Web image, Trivy filesystem scan |
| Require linear history | Yes (no merge commits) |
| Require deployments before merging | No (no deploys from main directly) |
| Block force pushes | Yes (enforce_admins, admin included) |
| Block branch deletion | Yes |
| Enforce for admins | Yes |
| Require status checks to be up to date | No (branches need not be rebased) |
CI runs automatically on pushes to main and PRs targeting main.
The required checks are intentionally limited to deterministic merge
gates and are all reported on pull_request, so none of them can leave
a PR permanently blocked by being skipped. Visual regression still runs
on PRs but is not required while the screenshot baselines remain under
refresh.
The same checks run locally via pre-commit and remotely on every push
and PR via GitHub Actions:
| Gate | pre-commit | ci.yml |
|---|---|---|
| trim trailing whitespace | ✅ | (auto-cached) |
| ruff check | ✅ | ✅ |
| ruff format | ✅ | ✅ |
mypy (--strict) |
✅ | ✅ |
| pytest | (manual) | ✅ |
| DCO check | (manual) | ✅ (PR-only) |
Run them yourself before pushing:
uv run ruff check
uv run ruff format --check
uv run mypy libs apps --no-incremental
uv run pytest --tb=shortThe pytest-suite runs against synthetic fixtures constructed in-memory;
the Postgres-dependent test_uploads_e2e.py self-skips via the
db_reachable fixture when the docker-compose database isn't running
on the runner.
- Every PR must include or update tests covering the change.
- Cross-field invariants on pydantic models need at least one explicit test (don't rely on indirect assertions across multiple test cases).
- Lenient-parser edge cases (empty / null / unprefixed inputs) must be locked down at unit-test level. Real-fixture integration tests are not a substitute for unit coverage of a parser quirk.
- Reuse existing
_build_*_recordhelpers in test files whenever available. Don't reinvent fixture assembly in a new test.
The web/tests/e2e/visual-regression.spec.ts spec pixel-diffs
the 8 tracked PNGs at docs/screenshots/ against fresh full-page
captures of the corresponding routes (shipped in v0.8.9
plan/003). The CI gate is PR-only ("Visual regression e2e
(PR only)" step in .github/workflows/ci.yml); pushes to main
don't pay the ~2-4 s of browser time.
Refresh the 8 PNGs at docs/screenshots/ whenever an intentional
UI change would otherwise fail the diff (e.g. a new colour on the
landing page, a layout shift on /fights, a re-rendered AG Grid
column, a new SVG asset on the player timeline). The procedure:
# 1. From web/, start the mock server (port 8080) + Next dev
# server (port 3000). The pnpm dev script auto-regenerates
# the OpenAPI client first.
(cd web && pnpm dev)
# 2. In another terminal, refresh + persist the baselines:
(cd web && pnpm screenshots --persist)
# This writes 8 PNGs to screenshots/ at the repo root
# (gitignored) AND mirrors them into docs/screenshots/
# (tracked) via the --persist flag.
# 3. Visually skim the 8 PNGs to confirm the change is
# intentional + looks correct (open them in your image
# viewer). Then commit them:
git add docs/screenshots/*.png
git commit -m "test(e2e): refresh 6 stale visual-regression baselines"Two tunable values, both in web/tests/e2e/visual-regression.spec.ts:
| Constant | Value | Meaning |
|---|---|---|
DIFF_THRESHOLD |
0.01 |
Total diff budget (1% of total pixel count). |
pixelmatch({ threshold }) |
0.05 |
Per-pixel color-difference tolerance (anti-aliasing). |
The 0.05 per-pixel tolerance was chosen empirically as the strictest value that still passes 8/8 against the committed baselines. A lower value (e.g. 0.02) would catch finer font-rendering drift at the cost of more false-positive CI failures from sub-pixel anti-aliasing differences between the baseline capture host + the spec capture host. A higher value (e.g. 0.1, pixelmatch's default) tolerates more drift but masks smaller intentional UI changes. See the spec's "Maintenance note" docstring for the full discussion.
When the spec fails:
- The Playwright report logs the diff ratio + diff pixel count
for each case, e.g.
landing differs from 01-landing.png by 1.34% (threshold: 1.00%, 17,356 of 1,296,000 pixels). - A diff PNG (a red highlight overlay on the changed pixels)
is written to
web/tests/e2e/.visual-regression-output/<baseline>(gitignored). The path is included in the failure message for direct access from the CI logs. - On CI, the same dir is uploaded as the
visual-regression-diffsartifact (7-day retention; gated onfailure() && github.event_name == 'pull_request'). Download it from the Actions run page → Artifacts section. - Compare the diff PNG to the committed baseline side-by-side to confirm whether the change is intentional (refresh baselines) or a regression (revert the offending commit).
If a diff > 0% surfaces on a non-PR run (i.e. locally
without changing any UI code), the most likely cause is
font-rendering drift between the capture host + the spec host.
Run pnpm screenshots --persist to re-baseline from the same
host the spec runs on, then commit the updated PNGs.
web/src/lib/api/schema.d.ts is the TypeScript contract the
web tier consumes from the backend's FastAPI app.openapi()
schema. Per v0.9.1 plan 008, the file is committed (un-gitignored)
and the CI Detect API client drift step
(git diff --exit-code -- web/src/lib/api/schema.d.ts) fails any
PR where the regenerated client differs from the committed
baseline.
When to regenerate: any backend PR that touches
apps/api/src/gw2analytics_api/routes/* -- a new endpoint, a
renamed parameter, an added/changed response field, a changed
response shape -- MUST regenerate the schema and commit it in
the same PR.
How:
# From web/ (runs `uv run python scripts/dump_openapi.py` then
# `pnpm exec openapi-typescript` then cleans up openapi.json):
cd web && pnpm generate:apiThen git add web/src/lib/api/schema.d.ts and commit the
regenerated file alongside the backend change. If a PR slips
through with a stale schema, the CI drift gate will fail on
the next CI run (after the gate starts working).
The intermediate openapi.json is gitignored and removed
automatically by the generate:api script chain. Only the
final web/src/lib/api/schema.d.ts ships.
apps/api/src/gw2analytics_api/routes/webhooks.py defines 3
discriminator generators: _generate_subscription_id,
_generate_secret, _generate_delivery_id. The encoding
convention (per v0.9.2 plan 009 Step 4) is:
- Path-parameter discriminators (
/webhooks/{id}): usebase64.urlsafe_b64encode-- standardb64encodeemits/and+which break FastAPI path-param matching. - Byte-only discriminators (HMAC secrets, never in a URL
path): use standard
base64.b64encode-- HMAC operates on bytes and format churn has migration cost for in-flight integrators. - UUID-based discriminators (
_generate_delivery_id): URL-safe by definition; no encoding needed.
When adding a new discriminator, classify it by where it
appears in the wire contract (path-param vs payload-only) and
pick the matching encoding. The 3 helper docstrings in
routes/webhooks.py mirror this rule for IDE-hover
discoverability.
- Branch off
main. - Make atomic commits (one logical change per commit).
- Push and open a PR.
- Required checks must pass before merge.
- Squash-merge once approved -- linear history.
When pushing, pre-commit runs trim-trailing-whitespace, ruff, and
the formatter for any file in the index. If it auto-fixes something,
add the fix to the same commit (not a follow-up) to keep each commit
self-contained.
Once a PR is squash-merged into main, delete the feature branch
to keep the branch list scannable. Two paths:
- GitHub UI (one click): PR page → scroll to the bottom → "Delete branch" button (appears immediately after the merge).
- CLI:
git push origin --delete <branch-name>from any local repo.
Dependabot-created branches (the dependabot/npm_and_yarn/... and
dependabot/uv/... ones) are auto-deleted after merge only if
the repository setting Settings → General → Pull Requests →
"Automatically delete head branches" is enabled. The setting lives at
the repo level, not in .github/dependabot.yml -- verify it is on
after opening the repo (otherwise dependabot branches accumulate one
per dep-update PR). Stale feature branches from past merges can be
pruned in a single sweep with:
# Lists + deletes every merged-into-main remote branch (read-only first):
git branch -r --merged main | grep -vE '^\s*origin/(main|HEAD)' | tee /tmp/merged-branches.txt
# Sanity-check the list, then delete (uncomment to actually run):
git push origin --delete $(awk '{$1=$1;print}' /tmp/merged-branches.txt | sed 's|^origin/||')The grep -vE excludes main + HEAD so a typo doesn't nuke
the canonical branch. The same script works for cleaning up
refactor/* / fix/* / feat/* branches whose PRs were
merged in past sprints.
We use semver with a scope suffix:
v0.X.Y-parser-- Phase 1 deliverables (parser only)v0.X.Y-analytics-prototype-- Phase 3 prototype buildsvX.Y.Z-web-- web/ component releases (frontend only, e.g.v0.3.0-webshipped the upload UI in isolation)vX.Y.Z-- full-stack releases (CI green + deployable). Theapps/api+libs/*backend renders here.
Tags are created locally with git tag -a and pushed with
git push origin <tag>. Release notes are then published via
gh release create <tag> --notes-file <path> --target main.### Known cosmetic noise
pnpm install may emit [ERR_PNPM_IGNORED_BUILDS] warnings
for sharp and esbuild even though we now set
dangerouslyAllowAllBuilds=true in web/.npmrc. pnpm's
success-with-warning contract keeps the install exit code at
0 (vitest + Next.js optimize work fine via the JS-only
fallback for esbuild, and sharp has a prebuilt-binary
download path that does not require a postinstall script).
- Local first install: run
pnpm approve-builds --allonce if you want per-dep approval; pnpm writes the entry to the user-level~/.npmrc. Not required. - CI: the
.github/workflows/ci.yml::lint-and-teststep usespnpm install --frozen-lockfiledirectly; thedangerouslyAllowAllBuildsflag inweb/.npmrcguarantees the install gate returns0on a fresh container (no user-level cache). If a future pnpm version starts returning non-zero again, add--ignore-scriptsto the install invocation -- verifynext buildimage optimisation still picks upsharp's prebuilt binary before flipping. - The
verify-deps-before-run: falseflag inweb/pnpm-workspace.yamlonly affectspnpm run/pnpm dev; it does not suppress the install-time verification gate.
ruff format(already auto-applied by the formatter hook).- Type hints mandatory on every public function/method. Use
from __future__ import annotationsat the top of each module. - Imports ordered by
ruff(Irule). - No bare
except:-- catch specific exception types. - One public class per module minimum (avoid "dump modules").
- Models use
frozen=Trueandextra="forbid"for stability.
This project is proprietary software, all rights reserved --
see LICENSE and NOTICE.md at the
repo root for the full legal text + a plain-language summary of
what is and isn't permitted.
Unlike MIT/Apache/GPL projects, this codebase does not accept outside contributions under a pre-existing open-source contribution agreement. Instead, the project operates on a Developer Certificate of Origin (DCO) model that mirrors the Linux kernel's:
-
You retain copyright in any contribution you submit. Submitting a Pull Request does not transfer your rights to the copyright holder.
-
You certify that the contribution is your original work (or you have authority to submit it under these terms) by appending a
Signed-off-by:line to every commit message. The line must use your real name + a reachable email address. Example:feat(parsers): handle new buff ID range Signed-off-by: Jane Contributor <jane@example.com> -
You grant an irrevocable, perpetual, worldwide, sublicensable license to the copyright holder to use, modify, sublicense, and redistribute the contribution under the project's proprietary
LICENSE, including the right to incorporate it into derivative works. This lets the copyright holder ship your contribution without asking for further paperwork, while you retain authorship credit + the right to use the same code in other projects of your own. -
You waive moral rights to the maximum extent permitted by applicable law. Many jurisdictions (France under CPI Article L121-1, the rest of the EU, and several non-EU copyright regimes) recognize moral rights (droit moral) that are distinct from economic copyright: the right of attribution (paternité -- to be credited as author), the right of integrity (to object to modifications that harm the author's reputation), the right of withdrawal / modification (to stop or modify distribution under certain conditions), and the right of respect of name, quality, and the work itself (L121-1: "droit au respect de son nom, de sa qualité et de son œuvre"). These rights are inalienable but waivable. By signing off on a contribution, you waive them to the maximum extent permitted by applicable law so the copyright holder can ship derivative works without renegotiating attribution waivers per change.
-
CI / branch protection may enforce the DCO sign-off before a PR can merge. Maintainers may also reject a contribution for any other reason (style mismatch, doesn't fit the project direction, etc.) without obligation to incorporate it.
# Tell git your real name + reachable email (used for Sign-off-by):
git config user.name "Jane Contributor"
git config user.email "jane@example.com"
# Always use ``git commit -s`` (or ``--signoff``) so the
# ``Signed-off-by:`` trailer is appended automatically.
git commit -s -m "feat(parsers): handle new buff ID range"DCO Signed-off-by: trailers are enforced by two complementary
mechanisms:
-
GitHub's native
Require contributors to sign off on web-based commits(repo setting) — covers commits authored via the github.com web editor. -
Inline bash DCO check (
.github/workflows/ci.yml, jobdco-check) — covers CLI / IDE commits (git commit -sfrom outside the web editor). Runs on every PR and validates each commit in the PR range for aSigned-off-by:trailer. Promoted torequired_status_checksin themainruleset (6 required checks total).
Both mechanisms must pass for a PR to merge. The inline bash
check uses git log + grep with zero external action
dependencies — the previous crazy-max/ghaction-dco@v1 action
was removed upstream, which motivated the migration.
The Signed-off-by: trailers are also verifiable by any
interested party from the git history itself without a separate
CLA database.
This section is kept for the case where the repo is ever
re-flipped from public back to private on a free GitHub
plan and the spending-limit block returns. As of v0.15.2 the
repo is public; the canonical reference for the 3 historical
private-mode workarounds is commit 8a1bfe4.
⚠ Critical: do steps 1-4 BEFORE the visibility flip in step 5.
The 4 hardcoded SECRETS_KEK: "YWFhYWFh..." env entries in
.github/workflows/ci.yml must be swapped for ${{ secrets.SECRETS_KEK }}
- a real repo secret before re-enabling the
push:trigger — otherwise the deterministicaaaa...test fixture lands in the public CI log on the first push, which is ugly first-impression hygiene.
# Step 1 — sanity-check license / metadata / file consistency.
# (no phantom-suffix text should be in tracked files)
git log --oneline origin/main -10
gh api repos/:owner/:repo/license # expect "Other (Proprietary -- All rights reserved)"
grep -nE 'PlaceholderEnd|Old1End|Old2End|DeferredInTextComment|DepartureComment' \
apps/api/pyproject.toml libs/gw2_analytics/pyproject.toml \
pyproject.toml CONTRIBUTING.md # expect NO output
# Step 2 — locate the hardcoded test KEK literals that step 4 will replace.
grep -nE 'SECRETS_KEK.*YWFh' .github/workflows/ci.yml
# Expect 4 matches (one per job env: dict in lint-python, test-python,
# lint-web, arq-integration).
# Step 3 — set the SECRETS_KEK repo secret FIRST so step 4 env refs
# resolve on the first push. Generate a fresh Fernet key + store it.
gh secret set SECRETS_KEK \
--body "$(uv run python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
# PASTE THE OUTPUT into your local .env file too. This is the SOLE
# key for ALL webhook-subscription ciphertexts — if lost, all rows
# become unreadable per the v0.10.0 plan 031 KEK flow.
# Step 4 — edit .github/workflows/ci.yml:
# a) replace the 4 hardcoded ``SECRETS_KEK: "YWFhYWFhYWFh..."`` entries
# with ``SECRETS_KEK: ${{ secrets.SECRETS_KEK }}``
# b) under the ``on:`` block, replace ``workflow_dispatch:`` with:
# push:
# branches: [main]
# pull_request:
# branches: [main]
# workflow_dispatch:
# Step 5 — flip the visibility NOW (push triggers are back, secret is set,
# but they cannot fire while the repo is private on the spending-limit
# cap). The flip is the moment CI is unblocked:
gh repo edit --visibility public --accept-visibility-change-consequences
# Step 6 — configure git sign-off locally (LOCAL repo config matches
# the DCO setup section at the top of this file). MUST run before
# step 7, otherwise ``git commit -s`` fails with "Please tell me
# who you are" on a fresh clone / CI runner.
git config user.name "Your Name"
git config user.email "your-real-email@example.com"
# Step 7 — verify CI runs end-to-end with the secret resolved, BEFORE
# applying the ruleset in step 8, so any CI failures surface without
# the ruleset in the way (iteration is cheaper). Uses ``git commit -s``
# (the **-s** flag) so git itself appends the ``Signed-off-by:``
# trailer from the ``user.name`` + ``user.email`` configured in
# step 6 — the canonical DCO-compatible commit pattern (avoids
# hand-typed trailers that bash word-splits on the embedded
# newline) and matches the ``-s`` pattern documented at the top
# of this file.
git commit --allow-empty -s -m "ci: smoke-test SECRETS_KEK secret resolution + 6-job public pipeline"
git push origin main
# All 6 jobs should now run on every push + PR, with SECRETS_KEK
# resolved from the repo secret (no ``YWFh...`` literal in the log).
# Step 8 — apply the branch-protection ruleset via the GitHub web UI:
# Settings → Rules → Rulesets → New branch ruleset, target = main.
# Enable these rules:
# - Require linear history (no merge commits)
# - Block force pushes (admin included)
# - Block branch deletion
# - Require status checks: pick the CI workflow's 6 jobs
# - **Require contributors to sign off on web-based commits** ← the
# GitHub-native DCO check for web-editor commits; pairs with
# the inline bash DCO check (see ``.github/workflows/ci.yml``,
# job ``dco-check``) to make DCO enforceable for ALL commits
# (web + CLI / IDE), not just web-editor ones
#
# **⚠ Caveat — the GitHub web-DCO toggle only covers commits
# authored via github.com's web editor.** For CLI / IDE commits
# (``git commit -s`` from outside the github.com editor), an
# inline bash DCO check is wired into ``.github/workflows/ci.yml``
# (job ``dco-check``, runs only on PRs). It iterates every commit
# in the range ``base.sha..head.sha`` and validates that a
# ``Signed-off-by:`` trailer is present. If absent, the job
# fails and the ruleset blocks the merge.
#
# The check is implemented as plain bash (``git log`` + ``grep``)
# rather than a third-party action. The previous action
# (``crazy-max/ghaction-dco@v1``) was removed upstream leaving
# the check broken silently; the inline approach has zero
# external dependencies and cannot disappear.
#
# (The ``gh ruleset`` CLI to apply rulesets programmatically is a
# GitHub-Enterprise feature; on the free plan the UI is the only
# path. The legacy ``gh api .../branches/main/protection`` PATCH
# endpoint works on free plans for the classic branch-protection
# fields — ``required_status_checks`` / ``enforce_admins`` /
# ``required_pull_request_reviews`` / ``required_signatures`` /
# ``linear_history`` — but not for the new Rulesets UI.)
# (Step 6b git-config block was moved up + renamed Step 6 to
# document the right execution order: git config MUST precede
# step 7's smoke-test commit; the ``--global`` flag was dropped
# in favour of LOCAL repo config to match the DCO setup section
# earlier in this file.)- Add a paid plan (Settings → Billing and plans).
- Set the spending limit > 0 (Settings → Billing → Plans and budget).
- Configure a self-hosted runner on a free cloud VM (e.g. Oracle
Cloud Always Free tier) -- the advisor plan stub
advisor-plans/011-nextjs-headers-fallback.mdcarries the legacy reasoning in case it is useful when picking the runner host.
The right "go-forward" answer is to keep the repo public
(unlimited free GitHub Actions minutes) and only re-flip to
private if there's a confidentiality reason strong enough to
justify the billing work. The license stance in LICENSE
(All Rights Reserved) does not require private visibility
to remain effective against idea theft.