diff --git a/.github/workflows/footer-assets.yml b/.github/workflows/footer-assets.yml new file mode 100644 index 0000000..5e74ddf --- /dev/null +++ b/.github/workflows/footer-assets.yml @@ -0,0 +1,53 @@ +# Footer assets +# Fails on any result other than pass, e.g. when a footer logo URL returns 404 (issue 47, subissue 50). +# Flag spelling confirmed with `python scripts/showcase.py footer --check --help`: `--check README.md --online` +# HEADs both decision 18 raw logo URLs (on main) and exits non-zero unless both return 200. +# Triggers: push to main (a regression is caught as it lands), weekly schedule and manual dispatch run the online HEAD. +# A pull request cannot be judged by HEADing main, so it runs an offline step instead: the configured footer logo +# paths must exist in the PR checkout under assets/brand/. + +name: Footer assets + +on: + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + push: + branches: [main] + paths: + - references/brand.yaml + - README.md + - assets/brand/** + - scripts/ctshowcase/footer.py + - .github/workflows/footer-assets.yml + pull_request: + paths: + - references/brand.yaml + - README.md + - assets/brand/** + - scripts/ctshowcase/footer.py + - .github/workflows/footer-assets.yml + +permissions: + contents: read + +jobs: + footer-online: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Python dependencies + run: python -m pip install -r requirements.txt + - name: Footer logo files exist in this checkout + if: github.event_name == 'pull_request' + run: | + python -c "import pathlib, sys, yaml; from urllib.parse import urlparse; f = yaml.safe_load(open('references/brand.yaml', encoding='utf-8'))['footer']; urls = [f['logo_light_url'], f['logo_dark_url']]; bad = [u for u in urls if not pathlib.Path(*urlparse(u).path.split('/main/', 1)[1].split('/')).is_file()]; print('missing in checkout:', bad) if bad else print('footer logo files present:', urls); sys.exit(1 if bad else 0)" + - name: Real HEAD against footer logo URLs + if: github.event_name != 'pull_request' + run: python scripts/showcase.py footer --check README.md --online diff --git a/CHANGELOG.md b/CHANGELOG.md index 6937c46..9fef4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo ### Fixed +- `readme --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image. +- Fork (G) and backup (H) READMEs migrate an unmarked trailing footer whose logo is a configured own-site historical source (`footer.legacy_logo_urls` or `docs/images/logo.svg`) to the single current footer; partner, foreign and look-alike images stay authored and are reported as no allowance. +- Own-site historical footer logo recognition is URL-derived: the company-website logo lives in `footer.legacy_logo_urls` in `references/brand.yaml` (lock refreshed through `config --write-lock`) and matches only exactly (scheme, host case-insensitively, port, path; no userinfo), so no brand host or path remains in Python. The generic local `docs/images/logo.svg` migration is a file convention, not brand data, and stays distinct. - Windows contributor tests now use explicit UTF-8, native path basenames, a platform-independent read-only metadata stub, and Git Bash with stdin-safe Action scripts. All 15 issue #9 failures are individually documented; the full suite now runs in Windows, Linux and macOS CI. - Lint accepts file operands after options on older Python 3.12 versions while continuing to reject unrecognized options. - The Action's optional render arguments support macOS Bash 3.2, including an empty argument array with `set -u`. @@ -21,6 +24,9 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - `content icon` uses the bundled `references/art-direction.md` in every working directory. The option A glyph template uses dedicated, contrasting `icon_ink` and `icon_spark` brand tokens and the documented family seed (or the tool's default when absent) without requiring private estate files; explicit art-direction overrides remain supported and missing paths are named in the error. - CLI stdout and stderr are configured as UTF-8 before argument parsing, preserving their existing error handlers, so redirected Windows output preserves Unicode in label plans, raw footer markup, help and diagnostics without `UnicodeEncodeError`, and undecodable filenames still use the stream's original escape policy. - The Action summary and quality validator decode CLI results explicitly as UTF-8, preserving non-ASCII descriptions on Windows rather than corrupting them or failing with `UnicodeDecodeError`. +- Footer logo URLs now use the existing published masters in this skill instead of missing `.github/brand` files. README refresh safely migrates the former URLs without dropping authored partner credits; the decision 18 artwork, theme selection and credit remain unchanged. Fork and backup READMEs (variants G and H) migrate the former footer too, and a custom footer image is never treated as a footer copy because its alt text matches. A `Footer assets` workflow checks on pull requests (offline) that the configured logo files exist, and on push to main, the weekly schedule and manual dispatch sends a real HEAD to both footer logo URLs (`footer --check README.md --online`), failing on any result other than pass. +- Footer image recognition reads whole attributes: only the exact attribute names `alt`, `src` and `srcset` count, the first occurrence of a name wins even when it has no value (`alt alt="CompleteTech LLC"` reads as an empty alt, so the authored image is preserved), and text inside another attribute's value or a name such as `data-alt` never authorizes an image, so a partner image cannot pass as the footer's logo. No brand or decision change. +- Own-site footer recognition is limited to the configured historical logo URLs on the company website host (`footer.legacy_logo_urls`), not any file on that host: a team photo or screenshot on the same host stays authored content and is kept through `readme --mode upgrade`. Migrating an unmarked footer reports as removed only the lines the new footer does not emit again, the same multiset rule as marked footers, so a lost banner anchor or other markup is no longer hidden by re-emitted footer lines and fails the never-drop-a-line check. No brand or decision change. - `doctor` reports disabled-Issues label checks, inapplicable social previews and archived community health as `n/a`, while preserving label coverage during approved archived polishing and unknown captured values on applicable surfaces. Social upload plans share the R10 applicability rule and include the personal-profile capstone. - `doctor` reports R11 as `n/a` for archived repositories and outside T1/T2 organizations (personal-account, utility and co-owned repositories), matching the definition of done. @@ -56,7 +62,7 @@ First public release, for the CompleteTech GitHub estate standard `ct-std-v1`. - A T3 org's repository is left out of the personal pins and more projects, other orgs' cross-listings, favorites and star List items until co-owner sign-off (`--allow-t3`), logged `t3-signoff:OWNER/REPO`. - `project copy-plan` and the standalone `project readme` name no repository in the README of a board that is public (as captured, approved or not) or may go public (an approved visibility item), logged `readme-links:repositories-omitted (the board may go public)`; a board captured public without approval logs `visibility:public (no approval)`, not `visibility:private`. - `labels --plan` and `adopt` refuse an `--org-defaults` metadata JSON or checkout of another org's `.github` (usage, exit 2), with the owner read from `owner`, `owner.login`, `full_name` or the checkout's own `origin` and resolved through the run's org resolver (`--config` included). A JSON without an owner, or a checkout without a GitHub origin, can no longer prove that no default issue form names a label, so the rename stays held (`needs-input:label-references`). Before, another org's `.github` was accepted and the rename was planned while the owner's default forms still assigned the old label. -- `readme --mode upgrade` (and the bootstrap `edited` path and the preserve list) replace an unmarked or drifted footer at the end of the README with the decision 18 footer instead of appending a second one. Only a block whose every line is a footer line (centring, complete.tech links, the footer's own logo image, the "Built by" credit line) is replaced; an image counts as the footer's logo only when its src or srcset is the brand footer logo URL (or the same file on another branch, with a query, or in its github.com blob/raw form) or its alt is exactly the company, never because its file name contains "logo". A centred block with an authored line (a sponsor or partner image, a thanks line) is kept, so no authored line is dropped. `lint readme` fails `D18 footer fail:duplicate`, and `footer --check` (and doctor's footer column) `fail:duplicate`, when a footer-like block outside code sits before the last block. +- `readme --mode upgrade` (and the bootstrap `edited` path and the preserve list) replace an unmarked or drifted footer at the end of the README with the decision 18 footer instead of appending a second one. Only a block whose every line is a footer line (centring, links to the configured footer host (`footer.href`), the footer's own logo image, the "Built by" credit line) is replaced; an image counts as the footer's logo only when its src or srcset is the brand footer logo URL (or the same file on another branch, with a query, or in its github.com blob/raw form) or it is a configured historical logo URL on the company website host (`footer.legacy_logo_urls`, not any file on that host) or the conventional local `docs/images/logo.svg` under a company alt, never because its file name contains "logo" or its alt matches. A centred block with an authored line (a sponsor or partner image, a thanks line) is kept, so no authored line is dropped. `lint readme` fails `D18 footer fail:duplicate`, and `footer --check` (and doctor's footer column) `fail:duplicate`, when a footer-like block outside code sits before the last block. ### Fixed diff --git a/README.md b/README.md index 38ae0ce..6dc65e6 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,8 @@ Code, templates, and documentation are licensed under the MIT License. CompleteT
` block, any such line in preserve mode, and one in an untouched template copy whose template taglines are unknown (the rule would empty any line in the block there on the next run) stay as authored text. An old footer at the end of the README (a centred block whose every line is a footer line: centring, ` ` block, any such line in preserve mode, and one in an untouched template copy whose template taglines are unknown (the rule would empty any line in the block there on the next run) stay as authored text. An old footer at the end of the README (a centred block whose every line is a footer line: centring, `
\n Built by CompleteTech LLC · Innovation at Every Integration ' not in [ln.strip() for ln in removed]
+ assert " \n \n
+
diff --git a/SKILL.md b/SKILL.md
index c2a61fb..5e76d16 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -245,7 +245,7 @@ content sidebar --facts FILE [--packages FILE]... [--current FILE] [--repo OWNER
| `org-banner` | Renders the 1600×400 org profile banner JPG (STANDARD §9.5). | `--out` |
| `org-tile` | Renders the 400×160 org tile for profile directories. | `--out`, `--png` |
| `intro` | Renders the profile intro (animated only for the personal profile and T1 flagship orgs; `.still` reduced-motion frame). | `--out` |
-| `footer` | Prints the decision 18 footer between `showcase:footer` markers, or checks that the README ends with it; `--check --online` also HEADs both raw logo URLs and fails (`fail:offline`) unless both return 200 (the Phase 4.0 hard gate). `--org OWNER` answers `exempt`, `exempt:footer` or `hold` for an org outside the footer's scope; for the watching org, `--check` still reads the README (its `.github` gets the footer, and `--org` cannot tell it from a fork). | stdout |
+| `footer` | Prints the decision 18 footer using the published masters in this skill's `assets/brand/`, between `showcase:footer` markers, or checks that the README ends with it; `--check --online` also HEADs both raw logo URLs and fails (`fail:offline`) unless both return 200 (the Phase 4.0 hard gate). `--org OWNER` answers `exempt`, `exempt:footer` or `hold` for an org outside the footer's scope; for the watching org, `--check` still reads the README (its `.github` gets the footer, and `--org` cannot tell it from a fork). | stdout |
| `readme` | Scaffolds, upgrades or refreshes the header marker blocks; never drops a line (multiset check). `--bootstrap` fills a template body only while its marker's sha256 still matches (a person's edit makes the block theirs). | `README.md` with `--write`; `CONTRIBUTING.md` with `--bootstrap` |
| `lint` | SVG rules, README rules (R7 plus decisions 13 and 18) and banner PNG checks; `public-leak` checks any public file the run writes against the estate's private set (private slugs and names under every login of the owner, members-only pins, internal notes from the org rows, the registry's top-level notes and judge_changes and the profile's internal keys; OPS-03), reading the private registry and `--meta-dir` only; `prose` runs the CQ-17 checks on text the run writes (§1.2 brand strings, no emoji, no placeholders, the §4.1 formula for descriptions and taglines, typos when the optional `codespell` is installed; `--preserved` text only warns). | nothing |
| `contact-sheet` | Lays outputs out the way GitHub shows them, on `#0d1117` and `#ffffff` (proofing surfaces, not asset variants). `--org ORG --cross-surface` is the G4 gate: the org avatar at 32 px, a hero banner, a repo card, the social preview and the org banner on one labelled sheet; a missing surface is logged `needs-input:cross-surface:
+
diff --git a/assets/examples/readme-example.md b/assets/examples/readme-example.md
index 41aeca7..7b0ea5e 100644
--- a/assets/examples/readme-example.md
+++ b/assets/examples/readme-example.md
@@ -50,8 +50,8 @@ Code and documentation are licensed under the MIT License; see `LICENSE`. Comple
+
diff --git a/references/art-direction.md b/references/art-direction.md
index 2dddbd2..c284285 100644
--- a/references/art-direction.md
+++ b/references/art-direction.md
@@ -64,8 +64,8 @@ Starred values are approximate until the estate planner re-measures them and pub
| README hero banner | `docs/images/banner.png` | 1280×360 (or 2560×720) | PNG ≤ 300 KB, opaque; generated by `banner` |
| Repo card | `docs/images/card.svg` | 400×160 | SVG ≤ 40 KB; generated by `card` |
| Repository icon | `assets/icon.png` | 1024×1024 | PNG RGBA, transparent (drawn to read on the family card colour, `#F8FAFC` for CompleteTech) or with one opaque square ground (it then fills the card, full-bleed), ≤ 300 KB, no text |
-| Brand files | `CompleteTech-LLC/.github/brand/`: `logo.png`, `logo-dark.png` (the exemplar pair, byte-identical), the chosen dark master (decision 19), `mark-dark.png` and `bulb-dark.png` once re-measured | as committed | PNG RGBA |
-| Logo pair in Skills repositories | `assets/logo.png`, `assets/logo-dark.png` | byte-identical to the brand files | sha256 `a5c3c4ef55336ed4b883d3bc3bb6e1e942b836469bd2b2933bc8cc230b321621` (logo.png, 243,500 B) and `0dc00c0e5159518ff1d552b7052293b4e545ddd35be489e6d114e3ea64db27ec` (logo-dark.png, 210,785 B) |
+| Brand files | `assets/brand/` in this repository (the published masters the generators and the footer use): `logo.png`, `logo-dark.png` (the exemplar pair), `logo-dark-full.png` (the candidate dark master, decision 19) and `mark-light.png`. `mark-dark.png` and `bulb-dark.png` are not shipped yet (see `assets/brand/README.md`). The former `CompleteTech-LLC/.github/brand/` location is historical only: those URLs returned 404, and the footer serves the files from `assets/brand/` (same sha256). | as committed | PNG; see `assets/brand/README.md` for modes |
+| Logo pair in Skills repositories | `assets/logo.png`, `assets/logo-dark.png` | byte-identical to the `assets/brand/` masters (same sha256) | sha256 `a5c3c4ef55336ed4b883d3bc3bb6e1e942b836469bd2b2933bc8cc230b321621` (logo.png, 243,500 B) and `0dc00c0e5159518ff1d552b7052293b4e545ddd35be489e6d114e3ea64db27ec` (logo-dark.png, 210,785 B) |
| Social preview | `assets/social-preview.png` | 1280×640 | PNG ≤ 900 KB (heavier: JPG q85); hard limit under 1 MB; uploaded in the browser |
| Org avatar | `.github/profile/assets/avatar-500.png` (+ `avatar-1024.png` master) | 500×500 | Opaque PNG ≤ 300 KB; uploaded in the browser |
| Org banner | `.github/profile/assets/banner.jpg` | 1600×400 | JPG q85 ≤ 400 KB |
diff --git a/references/brand.lock.json b/references/brand.lock.json
index 2e016c5..9675407 100644
--- a/references/brand.lock.json
+++ b/references/brand.lock.json
@@ -460,10 +460,15 @@
"footer": {
"alt": "CompleteTech LLC",
"href": "https://www.complete.tech",
+ "legacy_logo_urls": [
+ "https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo.png",
+ "https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo-dark.png",
+ "https://www.complete.tech/wp-content/uploads/logo.png"
+ ],
"line_prefix": "Built by",
"line_suffix": "· Innovation at Every Integration",
- "logo_dark_url": "https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo-dark.png",
- "logo_light_url": "https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo.png",
+ "logo_dark_url": "https://raw.githubusercontent.com/CompleteTech-Skills/github-showcase-skill/main/assets/brand/logo-dark.png",
+ "logo_light_url": "https://raw.githubusercontent.com/CompleteTech-Skills/github-showcase-skill/main/assets/brand/logo.png",
"width": 200
},
"languages": {
diff --git a/references/brand.schema.json b/references/brand.schema.json
index 9e40fc9..8d08de3 100644
--- a/references/brand.schema.json
+++ b/references/brand.schema.json
@@ -352,6 +352,13 @@
},
"footer": {
"type": "object",
+ "properties": {
+ "legacy_logo_urls": {
+ "type": "array",
+ "items": {"type": "string", "pattern": "^https://"},
+ "uniqueItems": true
+ }
+ },
"required": [
"href",
"logo_light_url",
diff --git a/references/brand.yaml b/references/brand.yaml
index fc0fc73..ec6c167 100644
--- a/references/brand.yaml
+++ b/references/brand.yaml
@@ -33,8 +33,14 @@ brand_assets:
footer:
href: https://www.complete.tech
- logo_light_url: https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo.png
- logo_dark_url: https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo-dark.png
+ logo_light_url: https://raw.githubusercontent.com/CompleteTech-Skills/github-showcase-skill/main/assets/brand/logo.png
+ logo_dark_url: https://raw.githubusercontent.com/CompleteTech-Skills/github-showcase-skill/main/assets/brand/logo-dark.png
+ # Former publication paths: recognized only to migrate existing footers safely.
+ legacy_logo_urls:
+ - https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo.png
+ - https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/logo-dark.png
+ # Historical company-website logo (an own-site README layout); recognized only to migrate such footers.
+ - https://www.complete.tech/wp-content/uploads/logo.png
alt: CompleteTech LLC
width: 200
line_prefix: Built by
diff --git a/references/decisions.md b/references/decisions.md
index 3738df1..832b66a 100644
--- a/references/decisions.md
+++ b/references/decisions.md
@@ -1,6 +1,8 @@
# User decisions this skill implements
-Verbatim excerpt from `decisions/USER_DECISIONS.md` (Timothy Wayne Gregg, 2026-10-25) in the estate hand-off bundle. These decisions outrank the estate standard `ct-std-v1` (`STANDARD.md`), the art direction and every audit proposal. Where this skill's references paraphrase the standard, the decisions below have already been applied.
+Verbatim excerpt from `decisions/USER_DECISIONS.md` (Timothy Wayne Gregg, 2026-10-25) in the estate hand-off bundle, except that the skill note under decision 18 is skill maintenance and not part of the user decision. These decisions outrank the estate standard `ct-std-v1` (`STANDARD.md`), the art direction and every audit proposal. Where this skill's references paraphrase the standard, the decisions below have already been applied.
+
+Skill maintenance (#47): the footer logo URLs configured in `references/brand.yaml` were repaired because the former URLs returned 404. Layout, credit and artwork are unchanged. The user decisions below are unmodified.
| Decision | Where the skill applies it |
|---|---|
@@ -76,6 +78,9 @@ Verbatim excerpt from `decisions/USER_DECISIONS.md` (Timothy Wayne Gregg, 2026-1
Built by CompleteTech LLC · Innovation at Every Integration
+
@@ -244,7 +244,7 @@ Never modify, rename or delete an existing LICENSE, NOTICE or COPYING file.
```
-The 10 lines between the markers are byte-exact; `footer --check` reports `fail:missing`, `fail:not-last`, `fail:drift` or `fail:duplicate` (an old footer-like block, centred with "Built by" and complete.tech outside code, kept before the decision 18 footer), and `footer --check README.md --online` also sends a read-only HEAD to both raw logo URLs and fails (`fail:offline`) unless both return 200 (the Phase 4.0 hard gate before any README PR links them). It comes after the License section. Scope: every in-scope README, T2 families included. Exceptions: watching-org forks (`exempt`; the watching org's `.github` gets the footer, and its other non-fork repositories are held, `hold:watching-non-fork`, until decided) and T3 until sign-off (refused). TouchedByAnAgent is included (decision 18); an org whose `footer` is set to `hold` in `brand.yaml` logs `hold`. Forks outside the watching org (G), archived repositories (H) and `.github` repositories (J) get the footer too. It is the only `
`/``, complete.tech links, the footer's own logo image (its src or srcset is the brand footer logo URL, the same file on another branch, with a query, or in its github.com blob/raw form, or its alt is exactly the company; never an image because its file name contains "logo") and the "Built by" credit line, i.e. an unmarked copy of the footer or a drifted one) is replaced by the marked decision 18 footer, never kept beside it, on the preserve list too. A centred block with any other line (a partner or sponsor logo, any other image, a thanks line) is authored content and is kept; `lint readme` then reports `D18 footer fail:duplicate` if it reads like a footer |
+| `upgrade` | The restructuring steps above, with the never-delete multiset check. The tagline block follows the `header` rule (with no description, a tagline written for the repository stays; only an inherited one is emptied, and the check does not count that dropped line as lost): the rebuilt header carries it, and on the preserve list (or over 15000 bytes) the block stays where it is. With no description, when upgrade rebuilds the header (not in preserve mode), a single bare `**...**` line opening the README after its header lines (H1, badges, banner, `---`) is the old tagline and moves into the block by the same rule; a second one, one beside a tagline block that already holds a line, a bold line inside a centred `
`/``, links to the configured footer host (`footer.href`), the footer's own logo image (it qualifies only by its parsed src or srcset: the brand footer logo URL (the same file on another branch, with a query, or in its github.com blob/raw form), a `footer.legacy_logo_urls` entry from references/brand.yaml, or the conventional local `docs/images/logo.svg` under the company alt; matching the company alt alone never qualifies, and neither does an image because its file name contains "logo") and the "Built by" credit line, i.e. an unmarked copy of the footer or a drifted one) is replaced by the marked decision 18 footer, never kept beside it, on the preserve list too. A centred block with any other line (a partner or sponsor logo, any other image, a thanks line) is authored content and is kept; `lint readme` then reports `D18 footer fail:duplicate` if it reads like a footer |
| `header` | Touches the marker blocks only (what the Action runs). Authored text outside the blocks comes back byte-identical, fenced code included (a marker block, `` or template-body block shown inside fenced or inline code is an example, never regenerated); blank lines are tidied only inside regenerated blocks and where a block meets authored text (a removed block leaves one blank line). With no description the tagline block is kept as written (logged `needs-input:description`), except an inherited tagline, which is emptied: the block holds exactly a tagline of the template the repository was generated from (`meta` `template_taglines`: the template README's tagline block, the first one outside code, when the repository was generated and today), or, only when those are unknown, the template body is still pristine; never in the template repository itself (R2: the tagline is the description, never another repository's line), and the paragraph after `` (and its heading) is rewritten from the license table whenever it can be derived |
| `--bootstrap` | Template only. The block between `` and `:end -->` starts with ``, the hash of the block's generated text. While the block still matches its hash byte for byte, bootstrap replaces it with the scaffolded variant body for this repository (each section holds only an HTML comment naming the facts it needs) under a new hash, rebuilds the header for the variant (Variant F: no H1; a CVE slug titles as `Acme CVE-2026-12345 Check`; the tagline block carries over and follows the `header` rule, so with no description a tagline written for the repository stays and only an inherited one is emptied), writes the license section from the table above (with or without a `LICENSE` file; a private repository reads "Proprietary...", a public Security repository gets `## Licensing`), and rewrites the owner tokens in `CONTRIBUTING.md` (only links to the template owner's `.github`; a link to another org's or an upstream's contributing guide or security policy stays as written). Because the hash still matches, a later run re-bootstraps when the type changes (the code landed, or `type:` was set in `.github/showcase.yml`). Once a person edits the block, the hash no longer matches: the marker is dropped (`bootstrap:edited`), the README goes through `upgrade` with the never-drop-a-line check, and the block is never rewritten again. A no-op when the markers are absent. |
diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py
index 9e1353f..2167ddb 100644
--- a/scripts/ctshowcase/readme.py
+++ b/scripts/ctshowcase/readme.py
@@ -463,11 +463,19 @@ def fork_callout(ctx: RepoContext) -> str | None:
BANNER_BLOCK = re.compile(re.escape(start("banner")) + r".*?" + re.escape(end("banner")) + r"\n*", re.S)
-def callout_only(ctx: RepoContext, text: str) -> str:
+def callout_only(ctx: RepoContext, text: str) -> tuple[str, list[str]]:
"""Variants G and H (§7.4): the hero banner block, then the callout (unless one is already there), then the body
unchanged, then the decision 18 footer. Nothing else changes. Watching-org forks never reach here (`readme`
exempts them). Review q27 "Full CompleteTech treatment" (brand.yaml readme.fork_readme: full) means the fork
- follows its §5.4 variant instead: pass that variant with `readme --variant`."""
+ follows its §5.4 variant instead: pass that variant with `readme --variant`.
+ Returns (text, removed lines): a recognized old footer (marked or unmarked, `is_footer_copy`: for example one with
+ the former logo URLs) is replaced by the current one, and only the removed footer lines that the replacement footer
+ does not carry again are reported (multiset difference), so the preservation check accepts them. A re-emitted
+ line is not reported, so it cannot weaken the never-drop-a-line guard. A marked footer that is not a footer copy
+ (a partner image) is left as it is. The trailing unmarked footer additionally accepts the brand's own-site historical
+ logo sources (`footer.legacy_logo_urls` entries and `docs/images/logo.svg`) under a vouching alt, while partner,
+ foreign and look-alike images stay authored. A removed recognized footer is always replaced, even when fenced
+ example text makes check() report drift."""
if ctx.det["variant"] == "G" and ctx.brand.readme_opts.get("fork_readme") == "full":
ctx.log("needs-input:fork-variant (q27 full treatment: pass readme --variant for the §5.4 variant)")
body = text
@@ -490,9 +498,35 @@ def callout_only(ctx: RepoContext, text: str) -> str:
elif m:
lead_banner = text.lstrip("\n")[:m.end()].rstrip("\n") + "\n\n"
out = lead_banner + out.lstrip("\n")
- if footer_wanted(ctx) and footermod.check(ctx.brand, out)[0] == "fail:missing":
- out = out.rstrip("\n") + "\n\n" + footermod.block(ctx.brand) + "\n"
- return out
+ removed: list[str] = []
+ if footer_wanted(ctx):
+ had_footer = False
+ if footermod.check(ctx.brand, out)[0] != "pass":
+ pos, kept = 0, []
+ for fm in find_blocks(MARK, out):
+ if fm.group(1) == "footer" and is_footer_copy(fm.group(2), ctx.brand):
+ kept.append(out[pos:fm.start()])
+ removed += [ln for ln in fm.group(2).splitlines() if ln.strip()]
+ had_footer = True
+ pos = fm.end()
+ out = "".join(kept) + out[pos:]
+ out, old_footer = drop_old_footer(out, ctx.brand, own_site=True)
+ removed += old_footer
+ if old_footer:
+ had_footer = True
+ if had_footer or footermod.check(ctx.brand, out)[0] == "fail:missing":
+ out = out.rstrip("\n") + "\n\n" + footermod.block(ctx.brand) + "\n"
+ # Report only the removed footer lines that the replacement footer does not carry again (multiset
+ # difference), so a re-emitted line never counts as lost content in the preservation check.
+ kept = Counter(ln.strip() for ln in footermod.block(ctx.brand).splitlines() if ln.strip())
+ rest = []
+ for ln in removed:
+ if kept[ln.strip()] > 0:
+ kept[ln.strip()] -= 1
+ else:
+ rest.append(ln)
+ removed = rest
+ return out, removed
def preserved(ctx: RepoContext, text: str) -> bool:
@@ -511,10 +545,20 @@ def preserved(ctx: RepoContext, text: str) -> bool:
CENTRED_OPEN = re.compile(r'(?m)^[ \t]*<(p|div) align="center">')
-def is_footer_like(block: str) -> bool:
- """A centred block that credits the company with a complete.tech link: the decision 18 footer, exact or drifted."""
- return bool(re.match(r'\s*<(p|div) align="center">', block)) and "built by" in block.lower() \
- and "complete.tech" in block.lower()
+def is_footer_like(block: str, brand=None) -> bool:
+ """A centred block that credits the company with a complete.tech link (or, given `brand`, a link to the configured
+ footer host): the decision 18 footer, exact or drifted. A cheap pre-filter; `is_footer_copy` decides what is removed."""
+ low = block.lower()
+ hosts = ["complete.tech"]
+ if brand is not None:
+ from urllib.parse import urlsplit
+ try:
+ h = (urlsplit(brand.raw["footer"]["href"]).hostname or "").lower()
+ except (KeyError, TypeError, ValueError):
+ h = ""
+ if h:
+ hosts.append(h[4:] if h.startswith("www.") else h)
+ return bool(re.match(r'\s*<(p|div) align="center">', block)) and "built by" in low and any(h in low for h in hosts)
FOOTER_TAG = re.compile(r"?(p|div|a|picture|source|img|br|sub|sup|b|strong|em)\b([^>]*)>", re.I)
@@ -522,13 +566,121 @@ def is_footer_like(block: str) -> bool:
FOOTER_ATTR = re.compile(r"""\b(src|srcset|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
+FOOTER_HREF = re.compile(r"""\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
+
+# one whole attribute (name plus optional quoted or bare value), so a name or value inside another attribute's quotes
+# (title="href=...") is never read as an attribute of its own
+FOOTER_ATTR_TOKEN = re.compile(r"""\s*([^\s"'=>/]+)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?""")
+
+FOOTER_IMG_ATTRS = ("alt", "src", "srcset")
+
+
+def _footer_attrs(attrs: str) -> dict[str, str]:
+ """The `alt`, `src` and `srcset` attributes of one tag's attribute text, parsed as whole attributes: exact
+ (case-insensitive) names only, so `data-alt`, `xalt` or text inside another attribute's quoted value never count;
+ the first occurrence of a name wins, as a browser reads it; the value is returned with its quotes removed. A
+ recognised attribute with no value is recorded as an empty string, so a later duplicate cannot override it."""
+ got: dict[str, str] = {}
+ for tok in FOOTER_ATTR_TOKEN.finditer(attrs):
+ name = tok.group(1).lower()
+ if name not in FOOTER_IMG_ATTRS or name in got:
+ continue
+ if tok.group(2) is None:
+ got[name] = ""
+ continue
+ val = tok.group(2).split("=", 1)[1].strip()
+ if len(val) >= 2 and val[0] in "\"'" and val[-1] == val[0]:
+ val = val[1:-1]
+ got[name] = val
+ return got
+
+
+OLD_LOCAL_LOGOS = {'assets/logo.png', 'logo.png', 'assets/logo-dark.png', 'logo-dark.png'}
+
+
+def _old_local_logo(url: str) -> bool:
+ """`url` is a former local footer logo file (`OLD_LOCAL_LOGOS`), relative and without a scheme, host or data URI."""
+ url = url.strip()
+ if "://" in url or url.startswith("//") or url.startswith("data:"):
+ return False
+ path = url.split("#", 1)[0].split("?", 1)[0]
+ if path.startswith("./"):
+ path = path[2:]
+ elif path.startswith("/"):
+ path = path[1:]
+ return path in OLD_LOCAL_LOGOS
+
+
+# A file CONVENTION (the logo a former own-site README layout kept beside the docs), not brand data.
+OWN_SITE_LOCAL_LOGOS = {"docs/images/logo.svg"}
+
+
+def _own_site_logo(url: str, brand) -> bool:
+ """`url` is a configured historical logo source of this brand: it equals, exactly, an http(s) entry of
+ `footer.legacy_logo_urls` in references/brand.yaml (scheme, host case-insensitively, port, path; query and
+ fragment ignored; no userinfo), so no brand host or path lives in Python. The generic local `docs/images/logo.svg`
+ (`OWN_SITE_LOCAL_LOGOS`) is a file convention, distinct from that configured data. Any other file on the same host
+ (a team photo, a screenshot) is authored content and is kept."""
+ from urllib.parse import urlsplit
+ url = url.strip()
+ if "://" not in url:
+ path = url.split("#", 1)[0].split("?", 1)[0]
+ return (path[2:] if path.startswith("./") else path) in OWN_SITE_LOCAL_LOGOS
+ legacy = brand.raw.get("footer", {}).get("legacy_logo_urls") or []
+ try:
+ got = urlsplit(url)
+ if got.username is not None or got.password is not None or not got.hostname:
+ return False
+ for entry in legacy:
+ if not isinstance(entry, str):
+ continue
+ want = urlsplit(entry.strip())
+ if (want.scheme in ("http", "https") and got.scheme == want.scheme and want.hostname
+ and got.hostname == want.hostname and got.port == want.port
+ and got.path == want.path):
+ return True
+ except ValueError:
+ return False
+ return False
+
+
+def _footer_href_ok(href: str, brand) -> bool:
+ """`href` is an http(s) link to the configured footer link host (`footer.href`, never hardcoded): the same hostname,
+ case-insensitively, with a leading `www.` equivalent on either side; no userinfo, port or other host. Path, query
+ and fragment are ignored. A suffix, look-alike or partner host, or a complete.tech mention outside the host, is not."""
+ import html
+ from urllib.parse import urlsplit
+ try:
+ got = urlsplit(html.unescape(href).strip())
+ want = urlsplit(brand.raw["footer"]["href"])
+ if got.scheme not in ("http", "https") or got.hostname is None:
+ return False
+ if got.username is not None or got.password is not None or got.port is not None:
+ return False
+ if want.hostname is None:
+ return False
+
+ def _norm(host: str) -> str:
+ host = host.lower()
+ return host[4:] if host.startswith("www.") else host
+
+ return _norm(got.hostname) == _norm(want.hostname)
+ except ValueError:
+ return False
+
def _footer_logo_url(url: str, brand) -> bool:
"""`url` is one of the decision 18 footer's own logo files (brand footer `logo_light_url` or `logo_dark_url`),
- as configured or drifted only in scheme, branch, query or the github.com blob/raw form of the same file."""
+ including explicitly configured legacy URLs, or drifted only in scheme, branch, query or the github.com
+ blob/raw form of the same file."""
url = url.strip().split("#", 1)[0].split("?", 1)[0]
- for key in ("logo_light_url", "logo_dark_url"):
- want = brand.raw["footer"][key]
+ footer = brand.raw["footer"]
+ urls = [footer[key] for key in ("logo_light_url", "logo_dark_url")]
+ # Legacy publication copies of the footer logo (GitHub raw/blob) are recognized on their own; any other legacy
+ # entry (a company-website logo) is an own-site source that only `_own_site_logo` accepts, under a vouching alt.
+ urls.extend(u for u in footer.get("legacy_logo_urls") or []
+ if isinstance(u, str) and u.startswith("https://raw.githubusercontent.com/"))
+ for want in urls:
if url == want:
return True
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/[^/]+/(.+)$", want)
@@ -541,31 +693,59 @@ def _footer_logo_url(url: str, brand) -> bool:
return False
-def is_footer_copy(block: str, brand) -> bool:
+def is_footer_copy(block: str, brand, loose_src: bool = False, own_site: bool = False) -> bool:
"""Every line of `block` is a line of the decision 18 footer or a drifted copy of one: only centring, link, logo,
- `
`/`` markup, with complete.tech links, the footer's own logo image (its src or srcset is the brand footer
- logo URL or a drifted form of it, or its alt is exactly the company) and as text only the credit line ("Built by
-
`/`` markup, with anchors that target the configured footer link host (`footer.href`), the footer's own logo image (its src or srcset is the brand footer
+ logo URL or a drifted form of it; an image without src/srcset passes on an alt that is exactly the company) and as text only the credit line ("Built by
+ alt vouches for its former local logo files
+ block_alt_ok = any(v in (company, f["alt"]) for m in FOOTER_TAG.finditer(block)
+ if m.group(1).lower() == "img" and not m.group(0).startswith("") for v in _alts(m.group(2)))
+
def tag_ok(m: re.Match) -> bool:
name, attrs = m.group(1).lower(), m.group(2)
if m.group(0).startswith("") or name not in ("a", "img", "source"):
return True
if name == "a":
- return "complete.tech" in attrs.lower()
- for a in FOOTER_ATTR.finditer(attrs): # never a file name that merely contains "logo"
- key = a.group(1).lower()
- val = html.unescape(next(g for g in a.groups()[1:] if g is not None)).strip()
- if key == "alt" and val in (company, f["alt"]):
- return True
- parts = [p.split()[0] for p in val.split(",") if p.split()]
- if key in ("src", "srcset") and parts and all(_footer_logo_url(u, brand) for u in parts):
+ href = None
+ for tok in FOOTER_ATTR_TOKEN.finditer(attrs): # first href attribute, as a browser reads it
+ if tok.group(1).lower() == "href":
+ hm = FOOTER_HREF.search(tok.group(0))
+ href = next((g for g in hm.groups() if g is not None), None) if hm else None
+ break
+ return href is not None and _footer_href_ok(href, brand)
+ alt_ok, urls = False, []
+ for key, raw in _footer_attrs(attrs).items(): # whole attributes, exact names; never a file name that merely contains "logo"
+ val = html.unescape(raw).strip()
+ if key == "alt":
+ alt_ok = alt_ok or val in (company, f["alt"])
+ else:
+ urls.append([p.split()[0] for p in val.split(",") if p.split()])
+ if name == "source":
+ alt_ok = block_alt_ok
+ if urls: # a matching alt never vouches for another image (unless loose_src): every src/srcset must be a footer logo URL
+ if loose_src and alt_ok and name == "img":
return True
- return False
+ return all(parts and all(_footer_logo_url(u, brand) or (alt_ok and (_old_local_logo(u) or (
+ own_site and name == "img" and _own_site_logo(u, brand)))) for u in parts)
+ for parts in urls)
+ return alt_ok
for line in block.splitlines():
if not line.strip():
@@ -578,35 +758,65 @@ def tag_ok(m: re.Match) -> bool:
return True
-def drop_old_footer(text: str, brand) -> tuple[str, list[str]]:
+def drop_old_footer(text: str, brand, loose_src: bool = False, own_site: bool = False) -> tuple[str, list[str]]:
"""(text, removed lines) with a trailing old footer (an unmarked or drifted decision 18 footer: every line a
footer line, `is_footer_copy`) taken off the end, so the marked footer replaces it instead of following it. Any
other block, a centred credits block with authored lines included, is left as it is (lint reports a second
- footer-like block as `fail:duplicate`)."""
+ footer-like block as `fail:duplicate`). `loose_src` and `own_site` are passed through to `is_footer_copy` (strict by default)."""
body = text.rstrip()
opens = [m for m in CENTRED_OPEN.finditer(masked(body))]
if not opens:
return text, []
m = opens[-1]
tail = body[m.start():]
- if not tail.rstrip().endswith(f"{m.group(1)}>") or not is_footer_like(tail) or not is_footer_copy(tail, brand):
+ if not tail.rstrip().endswith(f"{m.group(1)}>") or not is_footer_like(tail, brand) or not is_footer_copy(tail, brand, loose_src, own_site):
return text, []
return body[:m.start()].rstrip() + "\n", [ln for ln in tail.splitlines() if ln.strip()]
+def _unreemitted(lines: list[str], kept: Counter) -> list[str]:
+ """`lines` minus the lines the emitted footer carries again: each non-empty line matched in the multiset `kept`
+ (stripped text) is dropped and decrements `kept`; the others are returned with their original text."""
+ out: list[str] = []
+ for ln in lines:
+ s = ln.strip()
+ if not s:
+ continue
+ if kept[s] > 0:
+ kept[s] -= 1
+ else:
+ out.append(ln)
+ return out
+
+
def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]:
"""Return (new text, header lines removed). Raises nothing; callers run the shrink checks."""
variant = ctx.det["variant"]
rv = standard("readme-variants")
if variant in ("G", "H"):
- return callout_only(ctx, normalize_text(text)), []
+ return callout_only(ctx, normalize_text(text))
text = normalize_text(text)
body, blocks = _strip_markers(text)
want_footer = footer_wanted(ctx)
removed: list[str] = []
+ # One multiset of the footer lines the emitted footer carries again, shared by the marked and the unmarked branch
+ # because only one new footer is emitted. A compliant footer is re-emitted unchanged and must not weaken the
+ # never-drop-a-line guard, so only the old lines it does not carry again are reported as removed.
+ kept: Counter = Counter()
+ if want_footer:
+ kept = Counter(ln.strip() for ln in footermod.block(ctx.brand).splitlines() if ln.strip())
+ if want_footer and blocks.get("footer") and is_footer_copy(blocks["footer"], ctx.brand):
+ # A configured publication-path change replaces only our recognized footer, not authored credits or partner
+ # images inside a similarly named block.
+ for ln in blocks["footer"].splitlines():
+ if ln.strip():
+ if kept[ln.strip()] > 0:
+ kept[ln.strip()] -= 1
+ else:
+ removed.append(ln)
if want_footer: # an unmarked or drifted footer at the end is replaced by the marked one, never kept beside it
- body, old_footer = drop_old_footer(body, ctx.brand)
- removed += old_footer
+ body, old_footer = drop_old_footer(body, ctx.brand, own_site=True)
+ removed += _unreemitted(old_footer, kept)
# preserve list / existing third-party banner
preserve = ctx.repo in (ctx.org.get("readme_preserve") or []) or len(text.encode()) > 15000
pre, sections = split_sections(body)
@@ -625,7 +835,7 @@ def upgrade(ctx: RepoContext, text: str) -> tuple[str, list[str]]:
removed += [ln for ln in old_tag.splitlines() if ln.strip()]
body, _ = _strip_markers(text, keep={"tagline": tag})
if want_footer:
- body, _ = drop_old_footer(body, ctx.brand) # its lines are already in `removed`
+ body, _ = drop_old_footer(body, ctx.brand, own_site=True) # its lines are already in `removed`
pre, sections = split_sections(body)
lines = pre.splitlines()
# an unmarked bold line opening the preamble is an old tagline. With a description it is replaced; without one it
diff --git a/scripts/showcase.py b/scripts/showcase.py
index 244bfd0..1d8f536 100644
--- a/scripts/showcase.py
+++ b/scripts/showcase.py
@@ -375,6 +375,17 @@ def cmd_footer(args):
return None, OK
+def _authored_footer_refusal(ctx, text):
+ """[] when every marked footer block of `text` is a footer copy the footer refresh may replace; otherwise the
+ refusal (a partner or foreign image is authored content, and the refresh would remove it)."""
+ from ctshowcase import readme
+ for fm in readme.find_blocks(readme.MARK, readme.normalize_text(text)):
+ if fm.group(1) == "footer" and not readme.is_footer_copy(fm.group(2), ctx.brand, own_site=True):
+ return ["readme-shrink: the marked footer holds authored content (a partner or foreign image) that the "
+ "footer refresh would remove; README left unchanged"]
+ return []
+
+
def cmd_readme(args):
from ctshowcase import readme, records
ctx = context(args)
@@ -396,6 +407,11 @@ def cmd_readme(args):
if args.bootstrap:
state = readme.bootstrap_state(before)
removed = []
+ # every state (pristine, edited, none) refuses before any write: the footer refresh below would replace it
+ problems = _authored_footer_refusal(ctx, before)
+ if problems:
+ return base("readme", ctx, result="fail", mode="bootstrap", problems=problems,
+ logs=ctx.logs + ["readme-shrink"]), CHECK_FAILED
if state == "pristine":
after = readme.bootstrap(ctx, before)
cp = rd / "CONTRIBUTING.md"
@@ -410,6 +426,12 @@ def cmd_readme(args):
base_text = readme.drop_pristine(before)
after, removed = readme.upgrade(ctx, base_text)
if readme.preservation(base_text, after, removed):
+ # a marked footer that holds authored content (partner or foreign image) must never be regenerated
+ refusal = _authored_footer_refusal(ctx, base_text)
+ if refusal:
+ return base("readme", ctx, result="fail", mode="bootstrap",
+ problems=refusal + readme.preservation(base_text, after, removed)[:19],
+ logs=ctx.logs + ["readme-shrink"]), CHECK_FAILED
ctx.log("readme-shrink")
after, removed = readme.header_only(ctx, base_text), []
else:
diff --git a/tests/test_footer.py b/tests/test_footer.py
index 520cb66..6822863 100644
--- a/tests/test_footer.py
+++ b/tests/test_footer.py
@@ -3,8 +3,8 @@
DECISION_18 = """
+
diff --git a/tests/test_footer_attr_tokens.py b/tests/test_footer_attr_tokens.py
new file mode 100644
index 0000000..d6f0411
--- /dev/null
+++ b/tests/test_footer_attr_tokens.py
@@ -0,0 +1,192 @@
+"""Footer image attributes are matched as whole attribute names (issue 47, source finding P2 at 4823d10).
+
+`is_footer_copy` used to read `data-alt="\n
\n%s\n\n
\n'
+ ' Built by CompleteTech LLC · Innovation at Every Integration\n'
+ "
\n'
+
+
+def banner_wrapped_legacy_readme(brand):
+ banner = f"{readme.start('banner')}\n{BANNER_BODY}\n{readme.end('banner')}"
+ filler = "Authored paragraph that stays exactly as written in the upstream project.\n\n" * 40
+ return banner + "\n\n" + UPSTREAM + "\n" + filler + footer.block(legacy_brand(brand)) + "\n"
+
+
+@pytest.mark.parametrize("variant", ["G", "H"])
+def test_banner_wrapper_loss_beside_legacy_footer_is_refused(brand, tmp_path, variant):
+ ctx = ctx_for(brand, variant, tmp_path)
+ before = banner_wrapped_legacy_readme(brand)
+ after, removed = readme.upgrade(ctx, before)
+ problems = readme.preservation(before, after, removed)
+ assert problems, "the banner loss must not be accepted"
+ # the footer also carries a standalone , so the banner's own wrapper line must still be counted as lost
+ assert "lost line: " in problems, problems
+
+
+@pytest.mark.parametrize("variant", ["G", "H"])
+def test_cli_refuses_banner_wrapper_loss_on_fork_footer_migration(brand, tmp_path, variant):
+ import json
+
+ from conftest import run_cli
+
+ meta = tmp_path / "meta.json"
+ meta.write_text(json.dumps(meta_dict(**VARIANTS[variant])), encoding="utf-8")
+ readme_path = tmp_path / "README.md"
+ before = banner_wrapped_legacy_readme(brand)
+ readme_path.write_text(before, encoding="utf-8", newline="")
+ p = run_cli("--meta", meta, "--repo-dir", tmp_path, "readme", "--write")
+ assert p.returncode != 0 and "readme-shrink" in p.stdout + p.stderr, p.stdout + p.stderr
+ assert readme_path.read_bytes() == before.encode("utf-8"), "a refused write must leave README.md unchanged"
diff --git a/tests/test_footer_public_assets.py b/tests/test_footer_public_assets.py
new file mode 100644
index 0000000..f1827df
--- /dev/null
+++ b/tests/test_footer_public_assets.py
@@ -0,0 +1,108 @@
+"""The footer uses published masters and migrates the former broken asset paths."""
+import copy
+
+from conftest import ROOT, meta_dict
+from ctshowcase import footer, readme
+from ctshowcase.config import Brand
+from ctshowcase.context import RepoContext
+from ctshowcase.meta import normalize
+
+
+PUBLIC = "https://raw.githubusercontent.com/CompleteTech-Skills/github-showcase-skill/main/assets/brand/"
+LEGACY = "https://raw.githubusercontent.com/CompleteTech-LLC/.github/main/brand/"
+
+
+def relocated(brand):
+ raw = copy.deepcopy(brand.raw)
+ raw["footer"].update(
+ logo_light_url=PUBLIC + "logo.png",
+ logo_dark_url=PUBLIC + "logo-dark.png",
+ legacy_logo_urls=[LEGACY + "logo.png", LEGACY + "logo-dark.png"],
+ )
+ return Brand(raw, brand.path)
+
+
+def old_brand(brand):
+ raw = copy.deepcopy(brand.raw)
+ raw["footer"].update(logo_light_url=LEGACY + "logo.png", logo_dark_url=LEGACY + "logo-dark.png")
+ return Brand(raw, brand.path)
+
+
+def test_configured_footer_points_to_the_published_bundled_masters(brand):
+ for key, filename in (("logo_light_url", "logo.png"), ("logo_dark_url", "logo-dark.png")):
+ assert brand.raw["footer"][key] == PUBLIC + filename
+ assert (ROOT / "assets" / "brand" / filename).is_file()
+ seen = []
+
+ def published(url):
+ seen.append(url)
+ return 200 if url.startswith(PUBLIC) else 404
+
+ assert footer.online(brand, head=published)[0] == "pass"
+ assert seen == [PUBLIC + "logo.png", PUBLIC + "logo-dark.png"]
+
+
+def test_upgrade_replaces_marked_legacy_footer_without_shrink(brand, tmp_path):
+ before_ctx = RepoContext(old_brand(brand), normalize(meta_dict()), tmp_path)
+ after_ctx = RepoContext(relocated(brand), normalize(meta_dict()), tmp_path)
+ before = readme.scaffold(before_ctx)
+ after, removed = readme.upgrade(after_ctx, before)
+ assert readme.preservation(before, after, removed) == []
+ assert LEGACY not in after
+ assert footer.check(after_ctx.brand, after)[0] == "pass"
+ again, _ = readme.upgrade(after_ctx, after)
+ assert again == after
+
+
+def test_unmarked_legacy_footer_is_replaced_but_partner_credit_is_not(brand):
+ current = relocated(brand)
+ old = footer.markup(old_brand(brand))
+ body, removed = readme.drop_old_footer("# Synthetic\n\n" + old + "\n", current)
+ assert body == "# Synthetic\n"
+ assert removed == [line for line in old.splitlines() if line.strip()]
+ partner = old.replace(LEGACY + "logo.png", "https://example.test/partner.png").replace(
+ 'alt="CompleteTech LLC"', 'alt="Partner"'
+ )
+ assert not readme.is_footer_copy(partner, current)
+ assert readme.drop_old_footer("# Synthetic\n\n" + partner, current)[1] == []
+
+
+def test_legacy_url_matching_is_limited_to_configured_asset_files(brand):
+ current = relocated(brand)
+ assert readme._footer_logo_url(LEGACY + "logo.png", current)
+ assert readme._footer_logo_url(LEGACY.replace("/main/", "/release/") + "logo-dark.png?v=2", current)
+ assert not readme._footer_logo_url(LEGACY + "partner-logo.png", current)
+
+
+def test_authored_content_inside_footer_markers_is_not_exempt(brand, tmp_path):
+ current = relocated(brand)
+ ctx = RepoContext(current, normalize(meta_dict()), tmp_path)
+ before = readme.scaffold(ctx).replace(
+ footer.END, "Authored partner acknowledgement.\n" + footer.END
+ )
+ after, removed = readme.upgrade(ctx, before)
+ assert "Authored partner acknowledgement." not in removed
+ assert any("Authored partner acknowledgement." in problem
+ for problem in readme.preservation(before, after, removed))
+
+
+def test_scheduled_workflow_really_heads_the_footer_logos():
+ import re
+
+ import yaml
+
+ text = (ROOT / ".github" / "workflows" / "footer-assets.yml").read_text(encoding="utf-8")
+ wf = yaml.safe_load(text)
+ triggers = wf.get("on", wf.get(True)) # PyYAML reads the bare key `on` as True
+ assert triggers["schedule"] and "cron" in triggers["schedule"][0]
+ assert "workflow_dispatch" in triggers
+ steps = [s for job in wf["jobs"].values() for s in job["steps"]]
+ online = [s for s in steps if s.get("run", "").strip() == "python scripts/showcase.py footer --check README.md --online"]
+ assert len(online) == 1
+ assert online[0].get("if") == "github.event_name != 'pull_request'"
+ assert "pull_request" in triggers
+ offline = [s for s in steps if "assets" not in s.get("run", "") and "brand.yaml" in s.get("run", "")]
+ assert len(offline) == 1 and offline[0].get("if") == "github.event_name == 'pull_request'"
+ assert "continue-on-error" not in online[0]
+ assert not any("continue-on-error" in job for job in wf["jobs"].values())
+ assert not re.search(r"\|\|\s*(true|:)", text)
diff --git a/tests/test_footer_r6_regressions.py b/tests/test_footer_r6_regressions.py
new file mode 100644
index 0000000..6d3417e
--- /dev/null
+++ b/tests/test_footer_r6_regressions.py
@@ -0,0 +1,65 @@
+"""Round r6 footer findings: unmarked old footers, \n
\n %s\n
\n
\n
\n