Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions .github/workflows/dep-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ on:
TOOL is a semver artifact, so a behaviour change is a version bump somebody reviewed.
The pin lives here rather than in eleven callers.
type: string
default: '0.9.4'
default: '0.10.0'
run-floor-check:
description: |
Also run the suite against the BOTTOM of every declared sibling range, not only
Expand Down Expand Up @@ -106,6 +106,23 @@ jobs:
corepack prepare "pnpm@${PNPM_VERSION}" --activate

- name: Setup Node
# NO `cache:` here, and that is a measurement rather than an omission.
#
# The obvious move is `cache: pnpm`, and it would be dead configuration. This job only runs
# when `base_ref == 'main'` — a pull request, never a push. GitHub lets a run restore a
# cache from its own branch or from the DEFAULT branch, so a cache only ever written on
# release pull requests is one no other run can reach: every leg would be a guaranteed
# miss, paying the save cost forever and restoring nothing.
#
# cloudflare/workers-sdk hit this exact shape and wrote it down: `actions/cache` saves from
# a post step declaring `post-if: success()`, so a job that fails for any reason never
# saves either — and this job has been failing.
#
# What it would be worth: the install steps took 26s and 10s of a 319s run, so ~8%. The two
# changes that matter took the other 92% (first-party floors dropped entirely; the build
# batched, -34%). Doing it properly means `cache/restore` + explicit `cache/save` plus a
# job on `push: [main]` to populate what the pull requests read — new machinery for 8%.
# Recorded so the next person finds the measurement instead of the intuition.
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
Expand Down Expand Up @@ -440,19 +457,29 @@ jobs:
# workspace dependencies, and nothing else.
env:
PACKAGES: ${{ join(matrix.run.packages, ' ') }}
# ONE invocation, not one per package. Ten invocations re-plan the task graph ten times and
# rebuild the shared dependencies each round. Measured on theokit-plugins, whose leg claims
# 10 packages, cold cache, two rounds each:
#
# loop (10 invocations) 35.2s, 39.6s
# batched (1 invocation) 23.4s, 24.0s
#
# -34% and -39%, producing the same 10 `dist/` directories. `build-command` falls back to
# the single-package form for any package manager whose multi-package shape is unverified,
# so this is a speed-up where it applies and unchanged everywhere else.
run: |
set -euo pipefail
for pkg in $PACKAGES; do
cmd=$(npx --yes "@theokit/dep-check@${DEP_CHECK_VERSION}" build-command --root repo --package "$pkg")
if [ -z "$cmd" ]; then
echo "no build script in this repository — nothing to build"
exit 0
fi
echo "::group::build $pkg"
echo "detected: $cmd"
(cd repo && $cmd)
echo "::endgroup::"
done
args=()
for pkg in $PACKAGES; do args+=(--package "$pkg"); done
cmd=$(npx --yes "@theokit/dep-check@${DEP_CHECK_VERSION}" build-command --root repo "${args[@]}")
if [ -z "$cmd" ]; then
echo "no build script in this repository — nothing to build"
exit 0
fi
echo "::group::build $PACKAGES"
echo "detected: $cmd"
(cd repo && $cmd)
echo "::endgroup::"

- name: Suite, for the packages that claim this floor
env:
Expand Down
132 changes: 132 additions & 0 deletions .github/workflows/promotion-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# `develop` accepts one thing: the promotion from `workspace`.
#
# Called as:
#
# jobs:
# promotion-gate:
# uses: usetheokit/shared-workflows/.github/workflows/promotion-gate.yml@v1
#
# WHY A WORKFLOW AND NOT THE HOOK. `rules/git-safety.md` § 1 says `develop` "advances only by
# promoting `workspace`" and to "never merge anything other than `workspace` into it", and
# `hooks/validate-command.sh` blocks it — for a `git merge` typed in a local checkout. Nothing merges
# that way. Promotions land through `gh pr merge`, server-side, where no local hook exists. The rule
# was enforced on the path nobody uses and unenforced on the path everybody uses.
#
# That is not hypothetical, and both numbers below were measured rather than estimated:
#
# - Across the eleven repositories in this organisation that have a `develop` (2026-08-31):
# exactly ONE had a check that looks at where a pull request comes from.
# - `theokit-sdk` had accumulated 48 merged `main → develop` pull requests through the gap —
# #193 on 2026-08-11 through #493 on 2026-08-31 — against a rule that forbids them and a hook
# that blocks them, because the hook watched the path nobody uses.
#
# The 48 was first reported as thirty, which is `gh pr list`'s default page size and not a count.
# A total landing exactly on the default limit is a total nobody counted. The difference is not
# pedantic: thirty over twenty days reads as history, 48 reads as current practice.
#
# It does NOT replace the hook. The hook guards the local path and this guards the server path; a
# repository with only one of them is guarded on one side. `git-safety.md` § 1 already distinguishes
# the hook (origin) from branch protection (review) — this is the third column that was missing.
#
# REQUIRED, or it is decoration. An advisory check is a green tick nobody reads; what makes it a
# gate is branch protection listing it. Adding it as required BEFORE it has ever reported would
# block every pull request on a context that never arrives — so it is armed after its first green
# run in a repository, never in the same change.
#
# NO `paths:` FILTER in the caller, deliberately. This is marked required in branch protection, and
# a required check that does not run on some pull requests deadlocks them permanently.
#
# WHAT IT DOES NOT COVER. A push directly to `develop`, which branch protection already refuses, and
# a merge performed by someone with permission to bypass protection. It answers one question — did
# this work originate on `workspace` — which is the question the rule asks.
#
# WHY IT LIVES HERE. Measured 2026-09-05 across the ten consumers of this repository: nine held
# byte-identical copies of this gate and one held an improved variant, so a fix reached one
# repository at a time and the improvements never travelled. The `@v1` ref is what makes a change
# to the policy reach every caller; that is the same argument written at the top of `dep-check.yml`,
# with the same limit — the POLICY moves by ref, and anything versioned moves by a bump somebody
# reviewed.
#
# WHAT WAS DELIBERATELY NOT CARRIED OVER. The `theokit-sdk` variant declared a `concurrency` group
# with `cancel-in-progress`. This job is two string comparisons with no checkout and finishes in
# seconds, so cancelling a superseded run reclaims a few seconds of a runner, and GitHub already
# reports the newest run's conclusion as the check status — the stale-verdict problem the setting
# usually solves does not arise here. It is left out rather than copied, so that nobody reads its
# absence as an oversight. `timeout-minutes` below WAS carried over: it guards a different failure
# (a hang holding a runner for the six-hour default), which no other mechanism covers.
name: Promotion gate

on:
workflow_call:

permissions:
contents: read

jobs:
head-must-be-workspace:
name: develop accepts only workspace
runs-on: ubuntu-latest
# Two string comparisons and no checkout; it has never taken more than a few seconds. A tight
# ceiling turns a hang into a fast, obvious failure instead of a runner held for GitHub's
# six-hour default.
timeout-minutes: 5
steps:
- name: Refuse a head that is not workspace
env:
# Through the environment, never `${{ }}` inside `run:`. On a pull request from a fork the
# head branch name is text the author chose, so interpolating it into the script would put
# an attacker's string where the shell parses commands.
HEAD_REF: ${{ github.event.pull_request.head.ref }}
# The branch NAME is not enough. The head ref on a fork pull request is the branch name
# inside THE FORK, so anyone may fork a public repository, name a branch `workspace`, and
# satisfy a name-only check. The promotion comes from this repository's own `workspace` or
# it is not the promotion.
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail

echo "head: ${HEAD_REPO}:${HEAD_REF} -> base: ${BASE_REPO}:${BASE_REF}"

if [ "${HEAD_REPO}" != "${BASE_REPO}" ]; then
echo "::error::a fork cannot promote into develop. The head is '${HEAD_REPO}', not '${BASE_REPO}'."
echo
echo "Work originates on this repository's own workspace branch. A branch named"
echo "'workspace' in a fork is a different branch that happens to share a name."
exit 1
fi

if [ "${HEAD_REF}" = "workspace" ]; then
echo "head is this repository's workspace — the promotion the rule allows"
exit 0
fi

# The changesets bot opens its OWN pull request, named by `changesets/action` and never by
# a human. Where a repository's release runs from `develop`, that pull request targets
# `develop` and lands here. It is not the promotion this gate exists to guard
# (`git-safety.md` § 1 is about where WORK originates); it is generated tooling output
# under a different contract. Exempted by EXACT name, not a prefix match, and only
# reachable after the fork check above already confirmed HEAD_REPO == BASE_REPO — a fork
# cannot forge this repository's own bot branch.
#
# Found 2026-09-03 (usetheokit/theokit-sdk#535): every Version Packages pull request
# against develop failed this check unconditionally, because no exemption existed.
# GitHub's branch protection refused even an admin override — the only real fix was here.
#
# Harmless where it is unused: measured 2026-09-05, every consumer releases from `main`,
# so the bot's branch is `changeset-release/main` and never reaches this gate. The
# exemption costs those repositories nothing and spares the next one the same deadlock.
if [ "${HEAD_REF}" = "changeset-release/develop" ]; then
echo "head is the changesets bot's own branch — generated tooling output, not a promotion"
exit 0
fi

echo "::error::'${HEAD_REF}' cannot be merged into develop."
echo
echo "rules/git-safety.md § 1: develop advances ONLY by promoting workspace, and nothing"
echo "other than workspace may be merged into it. Work originates on workspace; develop"
echo "integrates it."
echo
echo "Land this on workspace first, then open workspace -> develop."
exit 1
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`promotion-gate.yml` is now a reusable workflow, so the gate that protects `develop` has one
home instead of ten (#51).** Measured 2026-09-05 across the ten consumers of this repository:
nine held **byte-identical** copies of a 74-line gate (identical after stripping comments and
blank lines) and one, `theokit-sdk`, held a variant that had learned two things the other nine
never received.

That is the shape this repository exists to remove. A fix written into one copy reaches one
repository, and the improvements the tenth made stayed there for as long as nobody compared.

The shared version is the **union**, not either copy:

| carried from | what |
|---|---|
| the nine | the multi-line diagnostics that cite `rules/git-safety.md` § 1 and tell the author what to do next |
| `theokit-sdk` | `timeout-minutes`, and the exemption for the changesets bot's own `changeset-release/develop` branch |

The changesets exemption is what `usetheokit/theokit-sdk#535` cost on 2026-09-03: every Version
Packages pull request against `develop` failed the gate unconditionally, and branch protection
refused even an admin override. It is harmless where unused — measured, every consumer releases
from `main`, so the bot's branch is `changeset-release/main` and never reaches this gate — and it
spares the next repository that moves its release the same deadlock.

`concurrency` was deliberately **not** carried over, and the workflow says so rather than leaving
its absence to read as an oversight: the job is two string comparisons with no checkout, and
GitHub already reports the newest run's conclusion as the check status.

- **A preview that falls back to publishing everything now says it is not installable (#48).** The
scoping added in `#46`/`#47` leaves one case open: a commit that touches no package directory —
a CI change, a docs change, a root config change. There the enumeration publishes the whole set in
Expand Down
17 changes: 17 additions & 0 deletions actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,21 @@ runs:
EXTRA_ARGS: ${{ inputs.install-args }}
# Frozen: CI installs what the lockfile says or fails. A non-frozen install in CI resolves
# something the committed lockfile does not describe, and then the suite passes about that.
#
# NO `PNPM_CONFIG_TRUST_LOCKFILE`, and that is a measurement rather than an omission.
#
# vitest-dev/vitest sets it repo-wide (`ci.yml:23-26`) and documents ~15-20s saved per job by
# skipping pnpm's per-install supply-chain verification. Measured here on theokit-sdk,
# pnpm 10.34.1, two runs each against a warm store:
#
# default (verification on) 2.78s, 2.54s
# TRUST_LOCKFILE=true 2.52s, 2.54s
#
# Nothing. Their saving is real and ours is not — most plausibly because their number comes
# from a colder store with more packages to verify, and every job here restores a warm one
# through the `cache: pnpm` above.
#
# Recorded rather than left silent so nobody re-derives it from the same blog-shaped
# intuition: this trades a supply-chain check for zero measured seconds. Worth re-testing if
# the cache stops hitting, and only against a measurement.
run: pnpm install --frozen-lockfile ${EXTRA_ARGS}
Loading