Skip to content

feat: runner selection moves to the RUNNER_LABELS repository variable - #32

Merged
TzuH-Hsu merged 18 commits into
mainfrom
feat/31-runner-labels
Sep 5, 2026
Merged

feat: runner selection moves to the RUNNER_LABELS repository variable#32
TzuH-Hsu merged 18 commits into
mainfrom
feat/31-runner-labels

Conversation

@TzuH-Hsu

@TzuH-Hsu TzuH-Hsu commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

All four workflows now resolve runs-on from a single RUNNER_LABELS repository variable, defaulting to ubuntu-latest. Unset, behaviour is byte-identical to today.

Stacked PR — base is feat/28-licence-phase (#29). Chain: #25#27#29 → this. It stacks only because ADR numbering and the architecture ledger both touch files #29 changes; the workflow edits themselves are independent.

Related issue

Closes #31

Validation

  • L0 static — markdownlint, yamllint, offline lychee (49 OK, 0 errors), actionlint (pinned 1.7.12), gitleaks, check-skills, check-local-md, check-license-marker — all green
  • L1 unit — make test (placeholder)
  • L2 integration — the failure mode was measured on a live probe, below
  • L3 e2e — this PR's own ci run is the unset-variable path executing for real

I measured the failure mode instead of guessing

This change introduces a third route into the "Expected — waiting for status" trap that ci.yml's header already warns about, and it is the only one an adopter can trigger from repository settings rather than from the file. That deserved evidence.

A throwaway probe workflow was pushed on a scratch branch using a separate variable name (RUNNER_LABELS_PROBE), so the required ci check was never at risk, with the value set to the tempting wrong ubuntu-latest — a bare string instead of a JSON array:

workflow run  : completed, conclusion = failure
run message   : "This run likely failed because of a workflow file issue."
jobs created  : 0
check runs on the head commit : only `ci` — the probe produced NONE

fromJSON fails while the job is being scheduled, so no job and no check run are ever created. Applied to ci.yml — this repository's only required status check — the check is never reported, the PR cannot merge, and the Actions tab never mentions the variable.

Recovery, which is not discoverable from the symptom, is now documented in both ci.yml's header and docs/setup/runners.md:

gh variable delete RUNNER_LABELS

The probe workflow, its variable, its PR and its branch were all removed afterwards.

Risk / rollback

RISK: none skipped. The unset-variable path is exercised by this PR's own ci run.
The malformed-value path was measured directly (above). The remaining untested
case is a genuinely valid non-default runner, which needs runner infrastructure
this repository does not have; the linux x86_64 constraint that bounds it is
documented instead.
Rollback: revert this commit. Adopters with RUNNER_LABELS set silently return to
ubuntu-latest, which is the safe direction.

Checklist

  • Conventional Commit PR title
  • Linked issue using "Closes #N"
  • No secrets, no *.local.md files committed
  • Documentation updated where affected

Why this resolves a contract violation rather than creating one

AGENTS.md:19 says "customize the Makefile, never the workflows", and design-principles.md:11 promises "workflow YAML stays untouched and upgradable". But runs-on is resolved by GitHub when scheduling the job, before any make target exists to be called — it is the one adopter-facing property the Makefile cannot own. So the rule was unsatisfiable for it, and an adopter needing a different runner had no option but to break it.

It also degraded upgrades: upgrading.md:18 recommends bulk cherry-picking .github/workflows/ precisely because it is rarely customized locally. A forked runs-on line makes every template update a manual merge.

AGENTS.md is therefore amended to name the exception, not left to folklore — an undocumented sanctioned exception is how a contract rots — and hygiene rule 1 is amended to match, or the skill would contradict the shipped workflows.

Decisions worth challenging

One variable, not four. The field report proposed CI_RUNNER_LABELS / AUTOMATION_RUNNER_LABELS / MAINTENANCE_RUNNER_LABELS / RELEASE_RUNNER_LABELS. Rejected: design principle 1 ("one good default beats three options"), three of the four jobs are trivial and identical in shape, and every extra name is another thing to typo. Splitting one into several later is trivial; merging four after adopters have set them is not.

fromJSON array vs plain string. ${{ vars.RUNNER_LABELS || 'ubuntu-latest' }} would remove the malformed-JSON footgun entirely — the single largest cost of this change — at the cost of multi-label self-hosted selection (["self-hosted","linux","x64"]), which is the main reason self-hosted users want this. This is the closest call here and ADR-0005 records it as such. If you judge the footgun worse than multi-label support is worth, it is a one-line substitution and nothing else in this PR changes.

Things that were not written down anywhere before

docs/setup/runners.md (123 lines) also captures two constraints that existed but were undocumented:

  • linux x86_64 only. install-ci-tools.sh's three tarball tools ship no other assets and require_supported_platform hard-fails otherwise — so pointing the variable at macOS or arm64 produces a uname message that reads like a broken script rather than a misconfigured variable. That script's own comment claiming the pins suit "the ubuntu-latest hosted runners this repo uses" is corrected too, since it stops being true the moment the runner is a variable.
  • Self-hosted runners install globally on the host. install_npm_global and install_python_tool escape INSTALL_DIR, require_install_consent waves them through whenever CI is set (Actions always sets it), and place_binary uses sudo. Fine on an ephemeral container, an accumulating mess on a long-lived VM.

On runner sizing

The adopter's ubuntu-slim measurements are folded into runners.md as guidance rather than into the hygiene skill as a rule, because the billing argument already lives in ci.yml:16-19 and a second copy is where drift starts. Short version: hosted runners are free on public repos, private billing rounds up to whole minutes so a sub-minute job already bills the floor, and a smaller runner can only push it over. Downsize only when a job materially exceeds a minute and you are past your included minutes.

If you would rather have it as a durable rule 11 in skills/github-actions-hygiene/SKILL.md — there is a fair case, since ci.yml already spends four lines rebutting the same instinct, which is evidence the debate recurs — say so and I will add it with a one-directional pointer from ci.yml.

TzuH-Hsu and others added 4 commits September 2, 2026 12:05
…efaults

Each default contradicted something the repository already documents.

squash_merge_commit_title=PR_TITLE. AGENTS.md, CONTRIBUTING.md, pr-authoring
and branch-and-commit all state the PR title becomes the commit message on
main. GitHub's COMMIT_OR_PR_TITLE default makes that false whenever a PR has
exactly one commit -- visible in this repo's own log, where #7 and #9 carry
the (#N) suffix and #11, #13 and #15 do not.

squash_merge_commit_message=PR_BODY. The COMMIT_MESSAGES default concatenates
every branch commit message into the main commit body, and release-please
deliberately parses that body for further Conventional Commits and
BREAKING-CHANGE footers. Already live here: f98cbf9's body carries
"* chore: trigger CI on release PR", harmless only because chore is
release-please's hidden bucket. A branch commit reading "fix: wip" would have
produced a phantom changelog entry or an unintended bump.

allow_rebase_merge=false. The comment justifying rebase claimed release-please
merges its own PR. It does not -- ADR-0002, the release-please workflow header
and the release-management skill all require a human merge. Nor has rebase
ever been used: the history has no merge commits, all eight merged PRs were
squash-merged, and both release PRs cut their tags that way.

allow_update_branch=true. The ruleset sets
strict_required_status_checks_policy=false, so the "Update branch" button is
not offered at all without it.

Also corrects skills/release-management/SKILL.md, which instructed
`gh pr merge --merge`. allow_merge_commit=false has been set since day one, so
that command has always returned HTTP 405 -- the documented release procedure
was broken. Its parenthetical was wrong too: release-please does not merge.

Accepted cost: with PR_BODY an unmodified PR template lands verbatim in the
main commit message. BLANK was rejected -- it drops Co-authored-by trailers
and discards the RISK/rollback record. PR_BODY reduces the misparse surface
but does not eliminate it; a Conventional-Commit-shaped line in a PR body is
still parsed.

Closes #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bootstrap did nothing about security settings -- grep for secret, scanning,
visibility, vulnerability or dependabot in the script returned nothing. Two
consequences. Converting a repo private to public grants ACCESS to secret
scanning but does not enable it, and push protection in particular must be
switched on explicitly, which is how a public repo ends up without it. And
.github/dependabot.yml asserts Dependabot alerts and security updates are
enabled while nothing verifies that.

The write path is bounded by cost, not by capability: the only settings this
phase ever enables are free by construction.

- Public repo: offers secret scanning and push protection, both free.
- Private or internal: never writes them, under any flag. There they need a
  paid Advanced Security / Secret Protection seat, and a setup script must not
  commit an adopter's account to a per-committer charge. It reports the state
  and emits a MANUAL step.
- Dependabot alerts and automated security fixes: free everywhere, so offered
  regardless of visibility.
- Repository visibility is never changed and never offered. Private to public
  erases stars and watchers and publishes all Actions history -- a one-way
  door, and the phase comment says so, because "detect whether the repo is
  public" invites someone to add that prompt later.

Two states the GitHub UI blurs are kept distinct. Unreadable settings mean the
token lacks admin, not that the setting is off, so security_and_analysis
coming back null degrades to warn plus MANUAL rather than reporting "disabled".
And automated-security-fixes returns {"enabled":..,"paused":..}: enabled but
paused means no fix PR ever opens, so it is reported separately.

Verified read-only before writing the phase: gh api documents the
key[subkey]=value nested syntax, so the security_and_analysis PATCH stays a
normal run_or_dry call visible under --dry-run; /vulnerability-alerts returns
204 enabled and 404 disabled; /automated-security-fixes returns enabled and
paused.

Unlike phases 3/5/8 this phase runs several independent checks, so it
accumulates a result and calls record_phase once at the end -- the pattern
phase_issue_types uses, matching run_phase's one-record-per-exit-path
contract.

Phases 6/7/8 renumber to 7/8/9 across the script and docs. Because every read
runs for real under --dry-run, bootstrap --dry-run now doubles as a zero-risk
security audit.

Closes #26

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
De-templating rewrote README, CHANGELOG and the release-please manifest and
removed docs/template/, but never touched LICENSE -- grep for "license" in
scripts/ returned nothing. So every adopted repository shipped MIT, copyright
the template author. For an open-source adopter that is a wrong copyright
line. For commissioned work it is an irrevocable written grant to the whole
world of the right to use, modify, publish, distribute, sublicense and sell,
made before payment, which removes the leverage the payment clause was built
on. Four private client repos shipped that way.

The obvious fix is itself a defect. Substantial portions of this template ship
verbatim in every adopted repo, and MIT requires its copyright and permission
notice to travel with them; rewriting LICENSE's copyright line deletes the
only copy of that notice from the repository. So attribution is unconditional
-- every answer that writes LICENSE also writes NOTICE, MIT-keep included.
That, not the prompt, is the load-bearing part of this change.

Three answers, no default. A bare Enter re-asks and a closed stdin defers,
because confirm() treats closed stdin as "take the default", which here would
mean silently shipping the template author's MIT -- the original bug with
extra steps. --yes never writes a licence: it defers and files the decision as
the first manual step, rendered above the others with a ! marker via a new
MANUAL_URGENT list. --license mit|proprietary|defer answers non-interactively.

Phase 9 runs before de-template and is not gated by --keep-template-docs:
de-template returns early for three unrelated reasons, any of which would
otherwise swallow the decision. It guards only LICENSE and NOTICE; adding
LICENSE to phase 10's guard would make de-template skip itself on every run
once phase 9 had written. LICENSE is regenerated from a seed rather than
sed-patched, because holder names legitimately contain & and /.

No docs/template/LICENSE.proprietary.example: under --yes the phase writes
nothing and files a manual step, and phase 10 then removes docs/template --
deleting the example in the very run that told the adopter to read it. The
body lives as a script constant, mirroring CHANGELOG_SEED, and in
docs/setup/licensing.md which survives de-templating.

Also adds scripts/check-license-marker.sh, wired into make check. If
TEMPLATE_COPYRIGHT_* drifts from LICENSE the phase stops recognising its own
licence and silently does nothing -- the original defect with no symptom. The
check turns that into a red build, and no-ops in adopted repos.

main is now guarded by a BASH_SOURCE test so the file can be sourced for its
functions without running. Nothing in make verify covers this script; that
guard is the only unit-test surface it has, and all nine licence paths were
exercised through it.

De-template renumbers 9 to 10. ADR-0004 records the decision and seven
rejected alternatives.

Closes #28

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four workflows hardcoded runs-on: ubuntu-latest, so an adopter needing a
different runner had to edit workflow YAML. That contradicted the template
twice: AGENTS.md says customize the Makefile never the workflows, and design
principle 5 promises workflow YAML stays untouched and upgradable. runs-on is
the one adopter-facing property the Makefile physically cannot own, because
GitHub resolves it when scheduling the job, before any make target exists. The
rule was unsatisfiable for it. It also broke upgrading.md's advice to bulk
cherry-pick .github/workflows/, since a forked runs-on line makes every
template update a manual merge.

One variable, not the four originally proposed: design principle 1, three of
the four jobs are trivial and identical in shape, and every extra name is
another thing to typo. A JSON array so multi-label self-hosted selection works.
Unset reproduces today's behaviour exactly.

The cost is real and was measured rather than assumed. A throwaway probe
workflow using a separate variable name, so the required ci check was never at
risk, was pushed with the value set to the tempting wrong `ubuntu-latest` --
a bare string instead of an array. Result: fromJSON fails during scheduling,
the run completes with conclusion=failure and the message "This run likely
failed because of a workflow file issue", ZERO jobs are created, and ZERO check
runs are attached to the commit. Applied to ci.yml that means the only required
check is never reported and the pull request cannot merge, with nothing in the
Actions tab naming the variable. Recovery is `gh variable delete
RUNNER_LABELS`. ci.yml's header now documents exactly that, alongside the two
existing routes into the same trap.

AGENTS.md is amended to name the exception rather than leave it as folklore,
and hygiene rule 1 is amended to match so the skill does not contradict the
shipped workflows.

New docs/setup/runners.md covers the variable, the malformed-value failure and
its recovery, the linux x86_64 bound imposed by install-ci-tools.sh, self-hosted
runner caveats that were not written down anywhere (npm and pip installs escape
INSTALL_DIR and run globally on the host every job), and why downsizing a runner
to save money is usually a pessimisation on a repo shaped like this.

ADR-0005 records the decision, and records the plain-string alternative as the
closest call: it would remove the malformed-JSON footgun entirely at the cost of
multi-label support.

Closes #31

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TzuH-Hsu TzuH-Hsu added this to the v0.3.0 milestone Sep 2, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T13:04:51.190831Z 190d07f New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TzuH-Hsu

TzuH-Hsu commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Live verification of all three variable states

Run against this PR on the real repository, not a scratch one.

State RUNNER_LABELS ci check Evidence
A. Unset (default) pass, 15s run 33611042770
B. Valid array ["ubuntu-latest"] pass, 12s run 33611147238
C. Malformed ubuntu-latest (bare string) never reported measured earlier on an isolated probe

State C was measured on a throwaway branch with a separate variable name (RUNNER_LABELS_PROBE) so the required ci check was never at risk: the run completed with conclusion=failure and the message "This run likely failed because of a workflow file issue", 0 jobs created, 0 check runs attached to the commit. The probe workflow, variable, PR and branch were all removed afterwards.

State B required an empty commit to re-trigger CI with the variable set. I have left it in the branch rather than rewriting history — AGENTS.md:57 puts force-pushing on the list of things an agent must not do without explicit approval. Its message says "(temporary)", which is now inaccurate; squash away or tell me to drop it and I will.

RUNNER_LABELS has been deleted, so the repository is back to the default state.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1859a3ce2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/setup/runners.md

```bash
gh variable set RUNNER_LABELS --body '["ubuntu-latest-4-cores"]'
gh variable set RUNNER_LABELS --body '["self-hosted","linux","x64"]'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block public PRs from self-hosted runners

When this template remains public and an adopter uses this documented setting, .github/workflows/ci.yml still triggers on every pull_request, checks out the PR-controlled tree, and executes its Makefile and scripts via make ci-tools and make ci-pr. An external contributor can therefore run arbitrary code on the self-hosted machine and access its network, filesystem, or ambient credentials; ephemeral cleanup does not protect resources reachable during the job. Add a prominent public-repository prohibition or route fork PRs to GitHub-hosted runners instead.

Useful? React with 👍 / 👎.

TzuH-Hsu and others added 8 commits September 5, 2026 15:11
1. An ampersand in the copyright holder corrupted LICENSE on bash >= 5.2.
   ${var//pat/repl} expands an unescaped & in the REPLACEMENT to the matched
   text when patsub_replacement is on, which is the default from 5.2. A holder
   of "Smith & Jones a/s" rendered as "Smith __HOLDER__ Jones a/s" while the
   phase reported success. My earlier test missed it because macOS /bin/bash is
   3.2, where the option does not exist -- and escaping as \& is itself literal
   on 3.2, so no single expansion is correct on both. Replaced with subst_all,
   a literal prefix/suffix-removal helper that never interprets the
   replacement. Verified identical output on 3.2 and 5.3.

2. write_notice returned early whenever a NOTICE file existed, treating mere
   existence as proof the upstream notice was present. An adopter with their
   own NOTICE for other dependencies would have had LICENSE replaced while the
   only copy of the template's MIT notice was silently dropped -- the exact
   violation this phase exists to prevent. It now looks for the upstream
   copyright line, appends when absent, and keeps existing content.

3. docs/setup/licensing.md's NOTICE block shipped literal __YEAR__ __HOLDER__
   placeholders, so anyone following the documented manual path produced a
   NOTICE with no attribution in it. It now carries the template's real
   copyright line, with a sentence explaining that this one is deliberately not
   the adopter's identity.

Fixing (1) introduced a regression that the doc-vs-output diff then caught:
command substitution strips trailing newlines, so LICENSE lost its final
newline and the NOTICE closing rule ran into "SOFTWARE.". Both fixed, the
constraint is documented on subst_all, and the doc block is now byte-identical
to what the script writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pushed the previous commit before reading the markdownlint result -- my
command chained on git add rather than gating on lint. CI would have caught
it; that is not the same as checking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex review, and it is the sharpest finding in the stack. runners.md
documented how to point RUNNER_LABELS at a self-hosted runner and listed only
housekeeping caveats. But ci.yml triggers on pull_request, which on a public
repository includes fork pull requests, and the job checks out the PR's own
tree and runs make ci-tools and make ci-pr from it. Anyone on the internet
could therefore execute their own Makefile and scripts on the adopter's
machine, with its filesystem, network position and reachable credentials.
Ephemeral cleanup does not help -- the damage happens during the job.

The section now leads with the prohibition rather than burying it: on a public
repo leave RUNNER_LABELS unset; the only safe shapes are making the repo
private, or splitting the workflow so fork PRs stay on hosted runners. It also
says why "require approval for first-time contributors" is not a fix --
approval is per-contributor, not per-diff. The operational caveats are demoted
to a private-repository subsection, and ci.yml's header carries the warning at
the point of risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TzuH-Hsu

TzuH-Hsu commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Codex P1 addressed — and it was the sharpest finding in the stack

You're right, and the doc was actively dangerous as written. ci.yml triggers on pull_request, which on a public repository includes fork PRs, and the job checks out the PR's own tree then runs make ci-tools / make ci-pr from it. Documenting how to point RUNNER_LABELS at a self-hosted runner, with only housekeeping caveats, invited adopters to hand arbitrary internet users code execution on their own machine.

The section now leads with the prohibition instead of burying it:

  • On a public repo, leave RUNNER_LABELS unset.
  • "Require approval for first-time contributors" is explicitly called out as not a fix — approval is per-contributor, not per-diff, so a returning contributor's next PR runs unreviewed.
  • The only safe shapes are named: make the repo private, or split the workflow so fork PRs stay on hosted runners and self-hosted only runs on push to controlled branches — and that second one is flagged as a real workflow change, not a variable.
  • The operational caveats are demoted to a private-repository subsection.

ci.yml's header carries the warning at the point of risk too.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8504ce6068

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/ci.yml
jobs:
ci:
runs-on: ubuntu-latest
runs-on: ${{ fromJSON(vars.RUNNER_LABELS || '["ubuntu-latest"]') }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent inheritance of organization-level runner variables

When a repository belongs to an organization that already exposes an organization-level RUNNER_LABELS, vars.RUNNER_LABELS resolves that value even though the repository variable is unset, bypassing both the documented ubuntu-latest fallback and the public-repository safety instruction; an inherited self-hosted value would immediately route fork PR code onto that runner. gh variable set --help confirms that organization variables are “available to GitHub Actions runs ... within an organization.” Use a collision-resistant name and/or have bootstrap create a repository-level default that shadows organization scope; deleting the repository variable alone is not reliable recovery.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

TzuH-Hsu and others added 4 commits September 5, 2026 15:31
1. An unreadable secret-scanning state was treated as disabled. security_and
   _analysis is only populated for callers with admin on the repo, so a token
   without it yields "public\tunknown\tunknown" -- a non-empty facts line that
   fell through to the else branch and prompted to enable, or PATCHed outright
   under --yes. That acts on a guess and reports a state never observed, which
   is the opposite of the unknown-is-not-disabled rule this phase was written
   around. There is now an explicit unknown branch that warns, emits a manual
   step, and writes nothing.

2. The Actions-permission troubleshooting entry still said phase 6 after the
   renumber moved it to 7, so the recovery text pointed at the Security prompt
   instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. LICENSE was replaced before NOTICE was known to be writable. If the NOTICE
   write then failed -- directory permissions, quota, I/O -- the repository was
   left with neither the template's original notice nor the promised
   attribution, a worse state than not running the phase at all. NOTICE is now
   written first and LICENSE is only touched once it succeeded. Verified by
   making NOTICE an unwritable path: the phase fails and LICENSE is still the
   template MIT, byte for byte.

2. The BASH_SOURCE guard covered main but not the top-level argument parser, so
   sourcing the file from a shell that had positional parameters consumed the
   caller's arguments and exit 1'd on the first one it did not recognise --
   terminating the sourcing shell. Reproduced with `set -- unexpected-arg`.
   Both the parser and main now sit behind a bootstrap_is_main helper.
   Sourcing still applies set -euo pipefail to the caller; that is documented
   rather than silently changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5430d51f93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Runner selection: the RUNNER_LABELS repo variable, a JSON ARRAY of labels.
# Unset = ubuntu-latest. A malformed value stops this job reporting any
# check at all -- see docs/setup/runners.md and ci.yml's header.
runs-on: ${{ fromJSON(vars.RUNNER_LABELS || '["ubuntu-latest"]') }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Isolate privileged jobs from pull-request runners

When RUNNER_LABELS selects a persistent self-hosted pool, this write-enabled job runs on the same machines that .github/workflows/ci.yml uses to execute pull-request-controlled Makefiles. In a private repository where a less-trusted contributor's PR is allowed to run, that code can persist on the runner and capture the later contents: write, pull-requests: write, and issues: write token when release-please runs; making the repository private does not provide job isolation. Keep privileged automation on hosted or separately isolated runners rather than routing all four workflows through one label set.

Useful? React with 👍 / 👎.

Comment thread docs/setup/runners.md
Comment on lines +130 to +131
- On a **public** repository, GitHub-hosted runners are free. A smaller runner
saves exactly nothing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the free-runner claim to standard runners

When an adopter follows the earlier ubuntu-latest-4-cores example to upsize, this claim is false: GitHub bills larger hosted runners even for public repositories; only standard hosted runners are free there. Because the page presents upsizing as an intended use of RUNNER_LABELS, the unqualified statement can cause adopters to incur charges they were explicitly told would be zero; qualify the claim and direct larger-runner users to the applicable pricing.

Useful? React with 👍 / 👎.

@TzuH-Hsu
TzuH-Hsu changed the base branch from feat/28-licence-phase to main September 5, 2026 12:54
# Conflicts:
#	docs/adr/README.md
#	docs/template/architecture.md
@TzuH-Hsu
TzuH-Hsu merged commit 02542da into main Sep 5, 2026
1 check passed
@TzuH-Hsu
TzuH-Hsu deleted the feat/31-runner-labels branch September 5, 2026 13:02
@github-actions github-actions Bot mentioned this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runs-on is hardcoded, so changing runner means forking workflow YAML the docs say never to touch

1 participant