Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 17 additions & 2 deletions .github/workflows/standards-release-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
# - scripts/cut-standards-release.sh (orchestrator)
# - scripts/lib/standards-release.sh (pure decision core)
# - tests/standards_release.bats (the suite)
# - scripts/lib/standards-channel-consumers.json (the consumers `verify` runs, #1173)
# - scripts/standards-staleness.sh, scripts/lib/standards-staleness.sh,
# tests/standards_staleness.bats, .github/workflows/standards-staleness.yml
# (the channel staleness alarm, #1173)
# - .github/workflows/standards-release-tests.yml (this file)
#
# Gates (all must pass before merge):
Expand All @@ -23,15 +27,25 @@ on:
paths:
- 'scripts/cut-standards-release.sh'
- 'scripts/lib/standards-release.sh'
- 'scripts/lib/standards-channel-consumers.json'
- 'scripts/standards-staleness.sh'
- 'scripts/lib/standards-staleness.sh'
- 'tests/standards_release.bats'
- 'tests/standards_staleness.bats'
- '.github/workflows/standards-staleness.yml'
- '.github/workflows/standards-release-tests.yml'
push:
branches:
- main
paths:
- 'scripts/cut-standards-release.sh'
- 'scripts/lib/standards-release.sh'
- 'scripts/lib/standards-channel-consumers.json'
- 'scripts/standards-staleness.sh'
- 'scripts/lib/standards-staleness.sh'
- 'tests/standards_release.bats'
- 'tests/standards_staleness.bats'
- '.github/workflows/standards-staleness.yml'
- '.github/workflows/standards-release-tests.yml'

permissions:
Expand Down Expand Up @@ -62,11 +76,12 @@ jobs:
- name: shellcheck
run: |
set -euo pipefail
shellcheck --severity=warning -x scripts/cut-standards-release.sh scripts/lib/standards-release.sh
shellcheck --severity=warning -x scripts/cut-standards-release.sh scripts/lib/standards-release.sh \
scripts/standards-staleness.sh scripts/lib/standards-staleness.sh

- name: Run bats suite
# The suite stubs gh/git via PATH; a dummy token satisfies the
# orchestrator's token precondition without any real API access.
env:
GH_TOKEN: dummy-token-for-stubbed-tests
run: bats --print-output-on-failure tests/standards_release.bats
run: bats --print-output-on-failure tests/standards_release.bats tests/standards_staleness.bats
57 changes: 57 additions & 0 deletions .github/workflows/standards-staleness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Standards Channel Staleness

# Staleness alarm for the standards release channel (#1173).
#
# standards/v<major>-stable is promoted by hand: a human decides when
# (standards/standards-versioning.md). This check tells that human when. It
# compares standards/ on the channel with the default branch and keeps ONE
# standing issue open while the gap is worth a promotion: a template under
# standards/workflows/ differs, or the oldest unpublished change is more than
# STANDARDS_STALENESS_MAX_DAYS (default 7) days old. The issue lists the
# differing files, is updated in place, and is closed once the channel catches
# up. Nothing is promoted here.
#
# Pure `gh` + `jq`, no LLM. Decision logic: scripts/lib/standards-staleness.sh
# (bats: tests/standards_staleness.bats). Runs at most once daily plus manual
# dispatch.

on:
schedule:
- cron: '23 10 * * *' # once daily, 10:23 UTC — off the top of the hour
workflow_dispatch:
inputs:
dry_run:
description: 'Print what would be done; open, edit or close no issue'
type: boolean
default: false

permissions: {} # no default permissions; the job grants only what it needs

concurrency:
group: standards-staleness
cancel-in-progress: false

jobs:
check:
name: Compare the standards channel with the default branch
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read # checkout + read tags, compare, commits
issues: write # open / update / close the one alarm issue

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
# Never pushes; don't leave the token in git config.
persist-credentials: false

- name: Check the channel
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
# Optional repo/org variable; empty falls back to the script default (7).
STANDARDS_STALENESS_MAX_DAYS: ${{ vars.STANDARDS_STALENESS_MAX_DAYS }}
run: bash scripts/standards-staleness.sh
130 changes: 125 additions & 5 deletions scripts/cut-standards-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ set -euo pipefail
# Logic Lives in a Pure, Tested Script"); this file is thin I/O glue.
#
# Usage:
# cut-standards-release.sh cut <vX.Y.Z> [--commit <sha>] [--dry-run]
# cut-standards-release.sh cut <vX.Y.Z> [--commit <sha>] [--dry-run] [--verify]
# Cut the immutable release standards/vX.Y.Z at <sha> (default: local HEAD)
# and move standards/v<major>-stable onto it. Re-running is a NOOP when the
# release already points at <sha>; it REFUSES to clobber a release that
# resolves to a different commit. --dry-run reads but never writes.
# --verify runs `verify <sha>` first and REFUSES to cut if any channel
# consumer fails. A promotion is fleet-wide with no canary stage, so
# promote with --verify (#1173).
# cut-standards-release.sh verify <ref>
# Run every known channel consumer against <ref> (a commit SHA, branch or
# tag on SR_REPO) and exit non-zero if any fails. Read-only. The consumers
# are data: scripts/lib/standards-channel-consumers.json.
# cut-standards-release.sh versions
# List published standards/vX.Y.Z versions, highest first.
# cut-standards-release.sh resolve
Expand All @@ -42,18 +49,28 @@ set -euo pipefail
# GH_TOKEN credential for the tag-write API path (the release-channel-tags
# ruleset grants the authorized mover an update-bypass on the
# protected tag; see standards/rulesets/release-channel-tags.json).
# SR_CONSUMERS_FILE the channel-consumer list `verify` reads
# (default: scripts/lib/standards-channel-consumers.json).
# SR_CONSUMERS_ROOT optional directory of existing consumer checkouts, one per
# repo name (e.g. $SR_CONSUMERS_ROOT/.github-private). When set,
# `verify` runs the consumers there instead of cloning them — use
# it to verify against an unmerged consumer branch.
# PYTHON interpreter for Python consumers (default python3).

_HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
# shellcheck source=lib/standards-release.sh
source "${_HERE}/lib/standards-release.sh"

SR_REPO="${SR_REPO:-${GITHUB_REPOSITORY:-petry-projects/.github}}"
SR_CONSUMERS_FILE="${SR_CONSUMERS_FILE:-${_HERE}/lib/standards-channel-consumers.json}"

_usage() {
cat >&2 <<EOF
Usage: $0 <command> [args]

cut <vX.Y.Z> [--commit <sha>] [--dry-run] cut the release + move the channel
cut <vX.Y.Z> [--commit <sha>] [--dry-run] [--verify]
cut the release + move the channel
verify <ref> run every channel consumer against <ref>
versions list published standards/vX.Y.Z
resolve print current + N-1 versions
channel <vX.Y.Z> print the moving channel for a version
Expand Down Expand Up @@ -162,17 +179,110 @@ _cmd_channel() {
printf '%s\n' "$(sr_channel_for "$v")"
}

# _cmd_cut <version> [--commit sha] [--dry-run] — cut the immutable release and
# move the channel.
# _gh_commit_sha <repo> <ref> — echo the full commit SHA <ref> resolves to on
# <repo>. Empty when it cannot be read: an error body is JSON, not a 40-hex SHA,
# so it is rejected rather than handed to a consumer as a ref.
_gh_commit_sha() {
local sha
sha="$(gh api -H "Accept: application/vnd.github.sha" "repos/$1/commits/$2" 2>/dev/null)" || true
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || return 0
printf '%s' "$sha"
}

# _consumer_checkout <repo> <workdir> — echo a directory holding a checkout of
# the consumer <repo>. With SR_CONSUMERS_ROOT set it is the existing checkout
# $SR_CONSUMERS_ROOT/<name>; otherwise the repo is shallow-cloned once into
# <workdir>. Returns non-zero when no checkout is available.
_consumer_checkout() {
local repo="$1" work="$2" name="${1##*/}" dir
if [ -n "${SR_CONSUMERS_ROOT:-}" ]; then
dir="${SR_CONSUMERS_ROOT%/}/$name"
[ -d "$dir" ] || return 1
printf '%s' "$dir"
return 0
fi
dir="$work/$name"
if [ ! -d "$dir" ]; then
gh repo clone "$repo" "$dir" -- --depth 1 --quiet >/dev/null 2>&1 || return 1
fi
printf '%s' "$dir"
}

# _cmd_verify <ref> — run every known channel consumer against <ref> and return
# non-zero if any fails (#1173). Read-only. "Could not verify" is a failure, never
# a pass: an unreadable consumer list, an empty one, an unresolvable ref, a
# malformed row and an unavailable checkout all fail.
_cmd_verify() {
local ref="${1:-}"
[ -n "$ref" ] || _usage
command -v jq >/dev/null 2>&1 || { echo "::error::verify needs jq" >&2; return 1; }
local count
count="$(jq -r '(.consumers // []) | length' "$SR_CONSUMERS_FILE" 2>/dev/null)" || count=""
if ! [[ "$count" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::no channel consumers could be read from $SR_CONSUMERS_FILE — cannot verify (an unreadable or empty list is not a pass)" >&2
return 1
fi
local sha
sha="$(_gh_commit_sha "$SR_REPO" "$ref")"
if [ -z "$sha" ]; then
echo "::error::could not resolve '$ref' to a commit on $SR_REPO — cannot verify" >&2
return 1
fi
echo "candidate: $ref -> ${sha:0:12} on $SR_REPO"
echo "consumers: $count (from $SR_CONSUMERS_FILE)"

local work i=0 failed=0 row id repo ref_env command expect reject dir out rc verdict
work="$(mktemp -d)"
while [ "$i" -lt "$count" ]; do
row="$(jq -c --argjson i "$i" '.consumers[$i]' "$SR_CONSUMERS_FILE")"
id="$(jq -r '.id // empty' <<< "$row")"
repo="$(jq -r '.repo // empty' <<< "$row")"
ref_env="$(jq -r '.ref_env // empty' <<< "$row")"
command="$(jq -r '.command // empty' <<< "$row")"
expect="$(jq -r '.expect_output // empty' <<< "$row")"
reject="$(jq -r '.reject_output // empty' <<< "$row")"
out="$work/$i.out"
: > "$out"
if [ -z "$id" ] || [ -z "$repo" ] || [ -z "$command" ] || ! [[ "$ref_env" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
verdict="FAIL malformed-entry"
elif ! dir="$(_consumer_checkout "$repo" "$work")"; then
verdict="FAIL checkout-unavailable"
else
rc=0
( cd "$dir" && env "$ref_env=$sha" bash -c "$command" ) > "$out" 2>&1 || rc=$?
verdict="$(sr_consumer_verdict "$rc" "$out" "$expect" "$reject")"
fi
printf ' %-28s %-34s %s\n' "${id:-<row $i>}" "${repo:-?}" "$verdict"
if [ "$verdict" != "PASS" ]; then
failed=$((failed + 1))
if [ -s "$out" ]; then
echo " --- last lines of ${id:-row $i} output ---"
tail -n 20 "$out" | sed 's/^/ /'
fi
fi
i=$((i + 1))
done
rm -rf "$work"

if [ "$failed" -gt 0 ]; then
echo "::error::$failed of $count channel consumer(s) failed against ${sha:0:12} — do not promote the channel to this commit" >&2
return 1
fi
echo "verified: all $count channel consumer(s) pass against ${sha:0:12}"
}

# _cmd_cut <version> [--commit sha] [--dry-run] [--verify] — cut the immutable
# release and move the channel.
_cmd_cut() {
local raw="${1:-}"; shift || true
[ -n "$raw" ] || _usage
local version commit="" dry_run=0
local version commit="" dry_run=0 verify=0
version="$(_normalize_version "$raw")"
while [ $# -gt 0 ]; do
case "$1" in
--commit) commit="${2:-}"; shift 2 ;;
--dry-run) dry_run=1; shift ;;
--verify) verify=1; shift ;;
*) echo "::error::unknown flag '$1'" >&2; _usage ;;
esac
done
Expand Down Expand Up @@ -210,6 +320,15 @@ _cmd_cut() {
;;
esac

# --verify (#1173): every channel consumer must pass against the target commit
# before anything is written. It reads only, so a dry run verifies too.
if [ "$verify" -eq 1 ]; then
if ! _cmd_verify "$commit"; then
echo "::error::REFUSE to cut $release_tag: channel-consumer verification failed at ${commit:0:12}. No tags written." >&2
exit 1
fi
fi

if [ "$dry_run" -eq 1 ]; then
echo "[dry-run] no tags written."
return 0
Expand Down Expand Up @@ -316,6 +435,7 @@ main() {
local cmd="$1"; shift
case "$cmd" in
cut) _cmd_cut "$@" ;;
verify) _cmd_verify "$@" ;;
versions) _cmd_versions ;;
resolve) _cmd_resolve ;;
channel) _cmd_channel "$@" ;;
Expand Down
23 changes: 23 additions & 0 deletions scripts/lib/standards-channel-consumers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"_comment": "Known consumers of the standards release channel (standards/v<major>-stable). `cut-standards-release.sh verify <ref>` runs each one against a candidate commit before a promotion (#1173). Add a row for every new STANDARDS_CHANNEL / PERSONA_SCHEMA_CHANNEL reader. Fields: id; repo (owner/name, checked out at its default branch); ref_env (the env var that pins the consumer to the candidate commit); command (run with `bash -c` from the consumer checkout); expect_output / reject_output (extended regexes over the combined output — a zero exit alone is not a pass, because a consumer that could not read the candidate may skip its checks and still exit 0).",
"consumers": [
{
"id": "template-stub-drift",
"repo": "petry-projects/.github-private",
"description": "repo-template drift guard: every seeded stub must match its emission at the candidate.",
"ref_env": "STANDARDS_REF",
"command": "bash scripts/template_stub_drift.sh",
"expect_output": "\tALIGNED\t",
"reject_output": "\tDRIFTED\t|::error|Could not compute the standards-derived content"
},
{
"id": "persona-schema-validator",
"repo": "petry-projects/.github-private",
"description": "Persona manifests must validate against the persona schema at the candidate. Needs Python >= 3.10 with jsonschema and pyyaml; set PYTHON to choose the interpreter.",
"ref_env": "PERSONA_SCHEMA_REF",
"command": "\"${PYTHON:-python3}\" personas/validate-personas.py",
"expect_output": "^OK: [0-9]+ persona manifest",
"reject_output": "::error|nothing to validate"
}
]
}
24 changes: 24 additions & 0 deletions scripts/lib/standards-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,27 @@
if [ "$existing" = "$requested" ]; then echo "NOOP"; return 0; fi
echo "REFUSE"; return 0
}

# sr_consumer_verdict <exit_code> <output_file> [<expect_regex>] [<reject_regex>]
# — decide whether one channel consumer passed against a candidate commit
# (#1173). Echoes exactly one of:
# PASS
# FAIL not-run — no numeric exit code: the consumer never ran.
# FAIL exit-<n> — the consumer exited non-zero.
# FAIL output-unreadable — its output could not be read back.
# FAIL rejected-output — its output matches <reject_regex>.
# FAIL expected-output-missing — its output does not match <expect_regex>.
# A zero exit alone is not a pass. A consumer that cannot read the candidate may
# skip every check and still exit 0, so the output must also show the work was
# done (<expect_regex>) and must not show a skip or an error (<reject_regex>).
# Both are extended regexes; an empty one is not applied. Reads the local output
# file only — no gh/git/network.
sr_consumer_verdict() {
local rc="${1:-}" out="${2:-}" expect="${3:-}" reject="${4:-}"
if ! [[ "$rc" =~ ^[0-9]+$ ]]; then echo "FAIL not-run"; return 0; fi
if [ "$rc" -ne 0 ]; then echo "FAIL exit-$rc"; return 0; fi

Check failure on line 218 in scripts/lib/standards-release.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=petry-projects_.github&issues=AaEo01EYQHyfEi5KiV-x&open=AaEo01EYQHyfEi5KiV-x&pullRequest=1293
if [ -z "$out" ] || [ ! -r "$out" ]; then echo "FAIL output-unreadable"; return 0; fi

Check failure on line 219 in scripts/lib/standards-release.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=petry-projects_.github&issues=AaEo01EYQHyfEi5KiV-z&open=AaEo01EYQHyfEi5KiV-z&pullRequest=1293

Check failure on line 219 in scripts/lib/standards-release.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=petry-projects_.github&issues=AaEo01EYQHyfEi5KiV-y&open=AaEo01EYQHyfEi5KiV-y&pullRequest=1293
if [ -n "$reject" ] && grep -Eq -- "$reject" "$out"; then echo "FAIL rejected-output"; return 0; fi

Check failure on line 220 in scripts/lib/standards-release.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=petry-projects_.github&issues=AaEo01EYQHyfEi5KiV-0&open=AaEo01EYQHyfEi5KiV-0&pullRequest=1293
if [ -n "$expect" ] && ! grep -Eq -- "$expect" "$out"; then echo "FAIL expected-output-missing"; return 0; fi

Check failure on line 221 in scripts/lib/standards-release.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=petry-projects_.github&issues=AaEo01EYQHyfEi5KiV-1&open=AaEo01EYQHyfEi5KiV-1&pullRequest=1293
echo "PASS"
}
Loading
Loading