ceremony/lib/facts.sh
cluade-reviewer-andresmgsl 87cc7d5aa5
Some checks failed
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Failing after 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m24s
fix(forge): facts.sh must set REPO, and the fragment must fit the bound
Two failures on !193's first run, both real and both caught by the guards
that exist for them.

release-exercise: `lib/forge-forgejo.sh: line 550: REPO: unbound variable`.
The forgejo backend addresses the repository through REPO, which each
reconciler sets for itself; the github backend reads GITHUB_REPOSITORY
directly. facts.sh set neither, so every forgejo read refused — correctly,
and with the new #191 diagnostic, which is how it was legible at all. The
github-path suites could not have caught this: they never touch that
backend.

self-guards: changelog-armed measured a 404-character entry against the
300-character bound (#167). Split into three shorter entries in the same
fragment, which is what the rule asks for.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:37:00 +00:00

151 lines
6.6 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# lib/facts.sh — the merge door's fact gathering (issue #9).
#
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
# 5-state verdict. This script is the impure half that establishes those
# facts. It runs inside the consumer's checkout (the working directory),
# talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
#
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
#
# stdout carries exclusively those lines — the release workflow appends the
# whole stream to $GITHUB_OUTPUT — so every diagnostic goes to stderr.
#
# Env in:
# VERSION_SOURCE file | package-json (the workflow's one input)
# MERGE_SHA the pushed head (github.sha)
# EVENT_BEFORE github.event.before — may be empty or all-zeros
# GITHUB_REPOSITORY for the two API facts
# GH_TOKEN for the forge client (unused when no API state is
# consulted)
#
# The API calls run only in the states that consult them (decide tolerates
# empty facts — issue #8): RELEASED only for a bare unchanged version,
# LABELED only for a bare transition. A -dev tree — every ordinary merge —
# decides on the two versions alone and never touches the API.
set -euo pipefail
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/version.sh
. "$_facts_lib/version.sh"
# shellcheck source=lib/forge.sh
. "$_facts_lib/forge.sh"
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
case "$VERSION_SOURCE" in
file) src=VERSION ;;
package-json) src=package.json ;;
*)
echo "facts: unknown VERSION_SOURCE '$VERSION_SOURCE' — expected file or package-json" >&2
exit 1
;;
esac
ver="$(version_read "$VERSION_SOURCE")"
# event.before is all-zeros on a branch-create push, and absent outside push
# events; the pushed head's first parent is main the instant before, either
# way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char").
# One branch-create has no instant before: the repository's FIRST push to
# main, whose head is a root commit — both 0.2.0 drills died here at exit
# 128, the first release-flow event either scratch consumer ever saw (#134).
# The parent count is read as a fact (`rev-list --parents` prints the head
# alone for a root commit) rather than inferred from a failed rev-parse, so
# an unresolvable MERGE_SHA still dies loudly instead of masquerading as
# "(none)".
base_sha="${EVENT_BEFORE:-}"
case "$base_sha" in
*[!0]*) ;;
*)
parents="$(git rev-list --parents -n 1 "$MERGE_SHA")"
case "$parents" in
*" "*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;;
*) base_sha="" ;; # a root commit: no base tree exists at all
esac
;;
esac
# Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves
# the first parent, but event.before can predate it when pushes raced. If
# the fetch still cannot produce it, the git show below is the loud failure.
# Skipped entirely when there is no base: with an empty rev the fetch is
# meaningless and `git show ":$src"` would read the INDEX — reporting the
# head's own version as the base, a wrong fact worse than any crash (#134).
if [ -n "$base_sha" ]; then
git cat-file -e "$base_sha" 2>/dev/null \
|| git fetch --depth=1 origin "$base_sha" >&2 \
|| true
fi
base_dir="$(mktemp -d)"
trap 'rm -rf "$base_dir"' EXIT
if [ -n "$base_sha" ] && git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then
base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")"
else
# No base tree (a root commit — the repository's first push, the 0.2.0
# drills' wall, #134), or a base tree with no version source in it: the
# merge that ADDS the version machinery (a consumer's adoption PR, a
# greenfield repo's first caller). "(none)" is not a version, so decide
# sees a changed version and the table still governs: a -dev head is work
# (row 2, the guided bootstrap path), a bare head still demands the
# merged release label (rows 56). Nothing releases silently either way.
base_ver="(none)"
fi
released=""
labeled=""
if ! version_is_dev "$ver"; then
# The forge is selected only in the states that consult the API — a -dev
# tree, every ordinary merge, still decides on the two versions alone and
# touches no forge at all (#8's tolerance for empty facts).
# The forgejo backend addresses the repo through REPO; the github backend
# reads GITHUB_REPOSITORY directly. Set it here from the one this script
# already documents, so the two backends address the same repository —
# missing it made every forgejo read refuse with "REPO: unbound variable"
# (caught by release-exercise on !193).
REPO="${REPO:-${GITHUB_REPOSITORY:?facts: GITHUB_REPOSITORY is required for the API facts}}"
export REPO
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || exit 1
if [ "$base_ver" = "$ver" ]; then
# Row 4's input. Before #191 any failure here read as "not released",
# which is safe only because row 4 refuses either way. It is still a
# lie about what was observed, so an unreadable answer refuses.
if ! released="$(forge_release_exists "$ver")"; then
echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2
exit 1
fi
else
# Row 5's input, and the one that cost a release: a push event carries
# no PR payload, so the label is read from the API. The old code turned
# ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that
# demoted a correctly labeled, correctly merged ceremony PR into "a bare
# push", refusing the release and creating nothing. Measured in the
# 0.4.1 drill, twice (drills/0.4.1.md).
#
# Now: a completed read that finds no merged release-labeled PR is still
# `no` and still fail-closed. A read that did not complete refuses.
if ! pulls="$(forge_commit_pulls "$MERGE_SHA")"; then
echo "facts: could not read the pull requests behind '$MERGE_SHA' — refusing rather than reporting 'no label' (#191)" >&2
exit 1
fi
# One jq expression for both forges: the backends agree on the shape.
if printf '%s' "$pulls" \
| jq -e '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' >/dev/null 2>&1; then
labeled=yes
else
labeled=no
fi
fi
fi
echo "facts: ver='$ver' base_ver='$base_ver' released='$released' labeled='$labeled'" >&2
printf 'ver=%s\n' "$ver"
printf 'base_ver=%s\n' "$base_ver"
printf 'released=%s\n' "$released"
printf 'labeled=%s\n' "$labeled"