From 8b3a0cf3f9c5f547b8d2f78c839b9ed4c5d76e37 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 17:57:01 -0700 Subject: [PATCH 1/7] Republish on dependency bumps; harden configure.sh (#211) Keeps the push-self-publish model (correct for NuGet - there is no Docker-style refresh, and a NuGet version can't be re-pushed) and closes the stale-dependency window, plus brings repo-config in line with the hardened canonical. ## Dependency currency A NuGet package's declared dependencies only update when a new version is published. With no scheduled rebuild possible, a merged dependency bump must republish or the package keeps shipping old/vulnerable dependency constraints. So **`Directory.Packages.props` is now a shipped input**: a dependency bump on main/develop auto-publishes that branch, keeping the package current. GitHub-Actions bumps stay excluded (they do not ship in the package). Trade-off (accepted): because versions are centrally managed, a test/codegen dependency bump (e.g. `xunit.v3`) also republishes even though it does not ship - cheap version churn in exchange for never shipping a stale dependency. (The shipped library has effectively one runtime dependency, `Microsoft.Extensions.Logging.Abstractions`.) WORKFLOW.md (glossary, D4.1, D8.2, 5A audit, S11) and AGENTS.md updated to match. ## repo-config hardening `configure.sh` `ruleset_id` distinguishes an absent ruleset from a real API error (lets gh surface its stderr; returns non-zero instead of a silent `set -e` abort), uses `jq --arg`, selects the first match inside jq (pipefail-safe), and pages the lookup (`per_page=100`). The repo-config README states the actual branch cleanup (auto-delete off so a `develop -> main` promotion does not delete `develop`; merged bot/feature branches cleaned up manually). Task comments aligned to the terse canonical form. ## Verification actionlint, markdownlint, shellcheck, `bash -n` clean; EOL preserved. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 11 ++++--- .github/workflows/test-pull-request.yml | 5 ++- .github/workflows/validate-task.yml | 5 --- AGENTS.md | 2 +- WORKFLOW.md | 44 ++++++++++++++----------- repo-config/README.md | 7 ++-- repo-config/configure.sh | 36 +++++++++++++++++--- 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 5bd4936..abf85f2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,10 +3,12 @@ name: Publish project release action # Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch. # # - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, -# embedded data, version floor, or build configuration), or a workflow_dispatch on the target branch. -# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Dependency bumps -# (Directory.Packages.props) and GitHub Actions bumps are not listed, so routine Dependabot churn does not -# republish. Ship a pending dependency update by promoting develop to main, or by dispatching. +# embedded data, version floor, build configuration, or package versions), or a workflow_dispatch. +# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Package versions +# (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be +# re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this +# closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in +# the package), so that Dependabot churn does not republish. # - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. # - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail # validation, on any path (push, dispatch, or force-push). @@ -19,6 +21,7 @@ on: - 'LanguageData/**' - 'version.json' - 'Directory.Build.props' + - 'Directory.Packages.props' workflow_dispatch: # Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index ec43ae9..fb8a232 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -21,14 +21,17 @@ concurrency: jobs: + # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fail). validate: name: Validate job + if: ${{ !github.event.deleted }} uses: ./.github/workflows/validate-task.yml # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it. smoke-build: name: Smoke build job + if: ${{ !github.event.deleted }} uses: ./.github/workflows/build-release-task.yml permissions: contents: read @@ -42,7 +45,7 @@ jobs: name: Check pull request workflow status job runs-on: ubuntu-latest needs: [validate, smoke-build] - if: always() + if: ${{ always() && !github.event.deleted }} steps: - name: Check workflow results step run: | diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 1188756..e93ec22 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -68,10 +68,5 @@ jobs: HISTORY.md incremental_files_only: false - # Lint the workflow YAML, including publish-release which the smoke path never runs. The action - # vendors actionlint (SHA-pinned, shellcheck included), so there is no hand-rolled download. The - # actionlint version is pinned for reproducibility - bump it alongside the action. - name: Lint workflows step uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 - with: - version: 1.7.12 diff --git a/AGENTS.md b/AGENTS.md index 5b5831b..6b6667d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom `mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). -**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or non-shipped dependencies does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. +**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input - including a dependency bump (`Directory.Packages.props`), so the published package's dependencies stay current - auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or GitHub-Actions bumps does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. ### Expected Review Loop diff --git a/WORKFLOW.md b/WORKFLOW.md index 520a65c..a6b42a6 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -37,11 +37,13 @@ pull requests merge themselves once their checks pass. the **base** branch's copy, while a `push`/`workflow_dispatch` event resolves it from the **pushed** head. Self-testing (section 3) depends on this. - **Shipped input** - a file that changes what the package ships: the library source (`LanguageTags/**`), - the embedded data (`LanguageData/**`), the version floor (`version.json`), or the build configuration - (`Directory.Build.props`). It is an explicit **inclusion list** (the publisher's `on.push.paths`), so a - change confined to tests, the codegen tool, dependencies, GitHub Actions, docs, or CI is **not** a - shipped input. Dependency bumps are excluded by policy to avoid republish churn (frequent, and not each - worth a release), so they ship on the next promotion or a dispatch. + the embedded data (`LanguageData/**`), the version floor (`version.json`), the build configuration + (`Directory.Build.props`), or the package versions (`Directory.Packages.props`). It is an explicit + **inclusion list** (the publisher's `on.push.paths`), so a change confined to tests, the codegen tool, + GitHub Actions, docs, or CI is **not** a shipped input. Package versions are included because a NuGet + version cannot be re-pushed (no scheduled rebuild like a Docker image), so a dependency bump must + republish to keep the package's declared dependencies current and close the stale/vulnerable-dependency + window. GitHub Actions bumps stay excluded - they do not ship in the package. - **GitHub App token** - a short-lived installation token from `actions/create-github-app-token`, minted from the App credentials (`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY`). Automation that must trigger downstream workflows or write to bot pull requests uses **this token, not `GITHUB_TOKEN`**: a @@ -182,9 +184,10 @@ Two things publish: - **An automatic release on a shipped change.** The publisher runs on `push` to `main`/`develop` with the `on.push.paths` inclusion list (`LanguageTags/**`, `LanguageData/**`, `version.json`, - `Directory.Build.props`), so it triggers only when a shipped input changed. `Directory.Packages.props` - and `.github/**` are not listed, so dependency and Actions bumps do not republish. The merge-bot merges - with the App token, so its merge commits reach this push trigger. + `Directory.Build.props`, `Directory.Packages.props`), so it triggers only when a shipped input changed. + `.github/**` is not listed, so Actions bumps do not republish; `Directory.Packages.props` is listed, so a + dependency bump republishes to keep the package's dependencies current. The merge-bot merges with the App + token, so its merge commits reach this push trigger. - **A manual release on demand.** A `workflow_dispatch` on a branch publishes it immediately, whatever changed - the "release now" control. @@ -206,7 +209,8 @@ D4. The library is self-maintaining: data and dependencies stay current on both branches, each shipped change releases automatically, and a person steps in only for a breaking change (a red check) or to force a -release by dispatch. A merged dependency bump does not itself publish. See D8. +release by dispatch. A merged dependency bump republishes (its `Directory.Packages.props` change is a +shipped input), keeping the published package's dependencies current. See D8. ### Single-target output seam @@ -291,11 +295,12 @@ applicable guarantee is not operational (section 1). - **D4.1 Publish only by dispatch or a shipped-input change.** Output: the publisher is reachable via (a) `workflow_dispatch` on a branch (force-publish, guarded to `main`/`develop`), or (b) a `push` to `main`/`develop` matching the **`on.push.paths` inclusion list** of shipped inputs (`LanguageTags/**`, - `LanguageData/**`, `version.json`, `Directory.Build.props`). The list is inclusion-only: it does not - list `Directory.Packages.props`, `.github/**`, docs, tests, or the codegen tool, so a dependency bump, - a GitHub Actions bump, or a docs change does not republish. There is no `schedule` and no - `PUBLISH_ON_MERGE`. *Prevents: a blind scheduled republish; a no-impact change (dependency bump, actions - bump, docs) cutting a release.* + `LanguageData/**`, `version.json`, `Directory.Build.props`, `Directory.Packages.props`). The list is + inclusion-only: it does not list `.github/**`, docs, tests, or the codegen tool, so a GitHub Actions bump + or a docs change does not republish. `Directory.Packages.props` **is** listed, so a dependency bump + republishes (a NuGet version can't be re-pushed, so deps must republish to stay current). There is no + `schedule` and no `PUBLISH_ON_MERGE`. *Prevents: a blind scheduled republish; a no-impact change (actions + bump, docs) cutting a release; and a stale/vulnerable dependency lingering in the published package.* - **D4.2 Publish exactly the triggering branch.** Output: the run publishes only `github.ref_name` (`develop` -> prerelease, `main` -> stable; a shipped change or dispatch on `main` cuts a stable release by design). *Prevents: a publish shipping the wrong branch.* @@ -377,9 +382,10 @@ applicable guarantee is not operational (section 1). - **D8.2 Dependabot auto-merges on green, every tier.** Output: every Dependabot pull request, any ecosystem and semver-major included, auto-merges once the required checks pass, with no version-tier exception. A failing check blocks the merge and surfaces via GitHub's check-failure notification. A - merged dependency bump does **not** itself publish (dependencies are not in the shipped-input inclusion - list, D4.1); it ships with the next shipped change or a dispatch. *Prevents: a breaking update merging - unverified; a safe update stalled waiting for a human; and dependency churn cutting needless releases.* + merged dependency bump **republishes** (`Directory.Packages.props` is a shipped input, D4.1), keeping the + published package's declared dependencies current; a GitHub-Actions bump does not. *Prevents: a breaking + update merging unverified; a safe update stalled waiting for a human; and a stale/vulnerable dependency + lingering in the published package.* - **D8.3 Codegen is deterministic and content-gated.** Output: codegen regenerates `LanguageData/` purely from its upstream sources (no per-run timestamps/GUIDs), opens a pull request only when the data changed, and auto-merges it on green. The merged data is a shipped input, so the publisher releases it (D4.1). @@ -436,7 +442,7 @@ guarantee, each pass/fail/N-A with a `file:line` citation: `publicReleaseRefSpec` is `^refs/heads/main$`. - **D4:** the publisher's triggers are `workflow_dispatch` and a `push` to `main`/`develop` with an `on.push.paths` inclusion list of exactly `LanguageTags/**`, `LanguageData/**`, `version.json`, - `Directory.Build.props` (no `Directory.Packages.props`, no `.github/**`); no `schedule`, no + `Directory.Build.props`, `Directory.Packages.props` (no `.github/**`); no `schedule`, no `PUBLISH_ON_MERGE`; the dispatch path is guarded to `main`/`develop`; the publisher calls the same `validate-task` as a `validate` job and the publish job `needs:` it (D4.6); the run publishes only `github.ref_name`; `target_commitish` is the NBGV commit id; the GitHub-release `prerelease` boolean @@ -477,7 +483,7 @@ determined by NBGV from the checkout state in section 3.* | S8 | branch/version classification disagree (e.g. `main` carries `-g`) | validate-release fails loud; build/publish skip | D2.2 | | S9 | merged codegen `LanguageData/**` change | shipped input changed -> that branch **auto-publishes** | D4.1, D8.3 | | S10 | merged GitHub-Actions version bump only | `.github/workflows/**` is not a shipped input -> **no publish** | D4.1 | -| S11 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is not in the inclusion list -> **no publish**; ships on the next shipped change or a dispatch | D4.1, D8.2 | +| S11 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is a shipped input -> that branch **auto-publishes**, keeping the package's declared dependencies current | D4.1, D8.2 | | S12 | PR with a CSharpier, dotnet-format, markdown, spelling, or workflow-YAML violation | the `lint` job fails -> aggregator blocks the merge | D1.3, D1.5 | | S13 | `version.json` floor bump merged to a branch | version floor is a shipped input -> **auto-publish** that branch at the new floor | D3.3, D4.1, D4.2 | | S14 | Dependabot **major** bump whose tests fail | required check fails -> auto-merge does **not** complete; no merge, no publish; maintainer notified | D8.2 | diff --git a/repo-config/README.md b/repo-config/README.md index 70af52e..03f1144 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -21,8 +21,11 @@ templates); repository administration config-as-code is the maintainer's, so it - [`ruleset-main.json`](./ruleset-main.json) - the `main` branch ruleset (merge-commit-only, signed commits, the same required check, strict **off**; no linear-history rule). - [`settings.json`](./settings.json) - repository settings (auto-merge on; squash **and** merge-commit - allowed; rebase off; auto-delete-on-merge **off**, so `main`/`develop` survive a promotion - the - merge-bot passes `--delete-branch` for bot PRs, and feature PRs are merged with `--delete-branch`). + allowed; rebase off; auto-delete-on-merge **off**). The repo-wide auto-delete **setting** is off so a + `develop -> main` promotion does not delete `develop` (GitHub's auto-delete would remove the merged head + branch). Per-merge deletion is explicit instead: the merge-bot deletes a merged bot branch with + `gh pr merge --delete-branch`, and a feature branch is deleted the same way (or via the merge UI's delete + button) - so `main`/`develop` survive while bot/feature branches are still cleaned up. ## What it does not store diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 4a44a9e..b13ea4d 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -29,8 +29,20 @@ pass() { printf ' \033[32mok\033[0m %s\n' "$*"; } fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } FAILED=0 -ruleset_id() { # name -> id (empty if absent) - gh api "repos/$REPO/rulesets" --jq ".[] | select(.name==\"$1\") | .id" 2>/dev/null | head -1 +ruleset_id() { # name -> id (empty if absent); aborts with a visible reason on an API error + local out + # An absent ruleset is a successful call with no match (empty); only a real API error fails. Let gh print its + # own error on stderr (do not suppress it); add a generic context line and return non-zero so the run stops + # (the caller's $(...) cannot print the cause itself). + # per_page=100 returns every ruleset in one array (a repo has only a handful); the default page size is 30. + if ! out="$(gh api "repos/$REPO/rulesets?per_page=100")"; then + echo "ERROR: could not list rulesets for $REPO (see gh error above)" >&2 + return 1 + fi + # shellcheck disable=SC2016 # $n is a jq variable (--arg n), not a shell expansion + # Select the first match inside jq (not `| head -1`): under pipefail, head closing the pipe early can + # SIGPIPE jq and fail the function. + jq -r --arg n "$1" '[.[] | select(.name==$n) | .id] | first // empty' <<<"$out" } apply_ruleset() { @@ -73,6 +85,10 @@ assert() { # caller's. Reads JSON from stdin. jq_has() { jq -e "$@" >/dev/null 2>&1; } +# jq_lacks FILTER... - true iff the jq filter selects nothing (jq exit 1). A real jq error (exit >1, e.g. a +# malformed filter or input) is propagated, not treated as "lacks", so the calling assert fails loudly. +jq_lacks() { local rc; jq -e "$@" >/dev/null 2>&1; rc=$?; case "$rc" in 0) return 1 ;; 1) return 0 ;; *) return "$rc" ;; esac; } + check_ruleset() { # name expected-merge-method expect-linear(true/false) local name="$1" method="$2" linear="$3" id rs id="$(ruleset_id "$name")" @@ -92,6 +108,10 @@ check_ruleset() { # name expected-merge-method expect-linear(true/false) if [[ "$linear" == "true" ]]; then assert "'$name' requires linear history" \ jq_has '.rules[] | select(.type=="required_linear_history")' <<<"$rs" + else + # main must NOT require linear history - it would block the develop -> main merge-commit promotion. + assert "'$name' does not require linear history" \ + jq_lacks '.rules[] | select(.type=="required_linear_history")' <<<"$rs" fi } @@ -121,10 +141,16 @@ check_security() { check_secrets() { # --paginate: the secrets endpoints page at 30, so without it a repo with many secrets could miss a - # required name and report a false failure. + # required name and report a false failure. An API/auth error FAILs fast (the required secrets cannot be + # verified, so reporting "matches" would be wrong) - distinct from a genuinely missing secret, which also + # FAILs. gh prints its own error (stderr not suppressed) so the cause is actionable. local actions deps - actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name' 2>/dev/null || true)" - deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name' 2>/dev/null || true)" + if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name')"; then + fail "could not list Actions secrets (API error - cannot verify required secrets)"; return + fi + if ! deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name')"; then + fail "could not list Dependabot secrets (API error - cannot verify required secrets)"; return + fi for s in "${REQUIRED_ACTIONS_SECRETS[@]}"; do assert "actions secret $s present" grep -qx "$s" <<<"$actions" done From a6a087e04c0bf9168747223e9b4f3b089e94ec87 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 18:16:13 -0700 Subject: [PATCH 2/7] Make jq_lacks set -e-safe (#213) Capture jq's exit code with `|| rc=$?` (a list is exempt from errexit) so an exit-1 no-match returns 'lacks' rather than risking a `set -e` abort outside a conditional, while still propagating a real jq error (>1). Verified under `set -euo pipefail` for match / no-match / jq-error. Follow-up to PR #211. Co-authored-by: Claude Opus 4.8 (1M context) --- repo-config/configure.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index b13ea4d..4390cd3 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -87,7 +87,8 @@ jq_has() { jq -e "$@" >/dev/null 2>&1; } # jq_lacks FILTER... - true iff the jq filter selects nothing (jq exit 1). A real jq error (exit >1, e.g. a # malformed filter or input) is propagated, not treated as "lacks", so the calling assert fails loudly. -jq_lacks() { local rc; jq -e "$@" >/dev/null 2>&1; rc=$?; case "$rc" in 0) return 1 ;; 1) return 0 ;; *) return "$rc" ;; esac; } +# The `|| rc=$?` keeps jq in a list (exempt from set -e) so an exit-1 no-match captures rc instead of aborting. +jq_lacks() { local rc=0; jq -e "$@" >/dev/null 2>&1 || rc=$?; case "$rc" in 1) return 0 ;; 0) return 1 ;; *) return "$rc" ;; esac; } check_ruleset() { # name expected-merge-method expect-linear(true/false) local name="$1" method="$2" linear="$3" id rs From 8e1d4cee9fe0e75f6267b6830e0ba122ecd393b3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 18:34:28 -0700 Subject: [PATCH 3/7] Clarify publisher comment: only Actions bumps skip republish (#214) Narrow the publish-release header comment - a package-version (Directory.Packages.props) bump republishes; only a GitHub-Actions Dependabot bump does not. Follow-up to #211/#212. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index abf85f2..bd3bf24 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -8,7 +8,7 @@ name: Publish project release action # (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be # re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this # closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in -# the package), so that Dependabot churn does not republish. +# the package), so an Actions Dependabot bump does not republish (a package-version bump does). # - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. # - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail # validation, on any path (push, dispatch, or force-push). From 993f2c92b9cc95dbed69bec973671d28bd492199 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 18:54:01 -0700 Subject: [PATCH 4/7] Document the branch-deletion CI guard (#215) WORKFLOW.md said validate/smoke-build run unconditionally, but they carry a `!github.event.deleted` guard. Note the branch-deletion exception in the architecture section, D1.1, and the 5A audit, and add scenario S16. Follow-up to #211/#212. Co-authored-by: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index a6b42a6..965635b 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -150,8 +150,10 @@ skips the delete still reclaims its artifact. The run's artifact set is never bl A pull request validates fast and never publishes. Validation is a reusable `validate-task` holding two jobs, `unit-test` (build and test) and `lint` (the editor's checks, enforced in CI). The pull request runs it as a `validate` job alongside `smoke-build` (build and pack the library to prove it ships, uploading and -pushing nothing). Both run unconditionally, no paths filter, so a reusable-workflow change is always -exercised head-resolved. Packaging validation as one task lets the publisher run the identical gate (D4.6). +pushing nothing). Both run on every push with no paths filter (a branch-deletion push is the one exception - +a `!github.event.deleted` guard skips them, since `github.sha` is all-zeros and checkout would fail), so a +reusable-workflow change is always exercised head-resolved. Packaging validation as one task lets the +publisher run the identical gate (D4.6). One required aggregator gates the merge. See D1. ### Self-testing workflows, and the required-context invariant @@ -241,10 +243,12 @@ applicable guarantee is not operational (section 1). ### D1 - Pull-request fast feedback - **D1.1 Every push builds, lints, and tests.** Output: on any push the `validate` job - the reusable - `validate-task`, holding the `unit-test` and `lint` jobs - and `smoke-build` all run unconditionally, - no paths filter. `smoke-build` builds and packs the library in its branch configuration through the same - `build-release-task` the publisher uses. *Prevents: a reusable-workflow change shipping untested because - a filter excluded it; a build/packaging break slipping through.* + `validate-task`, holding the `unit-test` and `lint` jobs - and `smoke-build` run with no paths filter. + The one exception is a branch-deletion push: a `!github.event.deleted` guard skips every job (and the + aggregator skips too, so the required check is not left pending), because `github.sha` is all-zeros and a + checkout/build would fail. `smoke-build` builds and packs the library in its branch configuration through + the same `build-release-task` the publisher uses. *Prevents: a reusable-workflow change shipping untested + because a filter excluded it; a build/packaging break slipping through; a branch-deletion push failing CI.* - **D1.2 Unit tests always run.** Output: the `unit-test` job (in `validate-task`) runs `dotnet test` (build with `TreatWarningsAsErrors`, so analyzer/style warnings fail here), and the aggregator reaches it through the `validate` job it `needs:`. @@ -431,7 +435,8 @@ guarantee, each pass/fail/N-A with a `file:line` citation: other consumer reading it via `needs:` outputs (a second invocation that recomputes is the defect; a commit checkout that only compiles is allowed). - **D1:** the PR workflow runs on `push` with no paths filter; the `validate` job (the reusable - `validate-task`, holding `unit-test` + `lint`) and `smoke-build` both run unconditionally; the smoke call + `validate-task`, holding `unit-test` + `lint`) and `smoke-build` run on every push except a branch deletion + (every job, the aggregator included, carries a `!github.event.deleted` guard); the smoke call sets publish off and `smoke: true`; every build `upload-artifact` is gated `!smoke`; the `lint` job runs CSharpier check, `dotnet format style --verify-no-changes`, `markdownlint-cli2`, `cspell` on README/HISTORY, and `actionlint`; the aggregator `needs:` `validate` + `smoke-build` and blocks on any @@ -488,6 +493,7 @@ determined by NBGV from the checkout state in section 3.* | S13 | `version.json` floor bump merged to a branch | version floor is a shipped input -> **auto-publish** that branch at the new floor | D3.3, D4.1, D4.2 | | S14 | Dependabot **major** bump whose tests fail | required check fails -> auto-merge does **not** complete; no merge, no publish; maintainer notified | D8.2 | | S15 | `develop` -> `main` promotion (merge commit) carrying a shipped change | the merge commit's diff (`before..after`, `before` = prior `main` tip) includes the promoted shipped input -> `main` **auto-publishes the stable release**; a promotion carrying only non-shipped changes does not | D4.1, D4.2, D8.1 | +| S16 | a branch is **deleted** (a push event with `github.sha` all-zeros) | the `!github.event.deleted` guard skips `validate`, `smoke-build`, and the aggregator -> no failed CI run, no pending required check | D1.1 | ### 5C. Live probe (where warranted, never publishing) From 7f0ba12f392000b879d5a8f9fd0f6006e6b61cae Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 19:16:37 -0700 Subject: [PATCH 5/7] Fix grammar in deletion-guard comment (#216) checkout/build fail -> fails. Follow-up to #211/#212. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/test-pull-request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index fb8a232..bb1106b 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -21,7 +21,7 @@ concurrency: jobs: - # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fail). + # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fails). validate: name: Validate job if: ${{ !github.event.deleted }} From af400b0e5ef8548cd9270ea88f62528c6d1d42ff Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 19:48:18 -0700 Subject: [PATCH 6/7] Sync section 0 shipped-input list with the glossary (#217) Section 0's model-at-a-glance omitted package versions from the shipped-input list (the glossary/D4.1 include them). Add it. Follow-up to #211/#212. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 965635b..f4f9fac 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -16,9 +16,11 @@ Each guarantee names the **failure it prevents**, so the reason survives a reimp A run targets **one branch, the one it was triggered on** (`github.ref_name`): `main` builds a stable release, `develop` a prerelease. The version is computed once and threaded downstream. A pull request -builds and tests but never publishes. The package **publishes itself** when a shipped input changes (the -source, the embedded data, the version floor, or the build configuration), so releases track the code -without a person cutting them. A maintainer dispatches only to force a release. Dependabot and codegen +builds and tests but never publishes. The package **publishes itself** when a shipped input changes - the +source, the embedded data, the version floor, the build configuration, or the package versions +(`Directory.Packages.props`) - so releases track the code without a person cutting them. Listing the package +versions means a dependency bump republishes too, keeping the package's declared dependencies current. A +maintainer dispatches only to force a release. Dependabot and codegen pull requests merge themselves once their checks pass. ### Glossary From f2ca290fd1c5986d58d85fc20455bfb2b1d80322 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 19:54:52 -0700 Subject: [PATCH 7/7] Fix directional reference in publisher comment (#218) The on.push.paths inclusion list is below the header comment; change 'add a path above' to 'add a path to that list'. Follow-up to #211/#212. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bd3bf24..901bbad 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -4,7 +4,7 @@ name: Publish project release action # # - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, # embedded data, version floor, build configuration, or package versions), or a workflow_dispatch. -# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Package versions +# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package versions # (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be # re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this # closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in