From fdaa0b49c29d1d9881b85afbb162f76bdd7ea72c Mon Sep 17 00:00:00 2001 From: Andrei Kislichenko Date: Tue, 11 Aug 2026 20:58:46 +0000 Subject: [PATCH] fix(data-deploy): resolve Pages branding from configs.yml, not the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cimas-config/cimas.yml` maps `.github/workflows/deploy.yml: gh-actions/data/deploy.yml` for 29 relaton-data-* repos as a verbatim whole-file copy. Since #59 the per-flavor branding lives in each caller's `with:` block, so a `cimas sync` wipes it — and that failure is entirely silent: the page just loses its favicon and description, with nothing red in CI. Twelve repos already carry a hand-edited `with:` block. Branding now comes from `data-index/configs.yml`, which was already the source of truth for these values but was only ever rendered into the Jekyll `_config.yml` that #58 retired. data-deploy.yml sparse-checks-out relaton/support at its own commit and resolves title/favicon/description per flavor, so the caller template carries no `with:` at all and is safe to sync. `source` is deliberately left a caller input: no released relaton-cli has the `index` command, and losing that pin fails loudly with `Could not find command "index"` — unlike branding. Centralising it would trade a loud failure for a silent one. - lib: DataIndexConfig.flavor + #branding (explicit arg > configs.yml > default; never raises, so relaton-data-ietf still builds unchanged). Private favicon/description become entry_*, and a shared entry_title keeps #render and #branding from drifting. - lib/github_output.rb + bin/index-branding: heredoc $GITHUB_OUTPUT encoding with a fresh random delimiter per value. `name=value` would truncate at the first newline and let a configs.yml description define arbitrary step outputs. - data-deploy.yml: pins the support checkout with `job.workflow_sha` / `job.workflow_repository`. Not the `github` context — a called workflow inherits the caller's, and `github.job_workflow_sha` is an OIDC claim rather than a property, so it would evaluate to null and silently resolve `ref:` to the default branch. Also moves `--title` into `env:`, which matters now that the value originates here rather than in the calling repo's own file. - configs.yml: records relaton-data-iana's favicon/description, which nothing in this repo held, and documents the favicon convention (the SDO's own icon where there is a stable URL, relaton.org otherwise). - specs: assert the caller template passes no inputs, generalised to every Cimas-synced template. Matches parsed scalars rather than raw text, since deploy.yml legitimately names relaton-data-iana in a comment. - ci-spec.yml: nothing ran the suite before. That was tolerable when these specs only guarded static templates; it is not now that this repo's lib/, bin/ and configs.yml are a runtime dependency of every relaton-data-* Pages build. --- .github/workflows/ci-spec.yml | 34 ++++ .github/workflows/data-deploy.yml | 127 ++++++++++---- bin/index-branding | 63 +++++++ cimas-config/gh-actions/data/deploy.yml | 14 ++ data-index/README.adoc | 76 ++++++--- data-index/configs.yml | 32 +++- data-index/generated/iana_config.yml | 4 +- lib/data_index_config.rb | 78 +++++++-- lib/github_output.rb | 52 ++++++ spec/cimas_data_pages_spec.rb | 68 ++++++++ spec/data_deploy_caller_template_spec.rb | 11 ++ spec/data_deploy_workflow_spec.rb | 138 +++++++++++++-- spec/data_index_config_spec.rb | 18 +- spec/github_output_spec.rb | 97 +++++++++++ spec/index_branding_spec.rb | 204 +++++++++++++++++++++++ 15 files changed, 939 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/ci-spec.yml create mode 100755 bin/index-branding create mode 100644 lib/github_output.rb create mode 100644 spec/github_output_spec.rb create mode 100644 spec/index_branding_spec.rb diff --git a/.github/workflows/ci-spec.yml b/.github/workflows/ci-spec.yml new file mode 100644 index 0000000..b87afc7 --- /dev/null +++ b/.github/workflows/ci-spec.yml @@ -0,0 +1,34 @@ +name: ci-spec + +# Run this repo's own spec suite. +# +# It existed before this workflow did but nothing invoked it: every other file in +# .github/workflows/ here is a `workflow_call` template synced into OTHER repos, +# and ci-lint.yml only lints. So the specs that pin the Cimas templates and the +# data-index config ran solely when someone remembered to `bundle exec rake`. +# +# That became load-bearing when data-deploy.yml started reading +# data-index/configs.yml at build time: this repo's lib/, bin/ and configs.yml are +# now a runtime dependency of every relaton-data-* Pages build, and a broken one +# fails all of them at once. These specs are the only thing standing in front of +# that, so they have to run on the PR that breaks them. + +on: + push: + branches: [ main ] + pull_request: + +jobs: + spec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + # Matches the version data-deploy.yml resolves branding under, so a + # syntax or stdlib difference cannot pass here and fail in a data repo. + ruby-version: "3.4" + bundler-cache: true + + - run: bundle exec rake diff --git a/.github/workflows/data-deploy.yml b/.github/workflows/data-deploy.yml index d381386..b21a0bb 100644 --- a/.github/workflows/data-deploy.yml +++ b/.github/workflows/data-deploy.yml @@ -20,9 +20,23 @@ name: Deploy # both the "skip a failed crawler run" guard and the Pages concurrency group are # declared here, so no per-repo copy can drift or forget them. # -# Optionally, a caller passes the per-flavor branding its `_config.yml` used to -# carry — `title`, `favicon` and `description` (see relaton/relaton#96). Left -# unset, the page carries no favicon and no description, exactly as before. +# Per-flavor branding — `title`, `favicon` and `description` (relaton/relaton#96) +# — is resolved HERE, from relaton/support's `data-index/configs.yml`, and not +# passed by the caller. cimas.yml maps `.github/workflows/deploy.yml` as a +# whole-file copy for 29 repos, so a `with:` block carrying branding is wiped on +# the next `cimas sync` and the page silently loses its favicon and description — +# nothing goes red. Keeping the caller byte-identical fleet-wide is what makes a +# sync safe; see `data-index/README.adoc`. +# +# The three inputs remain declared and still win when a caller sets one +# explicitly (a reusable workflow rejects an undeclared input at parse time, so +# they could not be removed without breaking existing callers anyway). Blank — +# what an omitted input arrives as — falls through to configs.yml. +# +# The trade this makes: relaton/support becomes a runtime dependency of every +# Pages build, not just the source of this file. A bad configs.yml breaks all 29 +# at once, which is why the resolve step runs before the expensive build and +# `job.workflow_sha` pins the data to the same commit as this workflow. # # Two source modes (input `source`): # * gem (default) — install the released relaton-cli gem. Ruby only; the gem @@ -39,20 +53,26 @@ on: required: false default: "gem" type: string + # The branding trio. Normally unset: `Resolve branding` below reads these + # per flavor from relaton/support's data-index/configs.yml. Setting one + # here overrides that repo's row for this caller only. title: - description: "Index page title (default: ' Index' from the repo name)" + description: "Override the index page title (default: from data-index/configs.yml)" required: false + default: "" type: string - # Both build steps pass these through unconditionally: relaton-cli treats a - # blank value as absent, so the empty default keeps the output identical - # for callers that set neither. favicon: - description: "Index page favicon URL or output-relative path (default: none)" + description: "Override the favicon URL or output-relative path (default: from data-index/configs.yml)" required: false default: "" type: string description: - description: "Index page description ( and header subtitle)" + description: "Override the description ( and header subtitle)" + required: false + default: "" + type: string + support-ref: + description: "relaton/support ref to read branding from (default: this workflow's own commit)" required: false default: "" type: string @@ -128,10 +148,60 @@ jobs: steps: - uses: actions/checkout@v4 # the data repo (has ./data) + # Must come AFTER the checkout above: a checkout into the workspace root + # clears the directory first, which would delete this one. + # + # `job.workflow_repository` and `job.workflow_sha` are the repo and commit + # THIS reusable workflow was loaded from — GitHub documents exactly this + # pair for a reusable workflow checking out its own source. So the resolver + # code and configs.yml always come from one commit, and + # `uses: .../data-deploy.yml@some-branch` reads that branch's branding with + # no caller-side input to set and remember to remove. + # + # Not the `github` context: a called workflow inherits the CALLER's, so + # `github.workflow_ref`/`workflow_sha` name the data repo's own deploy.yml, + # and `github.job_workflow_sha` is not a property at all — it is an OIDC + # token claim. An unknown property evaluates to null rather than failing, + # which would have silently resolved `ref:` to support's default branch. + - name: Checkout relaton/support (branding source of truth) + uses: actions/checkout@v4 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ inputs.support-ref || job.workflow_sha }} + path: .relaton-support + # Nothing pushes from here; no reason to leave a credential in the + # workspace's .git/config. + persist-credentials: false + # Cone mode (the default) is correct — these are directories, not + # globs. Do not add sparse-checkout-cone-mode. + sparse-checkout: | + bin + lib + data-index + - uses: ruby/setup-ruby@v1 with: ruby-version: "3.4" + # Ahead of the source-specific steps on purpose: the git-source build + # exports BUNDLE_GEMFILE to $GITHUB_ENV for every later step, and a + # branding failure should surface before a ~10-minute frontend compile, + # not after it. + - name: Resolve branding + id: branding + # The inputs travel by env for the same reason the build steps below do, + # and so nothing caller-supplied is interpolated into this shell. + env: + TITLE: ${{ inputs.title }} + FAVICON: ${{ inputs.favicon }} + DESCRIPTION: ${{ inputs.description }} + run: | + ruby .relaton-support/bin/index-branding "$GITHUB_REPOSITORY" \ + --title "$TITLE" \ + --favicon "$FAVICON" \ + --description "$DESCRIPTION" \ + >> "$GITHUB_OUTPUT" + # ---- gem source (default): install the released gem, Ruby only ---------- - name: Install relaton-cli (released gem) if: inputs.source == 'gem' @@ -176,31 +246,24 @@ jobs: echo "BUNDLE_GEMFILE=$RUNNER_TEMP/Gemfile.index" >> "$GITHUB_ENV" BUNDLE_GEMFILE="$RUNNER_TEMP/Gemfile.index" bundle install - - name: Derive title - id: meta - run: | - title="${{ inputs.title }}" - if [ -z "$title" ]; then - flavor="${GITHUB_REPOSITORY##*/}" # relaton-data-bipm - flavor="${flavor#relaton-data-}" # bipm - title="$(echo "$flavor" | tr '[:lower:]' '[:upper:]') Index" - fi - echo "title=$title" >> "$GITHUB_OUTPUT" - - name: Build index (gem source) if: inputs.source == 'gem' - # The branding values are free-form prose, and `${{ }}` is substituted as - # raw text before bash parses the line — an inlined description reading - # `IEC "TC 1" registry` would word-split into stray argv entries. Passing - # them by env hands the runner's verbatim value to the shell. + # All three branding values are free-form prose, and `${{ }}` is + # substituted as raw text before bash parses the line — an inlined + # description reading `IEC "TC 1" registry` would word-split into stray + # argv entries, and one containing $(...) would execute. Passing them by + # env hands the runner's verbatim value to the shell. `title` belongs + # here too now that it originates in relaton/support rather than in the + # calling repo's own file. env: - FAVICON: ${{ inputs.favicon }} - DESCRIPTION: ${{ inputs.description }} + TITLE: ${{ steps.branding.outputs.title }} + FAVICON: ${{ steps.branding.outputs.favicon }} + DESCRIPTION: ${{ steps.branding.outputs.description }} run: | relaton index "${{ inputs.data-dir }}" \ --output _site \ --mode "${{ inputs.mode }}" \ - --title "${{ steps.meta.outputs.title }}" \ + --title "$TITLE" \ --favicon "$FAVICON" \ --description "$DESCRIPTION" \ --base-url "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_REF_NAME}" @@ -208,15 +271,17 @@ jobs: - name: Build index (git source) if: inputs.source == 'git' # BUNDLE_GEMFILE is exported to GITHUB_ENV in the build step above. - # FAVICON/DESCRIPTION travel by env for the same reason as the gem step. + # TITLE/FAVICON/DESCRIPTION travel by env for the same reason as the gem + # step. env: - FAVICON: ${{ inputs.favicon }} - DESCRIPTION: ${{ inputs.description }} + TITLE: ${{ steps.branding.outputs.title }} + FAVICON: ${{ steps.branding.outputs.favicon }} + DESCRIPTION: ${{ steps.branding.outputs.description }} run: | bundle exec relaton index "${{ github.workspace }}/${{ inputs.data-dir }}" \ --output "${{ github.workspace }}/_site" \ --mode "${{ inputs.mode }}" \ - --title "${{ steps.meta.outputs.title }}" \ + --title "$TITLE" \ --favicon "$FAVICON" \ --description "$DESCRIPTION" \ --base-url "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_REF_NAME}" diff --git a/bin/index-branding b/bin/index-branding new file mode 100755 index 0000000..26bc1d6 --- /dev/null +++ b/bin/index-branding @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Resolve a relaton-data-* repo's Pages branding from data-index/configs.yml and +# print it in `$GITHUB_OUTPUT` format. Run by the "Resolve branding" step of +# .github/workflows/data-deploy.yml, which appends the result: +# +# ruby .relaton-support/bin/index-branding "$GITHUB_REPOSITORY" \ +# --title "$TITLE" --favicon "$FAVICON" --description "$DESCRIPTION" \ +# >> "$GITHUB_OUTPUT" +# +# Branding is resolved centrally because cimas.yml maps +# `.github/workflows/deploy.yml` as a whole-file copy for 29 repos: a `with:` +# block carrying it is wiped on the next `cimas sync`, and the site silently +# loses its favicon and description. See data-index/README.adoc. +# +# The three flags are passed unconditionally by the workflow, so a blank value +# means "the caller set no input" and falls through to configs.yml. +# +# Usage: +# bin/index-branding relaton/relaton-data-iana +# bin/index-branding relaton-data-iana --favicon https://example.org/f.ico +# bin/index-branding iana --config path/to/configs.yml + +require "optparse" + +require_relative "../lib/data_index_config" +require_relative "../lib/github_output" + +options = { config: DataIndexConfig::DEFAULT_CONFIG_PATH } +parser = OptionParser.new do |o| + o.banner = "Usage: bin/index-branding OWNER/REPO [options]" + o.on("--title TITLE", "Explicit title, overriding configs.yml") { |v| options[:title] = v } + o.on("--favicon URL", "Explicit favicon, overriding configs.yml") { |v| options[:favicon] = v } + o.on("--description TEXT", "Explicit description, overriding configs.yml") do |v| + options[:description] = v + end + o.on("--config PATH", "Path to configs.yml") { |v| options[:config] = v } + o.on("-h", "--help", "Show this help") do + puts o + exit + end +end +parser.parse!(ARGV) + +if ARGV.size != 1 + warn parser + exit 1 +end + +repo = ARGV.fetch(0) +branding = DataIndexConfig.load(options[:config]).branding( + repo, + title: options[:title], favicon: options[:favicon], description: options[:description] +) + +# Diagnostics on stderr so they reach the Actions log without landing in +# $GITHUB_OUTPUT, which stdout is redirected to. +warn "#{repo}: #{branding.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}" + +# Rendered whole and written once: a partial write would leave a dangling +# heredoc opener and corrupt every later step's outputs. +print GithubOutput.render(branding) diff --git a/cimas-config/gh-actions/data/deploy.yml b/cimas-config/gh-actions/data/deploy.yml index 8b81c34..f43ae3e 100644 --- a/cimas-config/gh-actions/data/deploy.yml +++ b/cimas-config/gh-actions/data/deploy.yml @@ -49,4 +49,18 @@ jobs: # both centrally — it skips a `workflow_run` whose crawler failed, and # serializes Pages deployments per repo — so every caller gets them without # a copy of its own to drift. + # + # And no `with:` block, ever. Cimas copies this file byte-for-byte into 29 + # relaton-data-* repos, so an input set here is either wrong for the other 28 + # or, once a repo hand-edits it, silently reverted by the next `cimas sync` — + # the page just loses its favicon and description, with nothing red in CI. + # Per-repo branding (title/favicon/description) lives in relaton/support's + # `data-index/configs.yml`, which data-deploy.yml reads at build time. + # Pinned by spec/data_deploy_caller_template_spec.rb and, for every synced + # template, by spec/cimas_data_pages_spec.rb. + # + # The known exception is the temporary `source: git` pin several repos carry + # while relaton-cli ships no `index` command. That one is deliberately NOT + # centralised: losing it fails loudly with `Could not find command "index"`, + # so a sync that wipes it cannot go unnoticed. uses: relaton/support/.github/workflows/data-deploy.yml@main diff --git a/data-index/README.adoc b/data-index/README.adoc index b1aa87a..7019176 100644 --- a/data-index/README.adoc +++ b/data-index/README.adoc @@ -1,13 +1,21 @@ = relaton-data-* GitHub Pages index configs -Source of truth for the per-repo Jekyll Pages `_config.yml` that gives each -`relaton-data-*` repo an index site (relaton/relaton#48). +Source of truth for the per-repo settings behind each `relaton-data-*` repo's +GitHub Pages index site (relaton/relaton#48). The site is built by the shared reusable workflow -`relaton/support/.github/workflows/data-deploy.yml`, which checks out the theme -https://github.com/relaton/jekyll-theme-relaton-data-index[jekyll-theme-relaton-data-index], -merges the theme's base `_config.yml` with the repo's own (repo values win), runs -`jekyll build`, and deploys to Pages. +`relaton/support/.github/workflows/data-deploy.yml`, which runs `relaton index` +over the repo's `data/` folder and deploys the result to Pages. It reads this +directory's `configs.yml` for the page's `title`, `favicon` and `description`. + +[IMPORTANT] +==== +`relaton index` replaced a Jekyll build in support#58 (relaton/relaton#83). The +`_config.yml` material below — `../bin/gen-data-index-config`, `generated/`, the +theme merge, the pubid rule — describes that superseded path and is kept because +`configs.yml` still carries the fields it used. Only `display`, `favicon` and +`description` reach the live build today; retiring the rest is follow-up work. +==== == Files @@ -15,7 +23,16 @@ merges the theme's base `_config.yml` with the repo's own (repo values win), run (published INDEXFILE the current relaton consumer reads), `branch` (the repo's real default branch — drives `baseurl`), and `pubid_class` (blank for flat indexes). Edit this to change any repo's config. ++ +It has two consumers. `display`, `favicon` and `description` are also the +branding the live `relaton index` build renders (see the note below), so an edit +to those fields changes a repo's published page on its next deploy — no data-repo +change involved. `../bin/gen-data-index-config`:: Renders a repo's `_config.yml` from `configs.yml`. +`../bin/index-branding`:: Resolves one repo's `title`/`favicon`/`description` from + `configs.yml` and prints them in `$GITHUB_OUTPUT` form. Run by the "Resolve + branding" step of `data-deploy.yml`; useful by hand to preview what a repo will + publish (`bin/index-branding relaton/relaton-data-iana`). `generated/`:: The committed snapshot of all 28 rendered `_config.yml` files (one per target repo) — the exact bytes to commit into each data repo. Regenerate with `../bin/gen-data-index-config --out data-index/generated`; kept honest by @@ -57,20 +74,41 @@ share a queue — GitHub keeps just one *pending* run per group and cancels it w the next arrives, so a tag push or PR build sharing the queue could otherwise drop a run carrying fresh data. -[CAUTION] +[NOTE] ==== -Cimas replaces `deploy.yml` wholesale in all 29 mapped repos. Four of them carry -a hand-added `with:` block that nothing here regenerates — `configs.yml` renders -`_config.yml` only: - -* `relaton-data-bipm`, `relaton-data-3gpp` — `source: git` -* `relaton-data-w3c`, `relaton-data-iana` — `source: git` plus `favicon:` and - `description:` - -Syncing over them drops those inputs back to `source: gem`, which fails with -`Could not find command "index"` until relaton-cli ships `index`, and loses the -branding. Re-apply the `with:` blocks after a sync, or hold those four back and -edit them by hand. +Cimas replaces `deploy.yml` wholesale in all 29 mapped repos, so nothing per-repo +can live in a caller's `with:` block and survive. + +Branding no longer tries to. `title`, `favicon` and `description` come from +`configs.yml` in this directory: the shared `data-deploy.yml` sparse-checks-out +`relaton/support` at its own commit (`job.workflow_repository` + +`job.workflow_sha` — the `job` context, not `github`, which in a called workflow +describes the caller) and runs +`bin/index-branding` to resolve them per flavor. The caller template therefore +carries no `with:` block at all and is safe to sync — pinned by +`spec/data_deploy_caller_template_spec.rb`, and generalised to every synced +template by `spec/cimas_data_pages_spec.rb`. + +What a sync *does* still clobber is the temporary `source: git` pin that callers +carry while no released relaton-cli has the `index` command. That one is +deliberately left in the caller: losing it fails loudly with +`Could not find command "index"`, whereas lost branding failed silently. +Re-apply those pins after a sync, or hold those repos back, until relaton-cli +ships `index` and they can all be deleted. + +Some of those callers also still pass branding inputs that are now redundant, +because `configs.yml` resolves the same values — verified equal repo by repo at +the time of writing. Those lines can be dropped independently of the `source` +pin. To re-check the current fleet rather than trusting a list that will drift: + +[source,sh] +---- +for r in $(gh repo list relaton --limit 200 --json name -q '.[].name' | grep '^relaton-data-'); do + gh api "repos/relaton/$r/contents/.github/workflows/deploy.yml" \ + -H 'Accept: application/vnd.github.raw' 2>/dev/null | + grep -q '^ with:' && echo "$r" +done +---- ==== == The pubid rule diff --git a/data-index/configs.yml b/data-index/configs.yml index 2afe7f6..64a662f 100644 --- a/data-index/configs.yml +++ b/data-index/configs.yml @@ -15,11 +15,24 @@ # Relaton::Index.find_or_create in relaton's lib/relaton//data_fetcher.rb. # No leading "::" — the theme plugin resolves it with Object.const_get. # -# Optional per-entry overrides (fall back to the defaults/template when absent) — -# used by the already-live ids/oasis/w3c to keep their own branding: +# Optional per-entry overrides (fall back to the defaults/template when absent): # favicon - override defaults.favicon # description - override the templated "Welcome to the ..." line # pubid_require - override defaults.pubid_require (w3c uses relaton/w3c/pubid) +# +# Favicon convention: prefer the SDO's own icon where there is a stable URL for +# one; leave `favicon` unset to inherit defaults.favicon (relaton.org). Adding +# one is a one-line edit here — no data-repo or workflow change. +# +# `display`, `favicon` and `description` are ALSO what the shared +# .github/workflows/data-deploy.yml passes to `relaton index` — as +# `--title " Index"`, `--favicon` and `--description`, via +# DataIndexConfig#branding and bin/index-branding. So this file, not each repo's +# deploy.yml `with:` block, is where a data repo's Pages branding lives. (There +# is no per-entry `title` key: the title always derives from `display`.) +# It has to be: cimas.yml maps `.github/workflows/deploy.yml` as a whole-file +# copy for 29 repos, so a `with:` block is wiped on the next `cimas sync` and the +# page silently loses its favicon and description. defaults: paginate: 100 @@ -39,7 +52,20 @@ repos: - { repo: ecma, display: ECMA, source: index-v1.yaml, branch: v2, pubid_class: } - { repo: etsi, display: ETSI, source: index-v2.yaml, branch: v2, pubid_class: Pubid::Etsi::Identifier } - { repo: gost, display: GOST, source: index-v2.yaml, branch: main, pubid_class: Pubid::Gost::Identifier } - - { repo: iana, display: IANA, source: index-v1.yaml, branch: v2, pubid_class: } + # Taken from the `with:` block of the relaton-data-iana caller prepared + # alongside this change (not yet pushed, so these values are not live + # anywhere). Recorded here rather than there because that block is exactly what + # a `cimas sync` would drop without trace. First flavor to follow the + # SDO's-own-icon convention beyond the three already-live repos. + - repo: iana + display: IANA + source: index-v1.yaml + branch: v2 + pubid_class: + favicon: 'https://www.iana.org/favicon.ico' + description: >- + Protocol parameter registries represent the authoritative record of many of + the codes and numbers contained in a variety of Internet protocols. - { repo: iec, display: IEC, source: index-v2.yaml, branch: v2, pubid_class: Pubid::Iec::Identifier } - { repo: ieee, display: IEEE, source: index-v2.yaml, branch: v2, pubid_class: Pubid::Ieee::Identifier } - { repo: iho, display: IHO, source: index-v3.yaml, branch: v2, pubid_class: Pubid::Iho::Identifier } diff --git a/data-index/generated/iana_config.yml b/data-index/generated/iana_config.yml index b08c90b..31b029b 100644 --- a/data-index/generated/iana_config.yml +++ b/data-index/generated/iana_config.yml @@ -1,9 +1,9 @@ title: IANA Index description: >- - Welcome to the IANA standards index site! + Protocol parameter registries represent the authoritative record of many of the codes and numbers contained in a variety of Internet protocols. paginate: 100 jekyll-index: - favicon: 'https://www.relaton.org/favicon.ico' + favicon: 'https://www.iana.org/favicon.ico' source: 'index-v1.yaml' baseurl: 'https://raw.githubusercontent.com/relaton/relaton-data-iana/v2/' add_type_to_reference: true diff --git a/lib/data_index_config.rb b/lib/data_index_config.rb index e3bf57c..bb2324d 100644 --- a/lib/data_index_config.rb +++ b/lib/data_index_config.rb @@ -22,6 +22,14 @@ def self.load(path = DEFAULT_CONFIG_PATH) new(data.fetch("defaults"), data.fetch("repos")) end + # The configs.yml key for a repo named any of the ways a caller might have it: + # "relaton/relaton-data-itu-r" ($GITHUB_REPOSITORY), "relaton-data-itu-r", or a + # bare "itu-r". The owner is dropped rather than checked so a fork's PR build + # resolves the same branding as the upstream repo. + def self.flavor(repo) + repo.to_s.split("/").last.to_s.sub(/\Arelaton-data-/, "") + end + def initialize(defaults, repos) @defaults = defaults @repos = repos @@ -29,8 +37,7 @@ def initialize(defaults, repos) # Look up a single repo entry by its `repo` key. def entry(repo) - repos.find { |e| e["repo"] == repo } or - raise ArgumentError, "unknown repo: #{repo.inspect}" + find_entry(repo) or raise ArgumentError, "unknown repo: #{repo.inspect}" end # Render the `_config.yml` text for a repo name. @@ -38,6 +45,31 @@ def render_repo(repo) render(entry(repo)) end + # The branding `relaton index` renders into the Pages site — title, favicon and + # `` — resolved centrally rather than passed by each + # caller. cimas.yml maps `.github/workflows/deploy.yml` as a whole-file copy for + # 29 repos, so a `with:` block carrying these values is wiped on the next + # `cimas sync` and the site silently loses them. + # + # Precedence: an explicit non-blank argument (a caller's workflow input) beats + # this repo's configs.yml entry, which beats the shared default. + # + # Deliberately never raises, unlike #entry: Cimas syncs deploy.yml into + # relaton-data-ietf, which publishes no document index and so has no configs.yml + # row. An unknown repo falls back to what the workflow's own shell derivation + # produced before this method existed — " Index" and no branding. + # + # => { "title" => String, "favicon" => String, "description" => String } + def branding(repo, title: nil, favicon: nil, description: nil) + found = find_entry(self.class.flavor(repo)) + + { + "title" => present(title) || (found ? entry_title(found) : derived_title(repo)), + "favicon" => present(favicon) || (found ? entry_favicon(found) : ""), + "description" => present(description) || (found ? entry_description(found) : ""), + } + end + # Render the `_config.yml` text for a raw entry hash (merged with defaults). # # `favicon`, `description`, and `pubid_require` accept an optional per-entry @@ -45,15 +77,18 @@ def render_repo(repo) # branding + w3c's non-default `relaton/w3c/pubid` require); absent, they fall # back to the shared default / templated value. def render(entry) - display = entry.fetch("display") - lines = [ - "title: #{display} Index", + # Shared with #branding so a repo's Pages title cannot drift from the one + # its generated config claims. + "title: #{entry_title(entry)}", "description: >-", - " #{description(entry)}", + # Every line indented, not just the first: an unindented continuation line + # would terminate the `>-` block and make the rendered _config.yml invalid + # YAML. Single-line values (all of them today) are unaffected. + entry_description(entry).to_s.lines.map { |l| " #{l.chomp}" }.join("\n"), "paginate: #{defaults.fetch('paginate')}", "jekyll-index:", - " favicon: #{sq(favicon(entry))}", + " favicon: #{sq(entry_favicon(entry))}", " source: #{sq(entry.fetch('source'))}", " baseurl: #{sq(baseurl(entry))}", " add_type_to_reference: true", @@ -85,23 +120,40 @@ def raw_index_url(repo) private + # Nil-returning lookup; #entry raises on top of it. + def find_entry(repo) + repos.find { |e| e["repo"] == repo } + end + # The raw.githubusercontent baseurl for an entry (repo + real default branch). def baseurl(entry) format(defaults.fetch("baseurl_template"), repo: entry.fetch("repo"), branch: entry.fetch("branch")) end + # The `entry_*` prefix is deliberate: #branding takes `favicon:`/`description:` + # keyword arguments, and bare `favicon` there would read as the parameter. + def entry_title(entry) + "#{entry.fetch('display')} Index" + end + # Per-entry override or the shared default favicon. - def favicon(entry) + def entry_favicon(entry) override(entry, "favicon") || defaults.fetch("favicon") end # Per-entry override or the templated "Welcome to the ..." line. - def description(entry) + def entry_description(entry) override(entry, "description") || format(defaults.fetch("description_template"), display: entry.fetch("display")) end + # What the workflow's retired shell step produced for a repo configs.yml does + # not cover: the slug, upcased. Keeps relaton-data-ietf building unchanged. + def derived_title(repo) + "#{self.class.flavor(repo).upcase} Index" + end + # Per-entry override or the shared default pubid require (`pubid`). def pubid_require(entry) override(entry, "pubid_require") || defaults.fetch("pubid_require") @@ -109,7 +161,13 @@ def pubid_require(entry) # Read a per-entry string override, treating nil/blank as "not set". def override(entry, key) - value = entry[key] + present(entry[key]) + end + + # nil/blank -> nil. Blank must mean "not set" for #branding's arguments too: + # the workflow passes --title/--favicon/--description unconditionally, so an + # unset caller input arrives as "" and has to fall through to configs.yml. + def present(value) return nil if value.nil? || value.to_s.strip.empty? value diff --git a/lib/github_output.rb b/lib/github_output.rb new file mode 100644 index 0000000..44085d0 --- /dev/null +++ b/lib/github_output.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "securerandom" + +# Encodes step outputs for GitHub Actions' `$GITHUB_OUTPUT` file. +# +# Only the heredoc form is used, never `name=value`. `name=value` terminates at +# the first newline, so any value that can contain one — and the branding this +# renders is free-form prose out of data-index/configs.yml — would spill its +# remaining lines into the file as further `key=value` assignments. That is an +# injection: a description containing a line `favicon=javascript:...` would +# define an unrelated step output. +# +# The heredoc closes that, but only while the delimiter is unguessable, hence a +# fresh random one per value rather than a fixed marker. +module GithubOutput + # Long enough that a collision with a line of real prose is not a thing that + # happens; the guard in .block covers the caller-supplied case anyway. + DELIMITER_PREFIX = "ghadelim_" + + # Render a name => value hash as one string. Callers write it in a single + # operation: a partial write would leave a dangling heredoc opener, which + # corrupts the outputs of every *later* step in the job, not just this one. + def self.render(pairs) + pairs.map { |name, value| block(name, value) }.join + end + + # One `name<. + def self.normalize(value) + value.to_s.gsub(/\r\n?/, "\n") + end + private_class_method :normalize +end diff --git a/spec/cimas_data_pages_spec.rb b/spec/cimas_data_pages_spec.rb index 8f40841..65bfd5b 100644 --- a/spec/cimas_data_pages_spec.rb +++ b/spec/cimas_data_pages_spec.rb @@ -76,4 +76,72 @@ "these data repos get deploy.yml but have no configs.yml row: " \ "#{uncovered.join(', ')}" end + + # The failure mode that made this whole change necessary is entirely silent: a + # per-repo value in a Cimas-synced template is copied verbatim into 29 repos, + # and a per-repo value hand-added to a *synced destination* is reverted on the + # next sync with nothing red in CI. These two guards make either a test + # failure, for every template cimas.yml syncs — not just deploy.yml. + describe "no Cimas-synced template can carry a per-repo value" do + # Every source template any repositories: entry maps, YAML ones only. + templates = repositories.values.compact + .flat_map { |r| (r["files"] || {}).values } + .uniq.select { |src| %w[.yml .yaml].include?(File.extname(src)) } + .sort + + # Walk a parsed document and yield every String it contains. Deliberately + # parsed rather than raw text: gh-actions/data/deploy.yml legitimately names + # relaton-data-iana in a comment (as evidence for its cron window), and a + # File.read scan would flag it. + scalars = lambda do |node, &blk| + case node + when Hash then node.each { |k, v| scalars.call(k, &blk); scalars.call(v, &blk) } + when Array then node.each { |v| scalars.call(v, &blk) } + when String then blk.call(node) + end + end + + # The repo slug, plus the rendered page title. Deliberately NOT the bare + # `display` value: those are short acronyms (RFC, ISO, IEC, CIE, XSF), and + # substring-matching them would fail an unrelated template the day one names + # `ISO 8601` or `RFC 3339` in a step. ` Index` is the shape a leaked + # per-repo title actually takes. + per_repo_tokens = configs.repos.flat_map do |e| + ["relaton-data-#{e.fetch('repo')}", "#{e.fetch('display')} Index"] + end + + templates.each do |src| + it "#{src} names no single repo" do + doc = YAML.safe_load_file(File.join(repo_root, "cimas-config", src)) + offenders = [] + scalars.call(doc) do |string| + per_repo_tokens.each { |t| offenders << [t, string] if string.include?(t) } + end + + expect(offenders).to be_empty, + "#{src} is copied byte-for-byte into every mapped repo, but " \ + "contains per-repo value(s): #{offenders.inspect}. Per-repo " \ + "values belong in data-index/configs.yml." + end + + it "#{src} passes only run-time expressions as workflow inputs" do + # A literal `with:` value is per-repo configuration. An expression + # (crawler.yml's `${{ github.event.inputs.args }}`, release.yml's + # `${{ github.event.inputs.next_version }}`) evaluates per run and is + # therefore repo-agnostic, so it survives a sync intact. + # `|| {}` twice: a comment-only or empty template parses to nil. + doc = YAML.safe_load_file(File.join(repo_root, "cimas-config", src)) || {} + literals = (doc["jobs"] || {}).flat_map do |name, job| + next [] unless job.is_a?(Hash) && job.key?("uses") + + (job["with"] || {}).reject { |_, v| v.to_s.match?(/\A\$\{\{.*\}\}\z/) } + .map { |k, v| "#{name}.with.#{k}=#{v.inspect}" } + end + + expect(literals).to be_empty, + "#{src} passes literal input(s) #{literals.join(', ')} that a " \ + "`cimas sync` would copy into every mapped repo" + end + end + end end diff --git a/spec/data_deploy_caller_template_spec.rb b/spec/data_deploy_caller_template_spec.rb index 8ae763c..fdbd965 100644 --- a/spec/data_deploy_caller_template_spec.rb +++ b/spec/data_deploy_caller_template_spec.rb @@ -83,5 +83,16 @@ # Also central, so a repo whose deploy.yml drifts still gets it. expect(deploy_job).not_to have_key("if") end + + it "passes no inputs at all" do + # THE reason branding moved into data-index/configs.yml. cimas.yml maps + # this file into 29 repos as a whole-file copy, so anything in a `with:` + # here is either wrong for the other 28 or — once a repo hand-edits it — + # silently reverted by the next `cimas sync`. Branding failed silently (the + # page just loses its favicon); `source: git` fails loudly with + # `Could not find command "index"`, which is why only branding moved and + # `source` is still a caller input. + expect(deploy_job).not_to have_key("with") + end end end diff --git a/spec/data_deploy_workflow_spec.rb b/spec/data_deploy_workflow_spec.rb index bf2827b..929a969 100644 --- a/spec/data_deploy_workflow_spec.rb +++ b/spec/data_deploy_workflow_spec.rb @@ -1,7 +1,9 @@ # Guards the reusable Pages workflow's caller contract: it builds the index with -# `relaton index` (support#58, which replaced the Jekyll build) and forwards the -# optional per-flavor branding a data repo used to carry in its `_config.yml` — -# title, favicon and description (relaton#96 added the latter two CLI flags). +# `relaton index` (support#58, which replaced the Jekyll build) and resolves the +# per-flavor branding a data repo used to carry in its `_config.yml` — title, +# favicon and description (relaton#96 added the latter two CLI flags) — from +# relaton/support's data-index/configs.yml rather than from a caller `with:` +# block, which `cimas sync` overwrites without trace. # # The two "Build index" steps (gem source / git source) are the fragile part: # they are near-duplicate command lines, so a flag added to one and forgotten in @@ -12,7 +14,15 @@ # Psych reads the unquoted `on:` key as YAML 1.1 boolean true. inputs = workflow.fetch(true).fetch("workflow_call").fetch("inputs") build_job = workflow.fetch("jobs").fetch("build_index_page") - index_steps = build_job.fetch("steps").select { |s| s["run"]&.match?(/\brelaton index\b/) } + steps = build_job.fetch("steps") + # Anchored to the command, not to a bare mention: the resolve step's `run` and + # comments must never be able to join this set, or the flag assertions below + # would be checked against a step that has no business carrying them. + index_steps = steps.select { |s| s["run"]&.match?(/^\s*(bundle exec )?relaton index /) } + step_index = ->(step) { steps.index(step) or raise "step not found" } + root_checkout = steps.find { |s| s["uses"]&.start_with?("actions/checkout") && s["with"].nil? } + support_checkout = steps.find { |s| s["name"].to_s.include?("relaton/support") } + branding_step = steps.find { |s| s["id"] == "branding" } # "Publish only from the repo's real default branch" — the one condition that # decides whether a run reaches actions/deploy-pages, reused by the # concurrency group below so the two cannot drift apart. @@ -22,7 +32,7 @@ expect(inputs.values).to all(include("required" => false)) end - %w[favicon description].each do |input| + %w[title favicon description support-ref].each do |input| describe "the #{input} input" do it "is declared, so callers can pass it" do # A reusable workflow rejects an undeclared input at parse time @@ -32,9 +42,10 @@ end it "is an optional string defaulting to the empty string" do - # The empty default is load-bearing: it lets both build steps pass the - # flag unconditionally. relaton's generator runs `presence(...)` on both - # values, so `--favicon ""` is indistinguishable from not passing it. + # The empty default is load-bearing in both directions: the resolve step + # passes every flag unconditionally and reads blank as "caller set + # nothing, use configs.yml", and relaton's generator runs `presence(...)` + # so `--favicon ""` is indistinguishable from not passing it. expect(inputs.fetch(input)) .to include("required" => false, "default" => "", "type" => "string") end @@ -46,17 +57,122 @@ .to contain_exactly("inputs.source == 'gem'", "inputs.source == 'git'") end - %w[favicon description].each do |input| + %w[title favicon description].each do |input| it "hands #{input} to the shell through the environment, not `${{ }}`" do # GitHub substitutes `${{ }}` as raw text before bash parses the line, so # an inlined value is shell source: a description reading # `IEC "TC 1" registry` would word-split into stray argv entries, and one # containing $(...) would execute. Via env the runner passes it verbatim. + # `title` is in this set too: it now originates in relaton/support's + # configs.yml rather than in the calling repo's own deploy.yml. index_steps.each do |step| - expect(step.fetch("env")).to include(input.upcase => "${{ inputs.#{input} }}") - expect(step.fetch("run")).not_to include("inputs.#{input}") + expect(step.fetch("env")) + .to include(input.upcase => "${{ steps.branding.outputs.#{input} }}") + expect(step.fetch("run")).not_to include("steps.branding") end end + + it "builds the index from the resolved #{input}, never straight from the input" do + # The inputs are overrides that pass THROUGH the resolver, which applies + # the configs.yml fallback. Reading inputs. directly here would restore + # the old behaviour where an unset input meant "no branding". + index_steps.each { |step| expect(step.to_s).not_to include("inputs.#{input}") } + end + end + + describe "resolving branding from relaton/support" do + it "checks out relaton/support, the branding source of truth" do + # Branding cannot live in the caller: cimas.yml maps + # .github/workflows/deploy.yml as a whole-file copy for 29 repos, so a + # `with:` block is wiped on the next sync and the page silently loses its + # favicon and description. + expect(support_checkout).not_to be_nil + expect(support_checkout.fetch("uses")).to start_with("actions/checkout@") + expect(support_checkout.fetch("with").fetch("path")).to eq(".relaton-support") + end + + it "resolves the support repo from the `job` context, not the `github` one" do + # A called workflow inherits the CALLER's `github` context, so + # `github.repository` is the data repo — that would check the data repo out + # over itself. `job.workflow_repository` is the repo this file came from. + with = support_checkout.fetch("with") + expect(with.fetch("repository")).to eq("${{ job.workflow_repository }}") + expect(with.fetch("repository")).not_to include("github.") + end + + it "reads branding from the commit this workflow was itself loaded from" do + # `job.workflow_sha` is the reusable workflow file's own commit, so + # resolver code and configs.yml can never come from different commits, and + # `@some-branch` tests that branch's branding for free. + # + # Asserted against the `job` context specifically because the plausible + # wrong spellings both fail silently rather than loudly: `github.*` names + # the caller's workflow, and `github.job_workflow_sha` is an OIDC claim + # rather than a context property, so it evaluates to null and `ref:` falls + # back to support's default branch with the build still green. + ref = support_checkout.fetch("with").fetch("ref") + expect(ref).to eq("${{ inputs.support-ref || job.workflow_sha }}") + expect(ref).not_to include("github.") + end + + it "checks out support only after the data repo" do + # A checkout into the workspace root clears the directory first, which + # would delete a subdirectory checked out before it. + expect(step_index.call(support_checkout)).to be > step_index.call(root_checkout) + end + + it "fetches only the paths the resolver needs, in cone mode" do + # Cone mode (the default) is correct for directory paths; setting + # sparse-checkout-cone-mode: false would silently reinterpret these as + # file globs and fetch nothing. + with = support_checkout.fetch("with") + expect(with.fetch("sparse-checkout").split).to contain_exactly("bin", "lib", "data-index") + expect(with).not_to have_key("sparse-checkout-cone-mode") + end + + it "leaves no credential behind in the workspace" do + expect(support_checkout.fetch("with").fetch("persist-credentials")).to be(false) + end + + it "resolves branding before the expensive source-specific build" do + # The git-source build exports BUNDLE_GEMFILE to $GITHUB_ENV for every + # later step, and a broken configs.yml should not cost a ~10-minute + # frontend compile before it surfaces. + expect(branding_step).not_to be_nil + index_steps.each do |step| + expect(step_index.call(branding_step)).to be < step_index.call(step) + end + end + + it "runs the resolver out of the support checkout" do + # Ordering matters twice over: the script does not exist until the support + # checkout has run, and the checkout must itself follow the root one. + expect(branding_step.fetch("run")).to include(".relaton-support/bin/index-branding") + expect(step_index.call(branding_step)).to be > step_index.call(support_checkout) + end + + it "appends to $GITHUB_OUTPUT and interpolates nothing into its shell" do + # Same reason as the build steps: the caller's inputs reach the script as + # env vars, not as text substituted into the command line. + run = branding_step.fetch("run") + expect(run).to include(">> \"$GITHUB_OUTPUT\"") + expect(run).not_to include("${{") + expect(branding_step.fetch("env").keys).to contain_exactly("TITLE", "FAVICON", "DESCRIPTION") + end + + it "forwards each caller input to its own resolver flag" do + # Without this, `--title "$FAVICON"` would satisfy a bare "passes --title" + # assertion while silently swapping two of the three values. + run = branding_step.fetch("run") + { "title" => "TITLE", "favicon" => "FAVICON", "description" => "DESCRIPTION" } + .each { |flag, var| expect(run).to include(%(--#{flag} "$#{var}")) } + end + + it "no longer derives a title in the shell" do + # The retired `Derive title` step upcased the repo slug, giving + # "RFCS Index"; configs.yml's `display` is authoritative now. + expect(steps.to_s).not_to include("steps.meta") + end end %w[--title --favicon --description --base-url].each do |flag| diff --git a/spec/data_index_config_spec.rb b/spec/data_index_config_spec.rb index 301115b..9d000fa 100644 --- a/spec/data_index_config_spec.rb +++ b/spec/data_index_config_spec.rb @@ -49,11 +49,17 @@ def jekyll_index(repo) end it "applies the default favicon to repos without an override" do + # The convention is the SDO's own icon where there is a stable URL for one, + # relaton.org otherwise. Listed explicitly so adding an override is a + # deliberate edit here rather than a silent change to a repo's live page. default_favicon = config.defaults.fetch("favicon") - overridden = %w[ids oasis w3c] + overridden = %w[iana ids oasis w3c] config.repos.reject { |e| overridden.include?(e["repo"]) }.each do |e| expect(jekyll_index(e["repo"])["favicon"]).to eq(default_favicon) end + overridden.each do |repo| + expect(jekyll_index(repo)["favicon"]).not_to eq(default_favicon) + end end it "honors a per-repo favicon override (w3c)" do @@ -61,6 +67,16 @@ def jekyll_index(repo) .to eq("https://www.w3.org/assets/logos/w3c/w3c-no-bars.svg") end + it "carries iana's branding" do + # Taken from the relaton-data-iana caller prepared alongside this change + # and not yet pushed, so these values are not live anywhere — this is a new + # editorial choice, not a recovery. Recorded here rather than in that + # caller because a `cimas sync` would drop a `with:` block without trace. + expect(jekyll_index("iana")["favicon"]).to eq("https://www.iana.org/favicon.ico") + expect(YAML.safe_load(config.render_repo("iana"))["description"]) + .to start_with("Protocol parameter registries represent the authoritative record") + end + it "honors a per-repo description override (ids)" do parsed = YAML.safe_load(config.render_repo("ids")) expect(parsed["description"]) diff --git a/spec/github_output_spec.rb b/spec/github_output_spec.rb new file mode 100644 index 0000000..bbf7ad0 --- /dev/null +++ b/spec/github_output_spec.rb @@ -0,0 +1,97 @@ +# Guards the `$GITHUB_OUTPUT` encoding used by bin/index-branding. +# +# This is the boundary where per-repo branding — free-form prose that comes from +# data-index/configs.yml, not from the repo being built — crosses into a GitHub +# Actions step output. The `name=value` form truncates at the first newline, so +# a multi-line description would spill its remaining lines into the file as +# *new* `key=value` pairs and let configs.yml define arbitrary step outputs. +# Only the heredoc form is safe, and it is only safe while the delimiter cannot +# be guessed or collided with. +require "github_output" + +RSpec.describe GithubOutput do + describe ".block" do + it "emits the heredoc form, never `name=value`" do + block = described_class.block("title", "IANA Index") + + expect(block).to match(/\Atitle<<(\S+)\nIANA Index\n\1\n\z/) + end + + it "round-trips a multi-line value intact" do + # The failure this prevents: with `name=value`, "b" and "c" below would be + # parsed as further output assignments rather than as part of the value. + block = described_class.block("description", "a\nb\nc") + delimiter = block[/\Adescription<<(\S+)\n/, 1] + + expect(block).to eq("description<<#{delimiter}\na\nb\nc\n#{delimiter}\n") + end + + it "emits an empty value as an empty line between the delimiters" do + # Load-bearing: an unset favicon must reach the build step as "", which + # relaton-cli treats as absent. A missing block would leave the output + # undefined instead. + block = described_class.block("favicon", "") + delimiter = block[/\Afavicon<<(\S+)\n/, 1] + + expect(block).to eq("favicon<<#{delimiter}\n\n#{delimiter}\n") + end + + it "normalises CRLF and lone CR to LF" do + # A stray \r survives the runner into the env var and then into the + # rendered . + block = described_class.block("description", "a\r\nb\rc") + + expect(block).to include("\na\nb\nc\n") + expect(block).not_to include("\r") + end + + it "picks an unpredictable delimiter on every call" do + # If the delimiter were fixed, a description containing that exact line + # would close the heredoc early and everything after it would be parsed as + # further output assignments — the injection this form exists to stop. + delimiters = Array.new(5) { described_class.block("k", "v")[/\Ak<<(\S+)\n/, 1] } + + expect(delimiters.uniq.size).to eq(5) + expect(delimiters).to all(match(/\A\S{16,}\z/)) + end + + it "raises when a line of the value equals the delimiter" do + # GitHub's parser fails the step when a value *line* is exactly the + # delimiter (containing it is fine). Unreachable with a random delimiter, + # but asserted so the guard cannot be dropped as dead code. + expect { described_class.block("k", "a\nDELIM\nb", delimiter: "DELIM") } + .to raise_error(ArgumentError, /delimiter/) + end + + it "accepts a value that merely contains the delimiter within a line" do + expect { described_class.block("k", "xDELIMx", delimiter: "DELIM") } + .not_to raise_error + end + + it "stringifies a non-string value" do + # A nil reaching $GITHUB_OUTPUT would silently emit an empty block rather + # than fail; make the coercion explicit and total. + expect(described_class.block("k", nil)).to match(/\Ak<<(\S+)\n\n\1\n\z/) + end + end + + describe ".render" do + it "returns every pair as one string, so the file is written in a single call" do + # Atomicity matters: if the script died between two writes, a dangling + # heredoc opener would corrupt the outputs of every *later* step in the + # job, not just this one. + rendered = described_class.render("title" => "IANA Index", "favicon" => "") + + expect(rendered).to be_a(String) + expect(rendered).to start_with("title<<") + expect(rendered).to include("\nfavicon<<") + end + + it "gives each pair its own delimiter" do + rendered = described_class.render("a" => "1", "b" => "2") + delimiters = rendered.scan(/^[ab]<<(\S+)$/).flatten + + expect(delimiters.uniq.size).to eq(2) + end + end +end diff --git a/spec/index_branding_spec.rb b/spec/index_branding_spec.rb new file mode 100644 index 0000000..3c5a242 --- /dev/null +++ b/spec/index_branding_spec.rb @@ -0,0 +1,204 @@ +# Guards the branding the shared data-deploy.yml resolves for `relaton index`. +# +# Branding used to live in each relaton-data-* repo's own deploy.yml `with:` +# block, which cimas.yml maps as a whole-file copy — so a `cimas sync` wiped it +# silently (the page just loses its favicon and description; nothing goes red). +# It now comes from data-index/configs.yml, the file that was already the source +# of truth for these values, and the caller template carries no `with:` at all. +# +# The load-bearing invariants here are the fallbacks: the workflow passes all +# three flags unconditionally, and Cimas syncs deploy.yml into one repo +# (relaton-data-ietf) that configs.yml deliberately does not cover. +require "English" # $CHILD_STATUS +require "shellwords" + +require "data_index_config" + +RSpec.describe "DataIndexConfig branding" do + repo_root = File.expand_path("..", __dir__) + cimas = YAML.safe_load_file(File.join(repo_root, "cimas-config/cimas.yml")) + + let(:config) { DataIndexConfig.load } + + describe ".flavor" do + it "accepts the $GITHUB_REPOSITORY form" do + expect(DataIndexConfig.flavor("relaton/relaton-data-itu-r")).to eq("itu-r") + end + + it "ignores the owner, so a fork's PR build resolves the same branding" do + expect(DataIndexConfig.flavor("someone/relaton-data-iso")).to eq("iso") + end + + it "accepts a bare repo name or a bare flavor" do + expect(DataIndexConfig.flavor("relaton-data-iso")).to eq("iso") + expect(DataIndexConfig.flavor("iso")).to eq("iso") + end + end + + describe "#branding" do + it "titles a repo from its display name, not its slug" do + # The old shell derivation upcased the slug, giving "RFCS Index" and + # "RFCSUBSERIES Index". configs.yml's `display` is what the retired Jekyll + # _config.yml actually set. + expect(config.branding("relaton/relaton-data-rfcs")["title"]).to eq("RFC Index") + end + + { + "rfcs" => "RFC Index", + "rfcsubseries" => "RFC Subseries Index", + "ids" => "Internet-Drafts Index", + "calconnect" => "CalConnect Index", + "adobe" => "Adobe Index", + "iso" => "ISO Index", + "itu-r" => "ITU-R Index", + "3gpp" => "3GPP Index", + }.each do |repo, title| + it "titles #{repo} #{title.inspect}" do + expect(config.branding("relaton/relaton-data-#{repo}")["title"]).to eq(title) + end + end + + it "returns a repo's own favicon and description overrides" do + branding = config.branding("relaton/relaton-data-w3c") + + expect(branding["favicon"]).to eq("https://www.w3.org/assets/logos/w3c/w3c-no-bars.svg") + expect(branding["description"]).to start_with( + "Welcome to the World Wide Web Consortium standards index site!", + ) + end + + it "falls back to the shared favicon and the templated description" do + branding = config.branding("relaton/relaton-data-iso") + + expect(branding["favicon"]).to eq("https://www.relaton.org/favicon.ico") + expect(branding["description"]).to eq("Welcome to the ISO standards index site!") + end + + it "prefers an explicit override over configs.yml" do + branding = config.branding( + "relaton/relaton-data-iso", + title: "Custom", favicon: "custom.ico", description: "Custom desc", + ) + + expect(branding).to eq( + "title" => "Custom", "favicon" => "custom.ico", "description" => "Custom desc", + ) + end + + it "treats an explicit blank override as unset" do + # Load-bearing: the workflow passes --title/--favicon/--description + # unconditionally, so an unset caller input arrives as "". If "" won, every + # repo whose caller omits an input would lose its configs.yml branding — + # exactly the bug this change closes. + blank = config.branding("relaton/relaton-data-iso", title: "", favicon: " ", + description: nil) + + expect(blank).to eq(config.branding("relaton/relaton-data-iso")) + end + + it "falls back to the derived title and no branding for a repo configs.yml omits" do + # relaton-data-ietf gets deploy.yml from Cimas but publishes no document + # index, so it has no configs.yml row (see cimas_data_pages_spec.rb). + # Its result must match what the retired shell derivation produced. + expect(config.branding("relaton/relaton-data-ietf")) + .to eq("title" => "IETF Index", "favicon" => "", "description" => "") + end + + it "does not raise for an unknown repo, unlike #entry" do + # #entry raises ArgumentError by design; branding must not, or a single + # missing configs.yml row would fail every Pages build in that repo. + expect { config.branding("relaton/relaton-data-nope") }.not_to raise_error + expect { config.entry("nope") }.to raise_error(ArgumentError) + end + + it "resolves for every repo Cimas syncs deploy.yml into" do + # The blast radius: data-deploy.yml is pinned @main by all of them, so a + # resolver that raised for one repo would break that repo's deploys the + # moment this lands. + repositories = cimas.fetch("repositories") + synced = cimas.fetch("groups").fetch("data").select do |name| + (repositories.fetch(name, {})["files"] || {}).key?(".github/workflows/deploy.yml") + end + + expect(synced).not_to be_empty + synced.each do |name| + branding = config.branding("relaton/#{name}") + expect(branding.values).to all(be_a(String)), + "#{name} resolved a non-String: #{branding.inspect}" + expect(branding["title"]).not_to be_empty + end + end + + it "agrees with the rendered _config.yml for every repo" do + # configs.yml now has two consumers — this method and #render. They must + # not drift, or a repo's Pages title would differ from the one its + # generated config claims. + config.repos.each do |entry| + repo = entry.fetch("repo") + rendered = YAML.safe_load(config.render_repo(repo)) + branding = config.branding("relaton/relaton-data-#{repo}") + + expect(branding["title"]).to eq(rendered.fetch("title")), "title drift for #{repo}" + expect(branding["description"]).to eq(rendered.fetch("description").strip), + "description drift for #{repo}" + expect(branding["favicon"]).to eq(rendered.fetch("jekyll-index").fetch("favicon")), + "favicon drift for #{repo}" + end + end + end + + # The unit examples above all bypass the executable the workflow actually runs. + # Without these, renaming a method on GithubOutput or DataIndexConfig would + # leave the whole suite green and break the resolve step in all 29 repos. + describe "bin/index-branding" do + bin = File.join(repo_root, "bin/index-branding") + + # Ruby's $GITHUB_OUTPUT format: `name</dev/null` + + expect($CHILD_STATUS).to be_success + expect(parse.call(out)).to eq( + config.branding("relaton/relaton-data-w3c"), + ) + end + + it "resolves a repo configs.yml does not cover" do + out = `#{bin.shellescape} relaton/relaton-data-ietf 2>/dev/null` + + expect($CHILD_STATUS).to be_success + expect(parse.call(out)) + .to eq("title" => "IETF Index", "favicon" => "", "description" => "") + end + + it "applies the flags the workflow always passes, blanks included" do + # The exact call shape of the Resolve branding step: all three flags, with + # an unset caller input arriving as "". + out = `#{bin.shellescape} relaton/relaton-data-iso --title "" --favicon "" \ + --description "Custom" 2>/dev/null` + + parsed = parse.call(out) + expect(parsed.fetch("title")).to eq("ISO Index") + expect(parsed.fetch("favicon")).to eq("https://www.relaton.org/favicon.ico") + expect(parsed.fetch("description")).to eq("Custom") + end + + it "keeps diagnostics off stdout, which is redirected into $GITHUB_OUTPUT" do + out = `#{bin.shellescape} relaton/relaton-data-iso 2>/dev/null` + + # Every line belongs to a heredoc block; nothing stray can define an output. + expect(out.lines.first).to match(/\Atitle<<\S+\n\z/) + expect(out).to end_with("\n") + end + + it "exits non-zero without exactly one repo argument" do + # bash -e fails the step on this, rather than writing a partial block. + expect(`#{bin.shellescape} 2>/dev/null`).to be_empty + expect($CHILD_STATUS).not_to be_success + end + end +end