2026-07-22 19:55:39 +00:00
|
|
|
|
#!/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),
|
2026-08-04 11:31:06 +00:00
|
|
|
|
# talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
|
2026-07-22 19:55:39 +00:00
|
|
|
|
#
|
|
|
|
|
|
# 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
|
2026-08-04 11:31:06 +00:00
|
|
|
|
# GH_TOKEN for the forge client (unused when no API state is
|
|
|
|
|
|
# consulted)
|
2026-07-22 19:55:39 +00:00
|
|
|
|
#
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-08-04 11:31:06 +00:00
|
|
|
|
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
2026-07-22 19:55:39 +00:00
|
|
|
|
# shellcheck source=lib/version.sh
|
2026-08-04 11:31:06 +00:00
|
|
|
|
. "$_facts_lib/version.sh"
|
|
|
|
|
|
# shellcheck source=lib/forge.sh
|
|
|
|
|
|
. "$_facts_lib/forge.sh"
|
2026-07-22 19:55:39 +00:00
|
|
|
|
|
|
|
|
|
|
: "${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").
|
2026-07-24 12:14:07 +00:00
|
|
|
|
# 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)".
|
2026-07-22 19:55:39 +00:00
|
|
|
|
base_sha="${EVENT_BEFORE:-}"
|
|
|
|
|
|
case "$base_sha" in
|
|
|
|
|
|
*[!0]*) ;;
|
2026-07-24 12:14:07 +00:00
|
|
|
|
*)
|
|
|
|
|
|
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
|
|
|
|
|
|
;;
|
2026-07-22 19:55:39 +00:00
|
|
|
|
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.
|
2026-07-24 12:14:07 +00:00
|
|
|
|
# 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
|
2026-07-22 19:55:39 +00:00
|
|
|
|
|
|
|
|
|
|
base_dir="$(mktemp -d)"
|
|
|
|
|
|
trap 'rm -rf "$base_dir"' EXIT
|
|
|
|
|
|
|
2026-07-24 12:14:07 +00:00
|
|
|
|
if [ -n "$base_sha" ] && git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then
|
2026-07-22 19:55:39 +00:00
|
|
|
|
base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")"
|
|
|
|
|
|
else
|
2026-07-24 12:14:07 +00:00
|
|
|
|
# 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 5–6). Nothing releases silently either way.
|
2026-07-22 19:55:39 +00:00
|
|
|
|
base_ver="(none)"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
released=""
|
|
|
|
|
|
labeled=""
|
|
|
|
|
|
if ! version_is_dev "$ver"; then
|
2026-08-04 11:31:06 +00:00
|
|
|
|
# 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).
|
2026-08-04 11:37:00 +00:00
|
|
|
|
# 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
|
2026-08-04 11:31:06 +00:00
|
|
|
|
# "" means decide from the environment; forge_select takes an explicit
|
|
|
|
|
|
# forge only in tests.
|
|
|
|
|
|
forge_select "" || exit 1
|
|
|
|
|
|
|
2026-07-22 19:55:39 +00:00
|
|
|
|
if [ "$base_ver" = "$ver" ]; then
|
2026-08-04 11:31:06 +00:00
|
|
|
|
# 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
|
2026-07-22 19:55:39 +00:00
|
|
|
|
fi
|
|
|
|
|
|
else
|
2026-08-04 11:31:06 +00:00
|
|
|
|
# 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
|
2026-07-22 19:55:39 +00:00
|
|
|
|
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"
|