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
11 changes: 7 additions & 4 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/test-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
5 changes: 0 additions & 5 deletions .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 25 additions & 19 deletions WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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.*
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 5 additions & 2 deletions repo-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 31 additions & 5 deletions repo-config/configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")"
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down