diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..eef1aef --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,143 @@ +# pages -- publish README.md as the project website with Jekyll. +# +# The site at https://hexember.github.io/active-browser/ is README.md (served as +# index.html) plus the four root docs it links to: CONTRIBUTING.md, SECURITY.md, +# CODE_OF_CONDUCT.md and CHANGELOG.md, each rendered to .html, plus the +# theme's own assets/css/... files. That set is an allowlist. _config.yml's +# exclude: keeps everything else out of the Jekyll source, and the "Published set +# is exactly the allowlist" step below fails the build, so nothing is uploaded, +# if anything else ends up in _site. Jekyll copies every non-Markdown file it is +# not told to exclude verbatim, which is how the old site came to serve a copy +# of install.sh. +# +# install.sh must never be served from Pages. The only install URL is +# raw.githubusercontent.com/hexember/active-browser/main/install.sh. A second +# copy on the site goes stale silently, and the old site's copy advertised the +# old, unowned install domain. The post-build check also fails if that domain +# string appears anywhere in the site. +# +# The zero-third-party-dependencies guardrail governs the Swift app. The +# actions/* steps here are GitHub's own first-party actions, the same category +# as actions/checkout in ci.yml. Adding a third-party action needs a re-plan +# first, and it must then be pinned to a full commit SHA, not a tag. There is no +# Gemfile and no Ruby setup: jekyll-build-pages brings its own pinned +# github-pages gem. +# +# Pages is configured as build_type: workflow, so this file is the only thing +# that updates the site. Deleting it freezes the site at its last deploy, which +# is how the old site went stale. +# +# Pull requests run the same build and allowlist check, so a leaked file or a +# missing page fails on the PR, before merge. The deploy job runs only for a +# push to main and for workflow_dispatch; on a pull_request it is skipped. +# +# workflow_dispatch from a branch other than main fails at the deploy job, +# because the github-pages environment only accepts main. This is expected. +name: pages + +on: + push: + branches: [main] + paths: + - README.md + - CONTRIBUTING.md + - SECURITY.md + - CODE_OF_CONDUCT.md + - CHANGELOG.md + - _config.yml + - .github/workflows/pages.yml + pull_request: + paths: + - README.md + - CONTRIBUTING.md + - SECURITY.md + - CODE_OF_CONDUCT.md + - CHANGELOG.md + - _config.yml + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: {} + +jobs: + build: + # jekyll-build-pages is a Docker container action; it does not run on macOS. + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v6 + + - uses: actions/jekyll-build-pages@v1 + with: + source: ./ + destination: ./_site + + - name: Published set is exactly the allowlist + shell: bash + run: | + set -euo pipefail + fail=0 + + # 1. Nothing outside the allowlist. The theme's assets live under + # assets/{css,js,images,fonts}/; the repository's own assets/ is + # excluded and has no such subdirectories. + extra=$(cd _site && find . -type f | sed 's|^\./||' | sort \ + | grep -vE '^(index|CONTRIBUTING|SECURITY|CODE_OF_CONDUCT|CHANGELOG)\.html$' \ + | grep -vE '^README\.html$' \ + | grep -vE '^assets/(css|js|images|fonts)/' || true) + if [ -n "$extra" ]; then + echo "::error::files outside the published allowlist (add to _config.yml exclude:, or to this allowlist and paths:)" + printf '%s\n' "$extra" + fail=1 + fi + + # 2. Every published page exists and is non-empty. + for f in index CONTRIBUTING SECURITY CODE_OF_CONDUCT CHANGELOG; do + if [ ! -s "_site/$f.html" ]; then + echo "::error::_site/$f.html is missing or empty" + fail=1 + fi + done + + # 3. The old, unowned install domain appears nowhere. Case-sensitive on + # purpose: ActiveBrowser.app is the bundle name. + if hits=$(grep -rl 'activebrowser\.app' _site); then + echo "::error::the old install domain appears in the site" + printf '%s\n' "$hits" + fail=1 + fi + + # 4. Warning only: a missing button must not block replacing a stale site. + if [ -f _site/index.html ] && ! grep -q 'View on GitHub' _site/index.html; then + echo "::warning::index.html has no 'View on GitHub' button" + fi + + if [ "$fail" -ne 0 ]; then exit 1; fi + echo "published set matches the allowlist" + + - uses: actions/upload-pages-artifact@v5 + + deploy: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + # A running deployment is never cancelled mid-flight. Job-level, so PR + # builds never share this group and cannot cancel a queued main deploy. + concurrency: + group: pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db5da15..7a321a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,9 +73,16 @@ assets/ icon artwork (see assets/README.md) install.sh the curl installer Makefile build, bundle, install, release docs/ background notes -.github/workflows/ CI and release +.github/workflows/ CI, release, and the GitHub Pages site +_config.yml GitHub Pages (Jekyll): which docs are published ``` +`README.md` is also the website at , rebuilt +on every push to `main`. A link in README (or in CONTRIBUTING, SECURITY, CODE_OF_CONDUCT +or CHANGELOG) must be absolute or point to one of those five `.md` files, because anything +else 404s on the site. A new file meant for the site must also be added to the allowlist +check and `paths:` in `.github/workflows/pages.yml`. + ## Project history Three directories are **history, not instructions**: `tasks/`, `Project.md`, and diff --git a/Project.md b/Project.md index 798e7e1..0e84bfc 100644 --- a/Project.md +++ b/Project.md @@ -91,6 +91,7 @@ One PR per task; a task never spans phases. Suggested split (the planner may spl | 08 | 5 | *Browsers* include/exclude + last-item guard, *Fallback* radio | exclusion changes routing | | 09 | 6 | `make release`, `install.sh` | one-liner installs from a local zip | | 10 | 6 | `.github/workflows/release.yml` | tag builds and publishes assets | +| 18 | 6 | .github/workflows/pages.yml, _config.yml | pages workflow green; site shows README, no install.sh | ### Phase 1 — Core (`Core/`) @@ -187,6 +188,7 @@ Goal: a user with no toolchain runs one command and has ActiveBrowser in `/Appli Usage: `curl -fsSL https://raw.githubusercontent.com/hexember/active-browser/main/install.sh | sh` - `make release`: `make bundle`, then `ditto -c -k --keepParent build/ActiveBrowser.app build/ActiveBrowser.app.zip` and `shasum -a 256` → `build/SHA256SUMS`. - GitHub Actions `release.yml` on tag `v*`: `macos-latest` runner, `make release`, attach zip + `SHA256SUMS` to the Release with `gh release create`. +- Project site: `https://hexember.github.io/active-browser/` is `README.md` rendered by Jekyll (`jekyll-theme-cayman`) via `.github/workflows/pages.yml` on push to `main`. Only README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT and CHANGELOG are published (`_config.yml` `exclude:`, plus a post-build allowlist check in the workflow). `install.sh` is never served from Pages; the one install URL stays `raw.githubusercontent.com`. - Signing: ad-hoc for v1. `curl` does not set the quarantine attribute, so an ad-hoc-signed bundle opens without Gatekeeper prompts via `install.sh`. Browser downloads and Homebrew *do* quarantine; if those paths are added later, add `make sign` (Developer ID) and `make notarize` (`notarytool`) targets first. - Homebrew Cask: out of scope for v1. @@ -212,7 +214,9 @@ active-browser/ ├── Package.swift ├── Makefile ├── install.sh # Phase 6 +├── _config.yml # Phase 6: GitHub Pages (Jekyll) config ├── .github/workflows/release.yml # Phase 6 +├── .github/workflows/pages.yml # Phase 6: README → GitHub Pages ├── .claude/{agents,rules}/ # subagents + always-on rules ├── docs/skills.md ├── tasks/ # one file per task, from TEMPLATE.md (see CLAUDE.md) diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..1d2e2d8 --- /dev/null +++ b/_config.yml @@ -0,0 +1,49 @@ +# GitHub Pages (Jekyll) config. The site is README.md plus the root docs it +# links to; see .github/workflows/pages.yml. Every non-Markdown file not listed +# under exclude is published verbatim. + +title: "ActiveBrowser" +description: "A macOS menu bar agent that opens every link in the browser you were just using." +theme: jekyll-theme-cayman +repository: hexember/active-browser +show_downloads: false + +readme_index: + enabled: true + remove_originals: true + +optional_front_matter: + remove_originals: true + +include: # these two are on jekyll-optional-front-matter's filename blacklist; include: whitelists them + - CONTRIBUTING.md + - CODE_OF_CONDUCT.md + +relative_links: + enabled: true + collections: false + +titles_from_headings: + enabled: true + strip_title: true + +# Names starting with ".", "_" or "#" are already skipped by Jekyll. Each entry +# is matched as a pattern or a path prefix, so never add a short prefix that +# could swallow a published file (e.g. "C" would exclude CONTRIBUTING.md). +exclude: + - tasks/ + - docs/ + - assets/ + - Sources/ + - Support/ + - build/ + - Project.md + - CLAUDE.md + - Makefile + - Package.swift + - Package.resolved + - install.sh + - LICENSE + - Gemfile + - Gemfile.lock + - vendor/ diff --git a/tasks/18-pages-readme.md b/tasks/18-pages-readme.md new file mode 100644 index 0000000..fc89703 --- /dev/null +++ b/tasks/18-pages-readme.md @@ -0,0 +1,286 @@ +# Task 18 — Publish the README to GitHub Pages with Jekyll + +Status: pr-open +Phase: 6 +Branch: chore/pages-readme +Base: chore/readme-restructure +PR: https://github.com/hexember/active-browser/pull/30 +Created: 2026-09-23 + +## Goal +Add a first-party GitHub Actions workflow that builds `README.md` (as `index.html`) and the four root docs it links to with Jekyll, then deploys them to https://hexember.github.io/active-browser/ on every relevant push to `main`. Merging the stack then replaces the stale site, which still advertises an install domain nobody here owns and still serves `/install.sh`. No repository settings change. + +## Spec (planner) + +Scope: two new files (`.github/workflows/pages.yml`, `_config.yml`) plus doc updates in `Project.md`, `CONTRIBUTING.md` and `CHANGELOG.md`. There are no Swift, `install.sh`, Makefile, `ci.yml`, `release.yml` or `README.md` changes. Both new paths sit outside `Project.md` §4, so this plan is the guardrail §3 re-plan: the §4 tree is updated in this same PR (item 3). + +Published set (the allowlist that every check below enforces): `README.md` → `/` (`index.html`), `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `CHANGELOG.md` → `.html`, plus the theme's own `assets/css/…`. Nothing else. Specifically **not** published: `install.sh`, `LICENSE` (README already links it absolutely, per task 17's final note), `Project.md`, `CLAUDE.md`, `tasks/`, `docs/`, `assets/` (repo artwork), `Sources/`, `Support/`, `Makefile`, `Package.swift`. + +Planner-verified platform facts (the implementer relies on these; the PR-time Actions run in `ai` step 10b proves them — amended 2026-09-23, was user step 11): +- `actions/jekyll-build-pages@v1` runs the `github-pages` gem (Jekyll 3.x, kramdown GFM), so the gem's always-on plugins apply: **jekyll-readme-index** (README becomes `index.html` when there is no `index.md` or `index.html`; its permalink is rewritten to `/`, so no `README.html` should appear), **jekyll-relative-links** (`[x](CONTRIBUTING.md#build-and-run)` → `/active-browser/CONTRIBUTING.html#build-and-run`; this handles `.md` targets only, which is why `LICENSE` is absolute), **jekyll-optional-front-matter** (`.md` files need no front matter), **jekyll-titles-from-headings**, **jekyll-default-layout** and **jekyll-github-metadata** (supplies `site.github.*` and the `/active-browser` baseurl fallback). We still set the relevant keys explicitly in `_config.yml`, so the intent is visible and does not depend on gem defaults. + - **Correction 2026-09-23 (review):** "`.md` files need no front matter" is wrong for two of the five files, and "no `README.html` should appear" understated what gets copied. The image pins `github-pages` 232, which uses `jekyll-optional-front-matter` 0.3.2 and `jekyll-readme-index` 0.3.0. optional-front-matter skips any file whose basename is in its `FILENAME_BLACKLIST` (`README LICENSE LICENCE COPYING CODE_OF_CONDUCT CONTRIBUTING ISSUE_TEMPLATE PULL_REQUEST_TEMPLATE`) unless the file is listed in `include:`, so `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md` are never rendered without it. Both plugins also default `remove_originals: false`, so every source `.md` is copied verbatim next to its `.html`. The live site shows both effects today. The fix is in Amendment A1 below: `include:` for exactly those two files, and `remove_originals: true` for both plugins. `README.md` must **not** be in `include:`, because README is handled by readme-index and including it would also emit `README.html`. Plugin priority makes this work: optional-front-matter (`:normal`) runs before readme-index (`:low`), which runs before relative-links (`:lowest`). +- Jekyll copies **every non-Markdown file verbatim** as a static file unless it is excluded. That is why the old site served `/install.sh`. Jekyll 3 already skips entries whose name starts with `.`, `_` or `#` (so `.github/`, `.claude/`, `.build/`, `.gitignore` and `_config.yml` are never published). Everything else must be in `exclude:`. Jekyll 3 matches an `exclude` entry as an fnmatch pattern **or a path prefix** (`File.fnmatch?` ‖ `start_with?`), relative to the source root. +- Liquid runs on every `.md` page, **including inside fenced code blocks**. The five published files currently contain no `{{` or `{%` (planner grepped). Test step 5 keeps it that way. +- **jekyll-theme-cayman** is a supported theme. Its `default` layout renders `View on GitHub` when `site.github.is_project_page` is true, which it is for `hexember.github.io/active-browser`. With `show_downloads` true it also shows source .zip/.tar.gz buttons, which would mislead users looking for the app, so it is set to false. For comparison: `minima` and `primer` have no repository button, and `slate`, `architect`, `minimal` and `midnight` also have one. Cayman is chosen because it is the most readable for a long single-page README. + +### Checklist +- [x] **`.github/workflows/pages.yml`** (new). Model it on GitHub's own "GitHub Pages Jekyll" starter workflow, with these requirements: + - `name: pages`. Header comment (same style as `ci.yml`/`release.yml`) that states: + - what the workflow publishes, and that the published set is the allowlist above; + - that `install.sh` must never be served from Pages, because the only install URL is `raw.githubusercontent.com/hexember/active-browser/main/install.sh`; + - that the zero-third-party-dependencies guardrail governs the Swift app, and that the `actions/*` steps here are GitHub's own first-party actions, the same category as `actions/checkout` in `ci.yml`. A third-party action would need a re-plan and SHA pinning (mirror `release.yml`'s wording); + - that Pages is configured as `build_type: workflow`, so this file is the only thing that updates the site. Deleting it freezes the site at its last deploy, which is how the old site went stale; + - that `workflow_dispatch` from a branch other than `main` fails at the deploy job, because the `github-pages` environment only accepts `main`. This is expected. + - The comment refers to "the old, unowned install domain" and never spells it. Test step 4 greps for the literal. + - `on:` → `push: branches: [main]` with `paths:` exactly `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `CHANGELOG.md`, `_config.yml`, `.github/workflows/pages.yml`, plus `workflow_dispatch:`. There are no `assets/` paths, because the site uses no repository assets. *(Extended 2026-09-23 by Amendment A2: add a `pull_request` trigger.)* + - Top-level `permissions: {}`, so every scope is none by default. Each job then declares its own: + - `build`: `contents: read` (checkout and github-metadata) and `pages: read` (`configure-pages` reads the Pages site). + - `deploy`: `pages: write` and `id-token: write` only. + - `concurrency: { group: pages, cancel-in-progress: false }`. A running deployment is never cancelled mid-flight. *(Moved 2026-09-23 by Amendment A2 from the top level to the `deploy` job.)* + - `build` job: `runs-on: ubuntu-latest` (`jekyll-build-pages` is a Docker container action and does not run on macOS), `timeout-minutes: 10`. Steps: + 1. `actions/checkout`. + 2. `actions/configure-pages`. + 3. `actions/jekyll-build-pages@v1` with `source: ./`, `destination: ./_site`. + 4. **"Published set is exactly the allowlist"** (`shell: bash`). This is a `run` step and must fail the job, so nothing is uploaded, if the site contains anything unexpected. Required logic: + - List every file under `_site` relative to it. Fail with `::error::` and print the offenders if any path does not match `^(index|CONTRIBUTING|SECURITY|CODE_OF_CONDUCT|CHANGELOG)\.html$`, `^README\.html$` (tolerated, not required) or `^assets/(css|js|images|fonts)/` (the theme's assets; the repository's own `assets/` has no such subdirectories). + - Fail if any of the five `.html` files is missing or empty. + - Fail if `grep -rl 'activebrowser\.app' _site` matches anything. Use a **case-sensitive** match: lowercase `activebrowser.app` is the unowned domain, while `ActiveBrowser.app` is the bundle name. + - Emit only a `::warning::`, not a failure, if `index.html` lacks `View on GitHub`. A missing button must not block replacing the stale site. + 5. `actions/upload-pages-artifact` (default `path: ./_site`). + - `deploy` job: `needs: build`, `runs-on: ubuntu-latest`, `timeout-minutes: 10`, `environment: { name: github-pages, url: ${{ steps.deployment.outputs.page_url }} }`, and one step `actions/deploy-pages` with `id: deployment`. *(Amended 2026-09-23 by A2: add the `if:` gate and the job-level `concurrency`.)* + - Pin every action to a **major tag**, never a SHA, branch or `@main`. Use `actions/checkout@v4` to match `ci.yml`, and `actions/jekyll-build-pages@v1`. For `configure-pages`, `upload-pages-artifact` and `deploy-pages`, use the current major: check with `gh api repos/actions//releases/latest --jq .tag_name` (floors: `configure-pages@v5`, `upload-pages-artifact@v3`, `deploy-pages@v4`), and record the chosen tags in Implementation Notes. Every `uses:` line must start with `actions/`. + - No `Gemfile`, no `Gemfile.lock`, and no Ruby setup step. `jekyll-build-pages` brings its own pinned `github-pages` gem, and a Gemfile would be the first dependency manifest in this repository. +- [x] **`_config.yml`** (new, repo root). Header comment: "GitHub Pages (Jekyll) config. The site is README.md plus the root docs it links to; see .github/workflows/pages.yml. Every non-Markdown file not listed under exclude is published verbatim." Keys: + - `title: "ActiveBrowser"` + - `description: "A macOS menu bar agent that opens every link in the browser you were just using."` (the README tagline, verbatim, without the bold markers). + - `theme: jekyll-theme-cayman` + - `repository: hexember/active-browser` (pins github-metadata so it does not have to guess). + - `show_downloads: false` + - `readme_index: { enabled: true }` and `relative_links: { enabled: true, collections: false }` (the defaults, made explicit). + - `titles_from_headings: { enabled: true, strip_title: true }`, so Cayman's header shows each page's H1 and the body doesn't repeat it. + - `exclude:` as a **block list, one entry per line** (test step 3's simulation parses that form). Entries: `tasks/`, `docs/`, `assets/`, `Sources/`, `Support/`, `build/`, `Project.md`, `CLAUDE.md`, `Makefile`, `Package.swift`, `Package.resolved`, `install.sh`, `LICENSE`, `Gemfile`, `Gemfile.lock`, `vendor/`. Dot-directories and `_`-prefixed entries are already skipped by Jekyll. List them only if the implementer finds they are needed, and say why. Do **not** use a broad prefix that could swallow a published file: Jekyll's prefix semantics mean an entry like `C` or `CO` would exclude `CONTRIBUTING.md`. + - No `baseurl` or `url` (github-metadata derives both, and a hardcoded `/active-browser` breaks if a custom domain is ever added). No `plugins:` list (the defaults suffice). ~~No `include:`.~~ *(Superseded 2026-09-23 by Amendment A1: `include:` lists exactly `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`.)* +- [x] **`Project.md`**: + - §3 split table: add a row after `10` reading `| 18 | 6 | .github/workflows/pages.yml, _config.yml | pages workflow green; site shows README, no install.sh |`. Tasks 11–17 are not backfilled here (out of scope). + - §3 Phase 6: add a bullet before "Signing". It says: the project site at `https://hexember.github.io/active-browser/` is `README.md` rendered by Jekyll (`jekyll-theme-cayman`) via `.github/workflows/pages.yml` on push to `main`; only README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT and CHANGELOG are published (`_config.yml` `exclude:`, plus a post-build allowlist check); `install.sh` is never served from Pages; the one install URL stays `raw.githubusercontent.com`. Do **not** write the old domain. + - §4 tree: add `├── _config.yml # Phase 6: GitHub Pages (Jekyll) config` below `install.sh`, and `├── .github/workflows/pages.yml # Phase 6: README → GitHub Pages` below `release.yml`, with the comments aligned to the existing column. +- [x] **`CONTRIBUTING.md`**, section `## Repository layout` only. In the code block, change `.github/workflows/ CI and release` to `.github/workflows/ CI, release, and the GitHub Pages site`, and add a line `_config.yml GitHub Pages (Jekyll): which docs are published`, aligned with the others. Below the block, add one short paragraph: `README.md` is also the website at `https://hexember.github.io/active-browser/`, rebuilt on every push to `main`. A link in README (or in CONTRIBUTING, SECURITY, CODE_OF_CONDUCT or CHANGELOG) must be absolute or point to one of those five `.md` files, because anything else 404s on the site. A new file meant for the site must be added to `pages.yml`'s allowlist check and `paths:` as well. Do not change any heading, because README links `CONTRIBUTING.md#build-and-run`. +- [x] **`CHANGELOG.md`**: under `## [Unreleased]` → `### Fixed`, add one bullet as the first item: "**The project website showed an outdated install command** on a domain this project does not own, and served a stale copy of `install.sh`. The site at https://hexember.github.io/active-browser/ is now rebuilt from the README on every change, and the only install command is the `raw.githubusercontent.com` one-liner." The bullet **must not contain the old domain string**: CHANGELOG is published, and both the post-build check and test step 4 would fail on it. Nothing else in the file changes. + +Acceptance criteria: +- `pages.yml` contains only `actions/*@vN` actions, has `permissions: {}` at the top with per-job scopes exactly as above, `concurrency.group: pages` with `cancel-in-progress: false`, a push-to-`main` trigger with the seven listed paths plus `workflow_dispatch`, and the allowlist step between build and upload. +- `_config.yml` parses as YAML. Applying Jekyll 3's filtering to the tracked tree leaves exactly the five `.md` files (test step 3). `install.sh`, `LICENSE`, `Project.md`, `CLAUDE.md`, `tasks/`, `docs/`, `assets/`, `Sources/`, `Support/`, `Makefile` and `Package.swift` are all excluded. +- No published file contains `activebrowser.app` (case-sensitive), `{{` or `{%`. Every relative link in a published file targets a published file. +- `Project.md` has the §3 row, the Phase 6 bullet and both §4 tree entries. The CONTRIBUTING layout block and paragraph are present, with no heading changed. CHANGELOG has the one added bullet and no removed lines. +- The diff against `chore/readme-restructure` touches only `.github/workflows/pages.yml`, `_config.yml`, `Project.md`, `CONTRIBUTING.md`, `CHANGELOG.md` and task files. `swift build` has no errors or warnings. + +### Architectural notes and risks +- **Guardrails.** Zero-deps (§1) governs the Swift app. The five first-party `actions/*` actions are the same category as `actions/checkout`, already used in `ci.yml` and `release.yml`, so this is not a new dependency class. There is no Gemfile, Ruby setup or third-party action. The §4 re-plan trigger is satisfied by updating §4 in this PR. The app, its bundle and its runtime are untouched, so every runtime guardrail is n/a. +- **Why an exclude list plus an allowlist check.** Jekyll has no native allowlist: any future root file (a script, a plist) would be published by default. The requirement asks for `exclude:`, so we keep it. The post-build step in `pages.yml` turns an omission into a failed build instead of a silently published file. A stricter alternative would stage the five files into a temporary source directory and build that. It was not chosen because it hides the config from anyone who reads `_config.yml`. Revisit it if the allowlist step ever fires. +- **The first deploy is the fix, and a failed first run leaves the stale site live.** For that reason the allowlist regex already tolerates `README.html` and the theme's `assets/{css,js,images,fonts}/`, and a missing "View on GitHub" button is only a warning. Only a genuinely leaked file or the old domain string fails the build. +- **Merge order.** Merge #29 first; GitHub retargets this PR to `main`. Then merge this PR. The push that merges #29 does not trigger Pages, because `pages.yml` is not yet on `main`. The merge of this PR changes `pages.yml`, which matches `paths:`, so it triggers the first deploy. If this PR is instead merged into `chore/readme-restructure` before #29, the site updates when #29 lands. That also works, but it's not the recommended order. +- **Until the merge, the live site keeps advertising the unowned domain and serving `/install.sh`.** Nothing in this PR can change that sooner without a settings change or a manual deploy, and the user has ruled out settings changes. `cname` is `null`, so the site itself is not bound to that domain. The exposure is the advertised `curl … | sh` command, which the deploy removes. Test step 8 records that live state as the baseline. +- **`pages: read` on the build job** is the least privilege that `configure-pages` needs to read the site. If the first run fails at `configure-pages` with HTTP 403, raise the build job to `pages: write`. That change must be a recorded deviation, not a silent one. +- **Anchors.** kramdown's GFM parser generates GitHub-compatible heading IDs, so `#build-and-run`, `#what-youre-trusting-when-you-install-this` and `#privacy` should resolve on the site. User step 14 verifies this. +- **Superseded:** the `feature/landing-page` idea in task 16's Next, and closed PR #28 (`site/`). Old tasks 01–16 still show `pr-open`/`in-review`, which is stale bookkeeping; none of them touches Pages. +- **Out of scope:** §4 still omits `ci.yml` and the root community docs (noted in task 17). A local Jekyll toolchain is also out of scope. + +### Amendment 2026-09-23 (planner, after review CHANGES_REQUESTED) +The items above marked *Superseded*, *Extended*, *Moved* or *Amended* are replaced by the items below. Everything else in the Spec stands. The implementer records each item as a dated entry in Implementation Notes. This is a planner-sanctioned change, not an implementer deviation. + +- [x] **A1. `_config.yml`**: apply the reviewer's fix. + - Add `include:` as a **block list** (test step 3's simulation parses that form, not `[a, b]`) with exactly `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`. Put a one-line comment on it: these two are on jekyll-optional-front-matter's filename blacklist, and `include:` whitelists them. **Do not** list `README.md`: readme-index already renders it as `index.html`, and including it would also emit `README.html`. + - Add `optional_front_matter:` with `remove_originals: true`. + - Under the existing `readme_index:`, keep `enabled: true` and add `remove_originals: true`. + - Keep `exclude:` unchanged. Keep the allowlist regex in `pages.yml` strict: it must **not** be widened to accept raw `.md` files. +- [x] **A2. `.github/workflows/pages.yml`**: build on PRs, deploy only from `main`. + - `on:` gains `pull_request:` with the **same seven `paths:`** as `push` and **no `branches:` filter**. Stacked PRs target non-`main` bases (this PR targets `chore/readme-restructure`), so a branch filter would skip exactly the PRs that need the check. `push: branches: [main]` and `workflow_dispatch:` are unchanged. + - `build` runs on every event, unchanged: checkout, configure-pages, jekyll-build-pages, the allowlist check, upload-pages-artifact. Uploading the artifact on a PR is intended, because test step 10b inspects it. + - `deploy` gains `if: github.event_name != 'pull_request'`, so a PR run shows `deploy` as *skipped* and never deploys. (The `github-pages` environment would reject a non-`main` ref anyway, but that is a failure rather than a skip, and it must not be the gate.) + - **Move `concurrency: { group: pages, cancel-in-progress: false }` from the top level to the `deploy` job.** Reason: GitHub keeps at most one *pending* run per concurrency group and cancels the older pending one. With a top-level group shared by PR builds and `main` deploys, a PR push that arrives while a `main` deploy is queued would cancel that deploy silently. Deploy-level concurrency still serialises deployments and never cancels one mid-flight. Build jobs, which only produce an artifact, may run in parallel. There is no top-level `concurrency:`. The existing "A running deployment is never cancelled mid-flight." comment moves with the key. + - Header comment: add one short paragraph saying that pull requests run the same build and allowlist check (so a leak or a missing page fails on the PR, before merge) and that `deploy` runs only for `push` to `main` and `workflow_dispatch`. + - No new actions and no permission changes: `build` keeps `contents: read` + `pages: read`, which is enough for `configure-pages` on a PR. `uses:` count stays at 5. + +Acceptance criteria (amendment): +- `_config.yml` has `include:` listing exactly `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md` (block form, without `README.md`), `optional_front_matter.remove_originals: true`, and `readme_index` with both `enabled: true` and `remove_originals: true`. Test step 3 (amended) passes, and its negative run fails. +- `pages.yml`: `on` has `push` (branches `[main]`), `pull_request` with paths identical to `push`'s and no `branches`, and `workflow_dispatch`. There is no top-level `concurrency`. `deploy` has the `if:` gate and `concurrency: { group: pages, cancel-in-progress: false }`. The allowlist regex is unchanged. +- On this PR, the `pages` run shows `build` green and `deploy` skipped, and the `github-pages` artifact holds exactly the expected site (test step 10b). + +Risks (amendment): +- **Artifact retention.** `upload-pages-artifact` keeps the artifact for 1 day by default. Step 10b must run right after the PR opens. If the artifact has expired, re-run the workflow with `gh run rerun ` and retry. +- **Fork PRs.** The PR build uses a read-only token and needs no secrets, so fork PRs work. Deploy is skipped on every PR regardless of source. +- **Step 10b runs after the PR is open**, which is an exception to "every `ai` step runs before the PR opens". It is the only step that exercises the real `github-pages` gem. If it fails, the fix goes as a follow-up commit on this branch (it is still the tip, since the main session runs 10b before starting the next task). The PR must not be handed off as ready until 10b passes. +- **`include:` replaces Jekyll's default `include` (`.htaccess`).** There is no `.htaccess` in the repository, so nothing changes. + +## Implementation Notes (implementer) +2026-09-23: +- **`.github/workflows/pages.yml`** (new): `build` job (checkout, configure-pages, jekyll-build-pages, allowlist check, upload), then `deploy` job. Top-level `permissions: {}`; build gets `contents: read` + `pages: read`; deploy gets `pages: write` + `id-token: write`. `concurrency: pages`, `cancel-in-progress: false`. Push to `main` filtered on the seven paths, plus `workflow_dispatch`. The header comment covers all five required points and never spells the old domain (it says "the old, unowned install domain"). +- **Action tags** (from `gh api repos/actions//releases/latest` on 2026-09-23): `configure-pages` v6.0.0 → `@v6`; `upload-pages-artifact` v5.0.0 → `@v5`; `deploy-pages` v5.0.1 → `@v5`; `jekyll-build-pages@v1` (latest v1.0.13); `checkout@v4`, as the spec requires, to match `ci.yml` (latest is v7, not adopted). +- **Allowlist step**: lists `_site` from inside a `cd _site` subshell, so paths stay relative and the body still works when `_site` is replaced with another path (step 7). It applies the three allowed patterns as successive `grep -v` calls. Each check sets `fail=1` instead of exiting straight away, so a single run reports every problem. The old-domain check is the case-sensitive regex `activebrowser\.app`. A missing "View on GitHub" button only produces `::warning::`. The step exits 1 if any check set `fail`. +- **`_config.yml`** (new): the keys exactly as specified, and `exclude:` as a block list with the 16 entries. No dot-directory or `_` entries were needed: `.vscode/`, `.github/`, `.claude/` and `.build/` are all skipped by Jekyll's special-name rule, and the simulation confirms this. There is no `baseurl`, `url`, `plugins:` or `include:`. I added a short comment above `exclude:` warning against broad prefixes. +- **`Project.md`**: added the §3 row verbatim from the spec (no backticks, to match the spec text exactly), the Phase 6 "Project site" bullet before "Signing", and both §4 tree entries with their comments aligned at column 38. +- **`CONTRIBUTING.md`**: in the layout block, changed the `.github/workflows/` line and added the aligned `_config.yml` line. Added the paragraph after the block. No headings changed. +- **`CHANGELOG.md`**: one bullet, placed first under `[Unreleased]` → `### Fixed`. It does not contain the old domain string. +- No deviations from the spec. No Gemfile. No changes to Swift, `install.sh`, the Makefile, `ci.yml`, `release.yml` or `README.md`. + +Implementer self-check of the `ai` steps (for the main session to confirm): +1. `jekyll` is absent, `github-pages` gem is `false`, and `bundle`/`ruby` are present (rbenv). There is no local github-pages Jekyll, so the real build is proven by user step 11. +2. Ruby YAML: both files parse. `permissions` is `{}`. Build is `{"contents"=>"read","pages"=>"read"}`. Deploy is `{"pages"=>"write","id-token"=>"write"}`, with needs `build` and env `github-pages`. Concurrency is as specified. `on` has push.branches `["main"]`, all 7 paths and `workflow_dispatch`. Both jobs run on `ubuntu-latest`. Pass. +3. The simulation published exactly the 5 files. `problems: 0`, exit 0. Pass. +4. No matches, `exit=1`. The informational grep of `Project.md` and `.github/` printed nothing. Pass. +5. No Liquid tags, `exit=1`. Pass. +6. Exactly 5 `uses:` lines, all `actions/*@vN`. The filter printed nothing (`exit=1`), cancel-in-progress count is 1, `install.sh` appears only in header comments (lines 11, 13, 14), and neither Gemfile exists. Pass. +7. I extracted the step's `run` body via Ruby YAML and replaced `_site` with the fake path. Run 1 exited 0 with the "View on GitHub" warning only. Run 2 exited 1 and named `install.sh`. Run 3 exited 1 on the old-domain check. Pass. +8. Live baseline: count `2`, `/install.sh` returns `200`. Recorded. +9. Only the two known CommandLineTools `ld` search-path warnings appeared, then `Build complete!`. Pass. +10. Tracked diff vs `chore/readme-restructure`: `CHANGELOG.md`, `CONTRIBUTING.md`, `Project.md`. Untracked: `pages.yml`, `_config.yml`, the task file. The only removed line is CONTRIBUTING's `.github/workflows/ CI and release`, and headings are identical to the base. Pass. + +Build: `swift build` clean (only the known CommandLineTools `ld` search-path warnings; `Build complete!`) · `make bundle` ok (`Signature=adhoc`) + +2026-09-23 (Amendment A1/A2, after review CHANGES_REQUESTED. Planner-sanctioned, not an implementer deviation): +- **A1 `_config.yml`**: `readme_index` now has `enabled: true` and `remove_originals: true`. Added `optional_front_matter: { remove_originals: true }`. Added `include:` as a block list with exactly `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`, and a same-line comment saying both are on jekyll-optional-front-matter's filename blacklist and that `include:` whitelists them. `README.md` is not included. `exclude:` is unchanged, and the allowlist regex in `pages.yml` is unchanged (still strict, so no raw `.md` is accepted). +- **A2 `pages.yml`**: `on:` gains `pull_request:` with the same seven `paths:` as `push` and no `branches:`. `push: branches: [main]` and `workflow_dispatch:` are unchanged. The top-level `concurrency:` is removed. `deploy` gains `if: github.event_name != 'pull_request'` and job-level `concurrency: { group: pages, cancel-in-progress: false }`. The "never cancelled mid-flight" comment moved with it, plus one line explaining why the key is job-level. The header gains a paragraph: PRs run the same build and allowlist check, and deploy runs only for a push to `main` and `workflow_dispatch`. There are no new actions and no permission changes, and `uses:` count is still 5. + +Re-run of `ai` steps 1–10 after the amendment (10b needs the open PR, so it was not run): +1. `jekyll` absent, `github-pages` gem `false`, `bundle`/`ruby` present (rbenv). There is no local github-pages Jekyll, so the real build is proven by step 10b. +2. `include` is `["CONTRIBUTING.md", "CODE_OF_CONDUCT.md"]`, `optional_front_matter` is `{"remove_originals"=>true}`, and `readme_index` is `{"enabled"=>true, "remove_originals"=>true}`. Top-level `permissions` is `{}` and top-level `concurrency` is `nil`. `on` has push (branches `["main"]`, 7 paths), pull_request (7 paths, no `branches`) and `workflow_dispatch`. The paths-equality check prints `true`. For `build`: `ubuntu-latest`, `{"contents"=>"read","pages"=>"read"}`, and `if`/`concurrency` both `nil`. For `deploy`: `ubuntu-latest`, `{"pages"=>"write","id-token"=>"write"}`, needs `build`, env `github-pages`, `if` is `github.event_name != 'pull_request'`, and concurrency is `{"group"=>"pages","cancel-in-progress"=>false}`. Pass. +3. (a) include prints `['CONTRIBUTING.md', 'CODE_OF_CONDUCT.md']`, published is exactly the 5 files, `problems: 0`, `exit=0`. (b) The negative config prints the blacklist line for both CODE_OF_CONDUCT.md and CONTRIBUTING.md, the `include:` line, and `remove_originals is not true` for both plugins. `problems: 5`, `exit=1`. Pass. +4. No matches (`exit=1`). The informational grep of `Project.md` and `.github/` printed nothing. Pass. +5. No Liquid tags (`exit=1`). Pass. +6. 5 `uses:` lines, all `actions/*@vN`. The filter printed nothing (`exit=1`), the cancel-in-progress count is 1, `install.sh` appears only in header comments (lines 11, 13, 14), and neither Gemfile exists. Pass. +7. The run body was extracted from the YAML via Ruby, with `_site` replaced. Run 1 `exit=0` (View on GitHub warning only). Run 2 `exit=1` and names `install.sh`. Run 3 `exit=1` on the old-domain check. Run 4 (live-site shape) `exit=1`: it lists all five `.md` as outside the allowlist and reports `CONTRIBUTING.html` and `CODE_OF_CONDUCT.html` missing. Pass. +8. Live baseline: count `2`, `/install.sh` returns `200`. Recorded. +9. Only the two known CommandLineTools `ld` search-path warnings, then `Build complete!`. Pass. +10. Tracked diff vs `chore/readme-restructure`: `CHANGELOG.md`, `CONTRIBUTING.md` and `Project.md`. Untracked: `pages.yml`, `_config.yml` and the task file. CHANGELOG has no removed lines. CONTRIBUTING's only removed line is `.github/workflows/ CI and release`. Heading text is identical to the base; only line numbers shift, because of the added paragraph. Pass. + +Build: `swift build` clean (only the known CommandLineTools `ld` search-path warnings; `Build complete!`) · `make bundle` ok (`codesign --force --sign -` succeeded) + +## Review (code-reviewer) +Verdict: CHANGES_REQUESTED +- **Blocker. `_config.yml:11-16` (no `include:`, no `remove_originals`): the first `pages` run fails at "Published set is exactly the allowlist", so the stale site stays live.** The spec's statement that "`.md` files need no front matter" is wrong for two of the five files. The image pins `github-pages` 232, which uses `jekyll-optional-front-matter` 0.3.2 and `jekyll-readme-index` 0.3.0. `jekyll-optional-front-matter` 0.3.2 has a `FILENAME_BLACKLIST` of `README LICENSE LICENCE COPYING CODE_OF_CONDUCT CONTRIBUTING ISSUE_TEMPLATE PULL_REQUEST_TEMPLATE`. A blacklisted file is skipped unless it is listed in `include:`. So `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md` never become pages: no `.html` is produced, and `jekyll-relative-links` leaves links to them pointing at the raw `.md`. Both plugins also default `remove_originals` to false, so every source `.md` is copied verbatim next to its `.html`. The live site, built by the same gem stack, shows this today: `/README.md` 200, `/CONTRIBUTING.md` 200, `/CONTRIBUTING.html` 404, `/CODE_OF_CONDUCT.html` 404, `/SECURITY.md` 200, `/SECURITY.html` 200. On the first run, `_site` will therefore contain `README.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` and `CHANGELOG.md` (all outside the regex at `pages.yml:81-84`), and `CONTRIBUTING.html` and `CODE_OF_CONDUCT.html` will be missing. Check 1 and check 2 both fail, and nothing deploys. **Required change** in `_config.yml`: add + ```yaml + include: # whitelists them past jekyll-optional-front-matter's filename blacklist + - CONTRIBUTING.md + - CODE_OF_CONDUCT.md + optional_front_matter: + remove_originals: true + ``` + and add `remove_originals: true` under `readme_index:`. Do **not** add `README.md` to `include:`. If you did, optional-front-matter would also emit `README.html`, and README is already handled by readme-index. Plugin order makes this work: optional-front-matter (`:normal`) runs before readme-index (`:low`), which runs before relative-links (`:lowest`). This contradicts the spec's "No `include:`", so the implementer must record it as a dated deviation, and the planner should append a correction to the Spec's platform-facts bullet. Keep the allowlist regex strict. Once this is fixed, raw `.md` files must not appear, and the check is right to reject them. +- **Test Steps 3 and 7 cannot detect the blocker.** PAGESSIM models only Jekyll's source `EntryFilter`, not the plugins' blacklist or originals behaviour. Step 7's fake `_site` is hand-made. Both passing tells us nothing about the real `_site`. Required: extend PAGESSIM so that it (a) fails if `CONTRIBUTING.md` or `CODE_OF_CONDUCT.md` is published but not listed in `include:`, and (b) fails unless `optional_front_matter.remove_originals` and `readme_index.remove_originals` are both `true`. Alternatively, add a step 7 run whose fake site holds exactly what the live site shows today (`README.md`, `CONTRIBUTING.md`, …, and no `CONTRIBUTING.html`) and expect a non-zero exit. As written, user step 11 is the first point where this fails, and by then the PR is merged. +- **Verified OK, no change needed:** + - All five tags exist: `checkout@v4`, `configure-pages@v6` (6.0.0), `jekyll-build-pages@v1` (1.0.13, image `ghcr.io/actions/jekyll-build-pages:v1.0.13`), `upload-pages-artifact@v5` (5.0.0, uses upload-artifact v7, default `name: github-pages`, `path: _site/`) and `deploy-pages@v5` (5.0.1). GitHub's starter workflow pairs `deploy-pages@v5` with `upload-pages-artifact@v3` and the same artifact contract, so v5 with v5 is compatible. + - `pages: read` is enough for `configure-pages`, which does a GET of the Pages site with no `enablement`, and for github-metadata's pages lookup. + - `_site` is root-owned but world-readable, so `find`, `grep` and the upload's `tar` work as the runner user. + - Cayman 0.2.0 ships only `assets/css/style.scss`, which becomes `assets/css/style.css`. Jekyll 3.10's `ThemeAssetsReader` does not apply `exclude:`, so `exclude: assets/` does not drop it. + - With no `plugins:` list there is no feed, sitemap or 404 output. + - `pages-gem` v232's `migrate_theme_to_remote_theme` is a no-op, so the theme stays a local gem. Markdown is kramdown GFM. + - Cayman's layout renders "View on GitHub" when `site.github.is_project_page` is true, and hides the downloads when `show_downloads` is false. + - The trailing-slash `exclude` entries (e.g. `tasks/`) do not match the directory itself in Jekyll 3.10 (`File.join` keeps the slash, so `start_with?` fails for `/src/tasks`). Every file inside does match, so nothing is emitted. The result is correct. + - The Project.md §3 row, the Phase 6 bullet and the §4 entries (aligned), the CONTRIBUTING block and paragraph (no heading changes), and the CHANGELOG bullet (no old-domain string) all match the spec. + - `swift build`: `Build complete!`, no warnings. `make bundle`: ok, ad-hoc signed. +- **Test Steps.** Steps 1, 2, 4, 5, 6, 8, 9 and 10 can run against the code as written. Steps 3 and 7 pass, but they do not prove the workflow works (see above). User step 11 **cannot pass** as written. Steps 12 to 14 depend on 11. Step 14's CONTRIBUTING and Code of Conduct links would open raw `.md` even if the deploy succeeded. + +2026-09-23 (re-review after Amendment A1/A2): +Verdict: APPROVED +- **Earlier blocker resolved.** `_config.yml:11-20` now has `readme_index.remove_originals: true`, `optional_front_matter.remove_originals: true`, and a block-form `include:` with exactly `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`. `README.md` is not in it. The trailing `# ...` comment on `include:` parses cleanly (Ruby YAML gives `["CONTRIBUTING.md", "CODE_OF_CONDUCT.md"]`). `exclude:` is unchanged, and the allowlist regex at `pages.yml:90-92` is unchanged and still strict. +- **Earlier test-step finding resolved.** I extracted PAGESSIM from this file and ran it myself. (a) `published:` is exactly the 5 files, `problems: 0`. (b) The negative config prints both blacklist lines, the `include:` line, and `remove_originals is not true` for both plugins, `problems: 5`. Step 7 run 4 models the live-site shape, and step 10b now inspects the real `github-pages` artifact before hand-off, so the real gem is exercised before merge instead of at user step 11. +- **A2 checks (all correct):** + - `pull_request:` (`pages.yml:49-57`) has no `branches:`, and its `paths` equal `push`'s (Ruby check: `true`). Stacked PRs with non-`main` bases trigger it, and this PR touches `pages.yml`, so it runs. + - Deploy is gated by `if: github.event_name != 'pull_request'` (`pages.yml:127`). A PR run shows `deploy` as skipped. The trigger is `pull_request`, not `pull_request_target`, so no other event name can reach deploy from a PR. + - There is no top-level `concurrency` (Ruby: `nil`). `concurrency: { group: pages, cancel-in-progress: false }` is on `deploy` only (`pages.yml:132-134`), and `build` has none. + - A PR build cannot overwrite or cancel a `main` deploy. Its deploy job is skipped before it joins the `pages` group, so it never occupies or displaces the pending slot. Its `github-pages` artifact belongs to its own run, and `deploy-pages` only deploys the artifact from its own run. + - Fork PRs: fork `pull_request` tokens are capped at read, which already covers `contents: read` + `pages: read`. `configure-pages` and jekyll-github-metadata query the base repo (`github.repository`) with that token, and no secrets are used, so a fork build succeeds. (First-time contributors' runs still need maintainer approval; that is GitHub policy, not a failure.) `pages: read` on a same-repo `pull_request` behaves exactly as on `push`, which the first review already verified is enough for `configure-pages`. + - `if:` together with `environment:` on the same job is supported (GitHub's docs show that combination). The `if:` is evaluated before the environment is created. A skipped job creates no deployment and never hits the `github-pages` branch policy, so the gate really is the `if:` and not an environment rejection, as A2 requires. +- **Non-blocking note (no change required):** because `build` is not serialised, two `main` pushes in quick succession could in theory finish their builds out of order, and the older commit's deploy could run last. GitHub's starter workflow avoids this with a top-level group. The window is one build (about a minute), the next push corrects it, and A2's reasoning for job-level concurrency (a PR push must not displace a queued `main` deploy) is the higher-value property. If this ever matters, use a top-level group keyed by event, e.g. `group: pages-${{ github.event_name == 'pull_request' && github.ref || 'deploy' }}`. That would need a planner amendment; it is not required for this PR. +- **Rest of the diff** (`Project.md`, `CONTRIBUTING.md`, `CHANGELOG.md`) is unchanged since the first review and still matches the spec. There are no Swift, `install.sh`, Makefile, `ci.yml`, `release.yml` or `README.md` changes, so every runtime guardrail is n/a. +- **Build.** `swift build`: `Build complete!`. `make bundle`: ok, `codesign --force --sign -` succeeded. +- **Test Steps.** Steps 1–10 can run against the code as written, and I reproduced 2 and 3(a)/(b). Step 10b is executable once the PR is open (the `pull_request` trigger fires on this PR's `pages.yml` change, and `deploy` will show skipped). Steps 11–14 can pass if 10b passes; with `remove_originals` and `include:` in place, step 14's CONTRIBUTING and Code of Conduct links resolve to `.html`. + +## Test Steps (planner writes; `ai` rows run by the main session before the PR opens, `user` rows by the human at the PR) + +**Preconditions** +- Build: none needed, and nothing is installed. Run all `ai` commands from the repo root on branch `chore/pages-readme`. Test the working tree including the two new, possibly still untracked, files. +- System state: unchanged. No Docker is needed. A local Jekyll build is used only if it is already present (step 1). Nothing gets installed. +- Covers: task-specific. Phase 6 Verify steps 1–7 cover the app installer, not the site, so they do not apply. +- `PUBLISHED` below means `README.md CONTRIBUTING.md SECURITY.md CODE_OF_CONDUCT.md CHANGELOG.md`. +- `PAGESSIM` below means: save this as `/pages_sim.py` and run `python3 /pages_sim.py`. It simulates Jekyll 3's source filtering (skip names starting with `.`, `_` or `#`, or ending in `~`, then `exclude` as fnmatch-or-prefix, with `include:` overriding both), then checks links and Liquid in the survivors. *(Amended 2026-09-23: it now also models the jekyll-optional-front-matter filename blacklist and asserts the `include:` / `remove_originals` settings from Amendment A1. `PAGES_CFG` selects the config file so that the negative run can use a doctored copy.)* + ```python + import fnmatch, os, re, subprocess, sys + cfg = open(os.environ.get("PAGES_CFG", "_config.yml"), encoding="utf-8").read() + def block_list(key): + m = re.search(rf"^{key}:[ \t]*(?:#[^\n]*)?\n((?:[ \t]*(?:-[^\n]*|#[^\n]*)?\n?)+)", cfg, re.M) + return [l.split("-", 1)[1].split(" #")[0].strip().strip("'\"") + for l in (m.group(1).splitlines() if m else []) if l.strip().startswith("-")] + def mapping_has(key, sub, val): + m = re.search(rf"^{key}:([^\n]*)\n?((?:[ \t]+[^\n]*\n?)*)", cfg, re.M) + body = re.sub(r"#[^\n]*", "", (m.group(1) + "\n" + m.group(2)) if m else "") + return re.search(rf"\b{sub}:[ \t]*{val}\b", body) is not None + excl, incl = block_list("exclude"), block_list("include") + print("exclude:", excl); print("include:", incl) + files = subprocess.run(["git", "ls-files", "--cached", "--others", "--exclude-standard"], + capture_output=True, text=True, check=True).stdout.split() + special = lambda p: p[:1] in "._#" or p.endswith("~") + excluded = lambda rel: any(fnmatch.fnmatch(rel, p) or rel.startswith(p) or p == rel + "/" for p in excl) + out = [] + for f in files: + parts = f.split("/") + if f not in incl: + if any(special(p) for p in parts): continue + if any(excluded("/".join(parts[:i])) for i in range(1, len(parts) + 1)): continue + out.append(f) + print("published:", sorted(out)) + expected = {"README.md", "CONTRIBUTING.md", "SECURITY.md", "CODE_OF_CONDUCT.md", "CHANGELOG.md"} + bad = 0 + if set(out) != expected: + print("extra:", sorted(set(out) - expected), "missing:", sorted(expected - set(out))); bad += 1 + # jekyll-optional-front-matter 0.3.2 (github-pages 232): blacklisted basenames render only if listed in include: + BLACKLIST = {"README", "LICENSE", "LICENCE", "COPYING", "CODE_OF_CONDUCT", "CONTRIBUTING", + "ISSUE_TEMPLATE", "PULL_REQUEST_TEMPLATE"} + for f in sorted(out): + if f != "README.md" and f.endswith(".md") and f[:-3].split("/")[-1].upper() in BLACKLIST and f not in incl: + print(f"{f}: on optional-front-matter's blacklist and not in include: -> no .html"); bad += 1 + if set(incl) != {"CONTRIBUTING.md", "CODE_OF_CONDUCT.md"}: + print("include: must be exactly CONTRIBUTING.md, CODE_OF_CONDUCT.md (README.md there emits README.html)"); bad += 1 + for key in ("optional_front_matter", "readme_index"): + if not mapping_has(key, "remove_originals", "true"): + print(f"{key}.remove_originals is not true -> raw .md copied into _site"); bad += 1 + if not mapping_has("readme_index", "enabled", "true"): print("readme_index.enabled is not true"); bad += 1 + for f in sorted(expected & set(out)): + raw = open(f, encoding="utf-8").read() + if re.search(r"\{\{|\{%", raw): print(f"{f}: Liquid tag (Liquid runs inside code fences too)"); bad += 1 + if "activebrowser.app" in raw: print(f"{f}: old domain"); bad += 1 + for t in re.findall(r"\]\(([^)\s]+)\)", re.sub(r"```.*?```", "", raw, flags=re.S)): + if re.match(r"(https?|mailto):|#", t): continue + if t.split("#")[0] not in out: print(f"{f}: link to unpublished file -> {t}"); bad += 1 + print("problems:", bad); sys.exit(1 if bad else 0) + ``` + +| # | Who | Action (exact command / click) | Expected | Actual | Result | +|---|---|---|---|---|---| +| 1 | ai | `command -v jekyll; gem list -i github-pages 2>/dev/null; command -v bundle; command -v ruby` | Record what exists. If `jekyll` **and** `github-pages` are both present, also run `jekyll build -d /_site` and run step 3's expectations against its output. Otherwise record "no local github-pages Jekyll; the real build is proven by step 10b". Do **not** install gems. *(Amended 2026-09-23: was "user step 11".)* | no jekyll, no github-pages gem (ruby/bundle via rbenv only): no local github-pages Jekyll; the real build is proven by step 10b | pass | +| 2 | ai | *(Amended 2026-09-23.)* YAML parse: `ruby -ryaml -e 'c=YAML.load_file("_config.yml"); p c["include"], c["optional_front_matter"], c["readme_index"]; w=YAML.load_file(".github/workflows/pages.yml"); on=w["on"]\|\|w[true]; p w["permissions"], w["concurrency"], on, on["push"]["paths"]==on["pull_request"]["paths"], w["jobs"].transform_values{\|j\| [j["runs-on"], j["permissions"], j["needs"], j["environment"], j["if"], j["concurrency"]]}'` (Psych reads the bare key `on` as `true`, hence the fallback). If `ruby` is absent, use `python3 -c 'import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]' _config.yml .github/workflows/pages.yml` and check the same keys by eye. If neither exists, record "not run — no YAML parser". | Both files parse. `_config.yml`: `include` is `["CONTRIBUTING.md", "CODE_OF_CONDUCT.md"]`, `optional_front_matter` is `{"remove_originals"=>true}`, and `readme_index` is `{"enabled"=>true, "remove_originals"=>true}`. `pages.yml`: top-level `permissions` is `{}`, and top-level `concurrency` is `nil`. `on` has `push` with `branches == ["main"]` and the 7 listed `paths`, `pull_request` with the same 7 `paths` and **no** `branches` key, and `workflow_dispatch`. The paths-equality check prints `true`. `build` has `{"contents"=>"read", "pages"=>"read"}`, with `if` and `concurrency` both `nil`. `deploy` has `{"pages"=>"write", "id-token"=>"write"}`, `needs` is `build`, the environment is `github-pages`, `if` is `github.event_name != 'pull_request'` (or an equivalent that allows only `push`/`workflow_dispatch`), and `concurrency` is `{"group"=>"pages", "cancel-in-progress"=>false}`. Both jobs run on `ubuntu-latest`. | all keys/values exactly as expected; paths equal `true` | pass | +| 3 | ai | *(Amended 2026-09-23.)* (a) `python3 /pages_sim.py; echo "exit=$?"` (PAGESSIM). (b) Negative run, which proves the new assertions fire: `grep -v -e remove_originals -e '^include:' -e '^ - CONTRIBUTING.md' -e '^ - CODE_OF_CONDUCT.md' _config.yml > /cfg_neg.yml && PAGES_CFG=/cfg_neg.yml python3 /pages_sim.py; echo "exit=$?"` | (a) The `exclude:` list prints, and `include:` prints exactly `['CONTRIBUTING.md', 'CODE_OF_CONDUCT.md']`. `published:` is exactly the 5 PUBLISHED files. `install.sh`, `LICENSE`, `Project.md`, `CLAUDE.md`, `tasks/…`, `docs/…`, `assets/…`, `Sources/…`, `Support/…`, `Makefile` and `Package.swift` are absent. There are no blacklist, include or `remove_originals` messages. Output ends `problems: 0`, `exit=0`. (b) Prints the blacklist line for both `CODE_OF_CONDUCT.md` and `CONTRIBUTING.md`, the `include:` line, and `remove_originals is not true` for both `optional_front_matter` and `readme_index`. `problems:` ≥ 5, `exit=1`. (If (b) exits 0, the simulation is not reading the settings, and step 3 fails.) | (a) exactly the 5 docs, `problems: 0`, exit 0. (b) both blacklist lines, include line, both remove_originals lines, `problems: 5`, exit 1 | pass | +| 4 | ai | `grep -n 'activebrowser\.app' README.md CONTRIBUTING.md SECURITY.md CODE_OF_CONDUCT.md CHANGELOG.md _config.yml; echo "exit=$?"`. Case-sensitive on purpose: `ActiveBrowser.app` is the bundle name. Then `grep -rn 'activebrowser\.app' Project.md .github/` for information only. | The first command prints no matches and `exit=1`. The second is recorded and should print nothing, because the guard in `pages.yml` is written as the regex `activebrowser\.app` and the header comment must not spell the domain. | no matches, exit 1; info grep also empty | pass | +| 5 | ai | `grep -nE '\{\{\|\{%' README.md CONTRIBUTING.md SECURITY.md CODE_OF_CONDUCT.md CHANGELOG.md; echo "exit=$?"` | No matches, `exit=1`. No Liquid tags in the published pages. | no matches, exit 1 | pass | +| 6 | ai | Actions audit: `grep -nE '^\s*-?\s*uses:' .github/workflows/pages.yml`, then `grep -nE '^\s*-?\s*uses:' .github/workflows/pages.yml \| grep -vE 'uses:\s*actions/[a-z-]+@v[0-9]+\s*$'; echo "exit=$?"`, and `grep -c 'cancel-in-progress: false' .github/workflows/pages.yml`, `grep -n 'install.sh' .github/workflows/pages.yml`, and `ls Gemfile Gemfile.lock 2>&1` | The first command lists exactly 5 `uses:` lines: checkout, configure-pages, jekyll-build-pages, upload-pages-artifact and deploy-pages. The second prints nothing, with `exit=1`, so every action is `actions/*@vN`. The cancel-in-progress count is `1`. `install.sh` appears only in the header comment. Both `ls` calls report "No such file". | 5 uses (checkout@v4, configure-pages@v6, jekyll-build-pages@v1, upload-pages-artifact@v5, deploy-pages@v5); non-actions grep exit 1; cancel count 1; install.sh only in header comment; no Gemfile(s) | pass | +| 7 | ai | Allowlist-step dry run: `mkdir -p /fake_site/assets/css && for f in index CONTRIBUTING SECURITY CODE_OF_CONDUCT CHANGELOG; do echo x > /fake_site/$f.html; done && echo x > /fake_site/assets/css/style.css`. Then run the allowlist step's `run:` body copied verbatim, with `_site` replaced by the fake path. Then `echo x > /fake_site/install.sh` and run it again. Then remove that file, `echo 'curl https://activebrowser.app/install.sh' > /fake_site/CHANGELOG.html`, and run it a third time. *(Added 2026-09-23)* Run 4 reproduces the live site's shape: in a fresh `/fake_site_live/`, create `index.html`, `SECURITY.html`, `CHANGELOG.html`, `assets/css/style.css`, `README.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` and `CHANGELOG.md` (each `echo x >`), with **no** `CONTRIBUTING.html` or `CODE_OF_CONDUCT.html`, and run the body against it. | Run 1 exits 0, with at most the "View on GitHub" `::warning::`. Run 2 exits non-zero and names `install.sh`. Run 3 exits non-zero on the old-domain check. Run 4 exits non-zero, lists all five `.md` files as outside the allowlist, and reports `CONTRIBUTING.html` and `CODE_OF_CONDUCT.html` as missing. | run1 exit 0 + VoG warning; run2 exit 1 names install.sh; run3 exit 1 old-domain; run4 exit 1, five .md listed, CONTRIBUTING/CODE_OF_CONDUCT.html missing | pass | +| 8 | ai | Baseline of the live stale site, which records the problem this PR fixes and is not a pass criterion: `curl -s https://hexember.github.io/active-browser/ \| grep -c 'activebrowser\.app'; curl -s -o /dev/null -w '%{http_code}\n' https://hexember.github.io/active-browser/install.sh` | Record both values. Expected today: count ≥ 1 and `200`. | live: count `2`, install.sh `200` (the stale site this PR fixes) | recorded | +| 9 | ai | `swift build 2>&1 \| grep -E 'warning:\|error:'; swift build 2>&1 \| tail -1` | Only the known CommandLineTools `ld` search-path noise (if any). The last line is `Build complete!` | no warnings/errors; `Build complete!` | pass | +| 10 | ai | `git diff --name-only chore/readme-restructure; git status --porcelain`, then `git diff chore/readme-restructure -- CHANGELOG.md CONTRIBUTING.md \| grep -E '^-[^-]'` and `grep -nE '^#{1,6} ' CONTRIBUTING.md` | Changes are limited to `.github/workflows/pages.yml`, `_config.yml`, `Project.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `tasks/18-pages-readme.md` (and `tasks/TEST-PLAN.md` if updated). CHANGELOG has no removed lines. CONTRIBUTING's only removed line is the `.github/workflows/` layout line. Its headings are identical to the base. | only CHANGELOG, CONTRIBUTING, Project.md + untracked pages.yml, _config.yml, task file; CONTRIBUTING's only removed line is the `.github/workflows/` layout line; headings identical | pass | +| 10b | ai | *(Added 2026-09-23. Runs **after** git-agent opens the PR, and before the main session hands off or starts the next task. Record the result in this row and in the PR body.)* `n=$(gh pr view chore/pages-readme --json number -q .number); gh pr checks "$n" --watch`. Then `run=$(gh run list --workflow pages.yml --branch chore/pages-readme --event pull_request --limit 1 --json databaseId -q '.[0].databaseId'); gh run view "$run" --json conclusion,jobs -q '.conclusion, (.jobs[] \| "\(.name) \(.conclusion)")'`. Then `rm -rf /pages_art && gh run download "$run" -n github-pages -D /pages_art && tar -tf /pages_art/artifact.tar \| sed 's#^\./##' \| grep -v '/$' \| grep -v '^$' \| sort`. Then `mkdir -p /pages_art/x && tar -xf /pages_art/artifact.tar -C /pages_art/x && grep -rl 'activebrowser\.app' /pages_art/x; echo "domain_exit=$?"; grep -ohE 'href="[^"#h][^"]*\.md(#[^"]*)?"' /pages_art/x/*.html; echo "md_href_exit=$?"; grep -oE 'href="[^"]*CONTRIBUTING[^"]*"' /pages_art/x/index.html \| head -3` | A `pages` run for `pull_request` exists and finishes. `build success` and `deploy skipped`, and the run is green, including the "Published set is exactly the allowlist" step. The artifact lists exactly `CHANGELOG.html`, `CODE_OF_CONDUCT.html`, `CONTRIBUTING.html`, `SECURITY.html`, `assets/css/style.css` and `index.html`. Record any additional file the allowlist regex permits (`README.html`, other `assets/{css,js,images,fonts}/…`), but none is expected. There is no `.md` file, no `install.sh`, `LICENSE`, `Project.*` or `tasks/`. `domain_exit=1` (no old domain). `md_href_exit=1` (no relative `.md` href is left, so relative-links rewrote every link). The CONTRIBUTING hrefs end in `CONTRIBUTING.html` (with `#build-and-run` where present). **Fail** → set Status `blocked`, back to the implementer as a follow-up commit on this branch, then re-run 10b. If no `pull_request` run appears, check that the PR diff touches one of the seven paths (it touches `pages.yml`). | run 35777705333 (pull_request): success, `build success`, `deploy skipped`. Artifact is exactly CHANGELOG.html, CODE_OF_CONDUCT.html, CONTRIBUTING.html, SECURITY.html, assets/css/style.css, index.html. `domain_exit=1`, `md_href_exit=1`. CONTRIBUTING hrefs are `/active-browser/CONTRIBUTING.html#build-and-run` and `/active-browser/CONTRIBUTING.html`. index has View on GitHub and the raw install URL. Rendered locally under /active-browser/: Cayman header, View on GitHub, Contents line, CONTRIBUTING page OK | pass | +| 11 | user | Merge **PR #29 first**, confirm this PR retargets to `main`, then merge this PR. Open Actions → **pages** → the run for the merge commit. | The `pages` run is green: `build` passes, including "Published set is exactly the allowlist", and `deploy` shows the `github-pages` environment URL. Any `::warning::` about "View on GitHub" is noted. *(Note 2026-09-23: step 10b already proved the build on the PR, so this step now checks only the deploy on `main`.)* | | pass / fail | +| 12 | user | About 2 minutes after the deploy (Pages CDN cache), open https://hexember.github.io/active-browser/ in a browser, hard-refreshed. | It shows the current README: the **Contents** line, and Install showing the `raw.githubusercontent.com/hexember/active-browser/main/install.sh` one-liner. The Cayman header reads "ActiveBrowser" with the tagline and has a **View on GitHub** button that opens github.com/hexember/active-browser. There are no Download .zip/.tar.gz buttons, and the H1 is not repeated under the header. | | pass / fail | +| 13 | user | `curl -s https://hexember.github.io/active-browser/ \| grep -c 'activebrowser\.app'` and, for each of `install.sh`, `Project.html`, `CLAUDE.html`, `tasks/TEST-PLAN.html`, `LICENSE`, `Makefile`, `README.html`: `curl -s -o /dev/null -w '%{http_code} %{url_effective}\n' https://hexember.github.io/active-browser/` | The count is `0`, so the old one-liner is gone. Every path returns `404` (a `README.html` 404 is expected, because README is served at `/`). | | pass / fail | +| 14 | user | On the site, click README's links to **CONTRIBUTING.md** (Build from source → "CONTRIBUTING.md"), **SECURITY.md** (Gatekeeper paragraph), **Code of Conduct** and **CHANGELOG.md**. Then, on the SECURITY page, click **README.md → Privacy**. Also click the in-page **Contents** links. | Each opens the rendered `.html` page on hexember.github.io, not a 404 or a raw `.md` file. `#build-and-run`, `#what-youre-trusting-when-you-install-this` and `#privacy` land on the right heading. The Contents anchors scroll correctly. The LICENSE links open GitHub. | | pass / fail | + +**Reset after testing** +- none. The Pages deployment is the intended permanent change, and scratchpad files are disposable. To roll back, revert this PR's merge. The site then stays frozen at its last deploy until a `pages` run happens, so a revert alone does not restore the old site, and it shouldn't. + +**Result (ai rows):** pass — 2026-09-23 (steps 1–10 and 10b, main session; 10b on PR #30's pages run 35777705333) +**Result (user rows):** +Failures: + +_2026-09-23 (main session):_ PR #30 was merged into its stacked base `chore/readme-restructure` about 20 minutes after #29 was squash-merged into `main`. GitHub did not retarget it, so none of its changes reached `main` and no deploy ran. The same diff (`git diff origin/main 14db2ce`) was re-applied onto `main` in a new PR. + +## Next + diff --git a/tasks/TEST-PLAN.md b/tasks/TEST-PLAN.md index abae8ca..a4145a5 100644 --- a/tasks/TEST-PLAN.md +++ b/tasks/TEST-PLAN.md @@ -309,6 +309,21 @@ https://github.com/hexember/active-browser/pull/24 --- +## PR #30 — Task 18: README published to GitHub Pages (`chore/pages-readme`, base `chore/readme-restructure`) + +**Preconditions:** merge PR #29 first, then this PR. The PR-time Jekyll build already passed (run 35777705333), so these steps check only the deploy and the live site. + +| # | Action | Expected | +|---|---|---| +| 1 | Merge **PR #29 first**, confirm this PR retargets to `main`, then merge this PR. Open Actions → **pages** → the run for the merge commit. | The `pages` run is green: `build` passes, including "Published set is exactly the allowlist", and `deploy` shows the `github-pages` environment URL. Any `::warning::` about "View on GitHub" is noted. *(Note 2026-09-23: step 10b already proved the build on the PR, so this step now checks only the deploy on `main`.)* | +| 2 | About 2 minutes after the deploy (Pages CDN cache), open https://hexember.github.io/active-browser/ in a browser, hard-refreshed. | It shows the current README: the **Contents** line, and Install showing the `raw.githubusercontent.com/hexember/active-browser/main/install.sh` one-liner. The Cayman header reads "ActiveBrowser" with the tagline and has a **View on GitHub** button that opens github.com/hexember/active-browser. There are no Download .zip/.tar.gz buttons, and the H1 is not repeated under the header. | +| 3 | `curl -s https://hexember.github.io/active-browser/ \| grep -c 'activebrowser\.app'` and, for each of `install.sh`, `Project.html`, `CLAUDE.html`, `tasks/TEST-PLAN.html`, `LICENSE`, `Makefile`, `README.html`: `curl -s -o /dev/null -w '%{http_code} %{url_effective}\n' https://hexember.github.io/active-browser/` | The count is `0`, so the old one-liner is gone. Every path returns `404` (a `README.html` 404 is expected, because README is served at `/`). | +| 4 | On the site, click README's links to **CONTRIBUTING.md** (Build from source → "CONTRIBUTING.md"), **SECURITY.md** (Gatekeeper paragraph), **Code of Conduct** and **CHANGELOG.md**. Then, on the SECURITY page, click **README.md → Privacy**. Also click the in-page **Contents** links. | Each opens the rendered `.html` page on hexember.github.io, not a 404 or a raw `.md` file. `#build-and-run`, `#what-youre-trusting-when-you-install-this` and `#privacy` land on the right heading. The Contents anchors scroll correctly. The LICENSE links open GitHub. | + +**Reset after this block:** none. Pages is the intended end state. + +--- + # Final teardown — run this only when you are finished with everything above Order matters; doing it out of order lets the login item come back.