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
104 changes: 104 additions & 0 deletions .github/scripts/fork-follow-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Guard job of fork-follow.yml (tests: fork-follow.test.sh). It runs before any job that gets
# FORK_PUSH_TOKEN and refuses unless the token's isolation holds as the repository is set up now:
#
# 1. No repository or organisation secret named FORK_PUSH_TOKEN. A workflow file on any pushed
# branch can read such a secret, and in a job with an Environment GitHub falls back to it when
# the Environment has none. The job runs without an Environment, so `secrets.FORK_PUSH_TOKEN`
# there can only be a repository or organisation secret; the workflow passes only whether it is
# empty (REPO_SECRET_PRESENT=true|false), never the value. GITHUB_TOKEN has no permission that
# lists secrets, so this is the check GitHub allows.
# 2. The Environment FORK_PUSH_ENVIRONMENT exists, and only SERIES_BRANCH may deploy to it: its
# deployment branches are "Selected branches and tags" with exactly one rule, the branch
# SERIES_BRANCH, written without wildcards. "Protected branches only" is refused, because the
# API cannot prove that no other branch is protected (or becomes protected later).
#
# `fork-follow-guard.sh --environment-only` makes check 2 alone. The job that moves the series
# branch runs it right before the push, because that can be an hour or more after the Guard job
# looked. Check 1 cannot be made there: inside the Environment, `secrets.FORK_PUSH_TOKEN` is the
# Environment's own secret.
#
# Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), FORK_PUSH_ENVIRONMENT,
# SERIES_BRANCH (default main), REPO_SECRET_PRESENT, REPORT (receives the reason on refusal). Reads
# the Environment with `gh api` (GH_TOKEN: GITHUB_TOKEN with actions: read is enough).
set -euo pipefail
# Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh).
. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh"

REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}"
: "${FORK_PUSH_ENVIRONMENT:?FORK_PUSH_ENVIRONMENT}"
SERIES_BRANCH="${SERIES_BRANCH:-main}"
REPORT="${REPORT:-fork-follow-report.md}"
env_name="$FORK_PUSH_ENVIRONMENT"
settings="Settings > Environments > $env_name > Deployment branches and tags"
err="$(mktemp)"
trap 'rm -f "$err"' EXIT

die() {
printf '%s\n' "$@" > "$REPORT"
printf '::error::%s\n' "$1" >&2
[[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2
exit 1
}

case "${1:-}" in
"") secret_check=true ;;
--environment-only) secret_check=false ;;
*) echo "usage: $0 [--environment-only]" >&2; exit 2 ;;
esac

[[ "$secret_check" == false ]] || case "${REPO_SECRET_PRESENT:-}" in
false) ;;
true)
die "A repository or organisation secret named FORK_PUSH_TOKEN exists, so this run does not use the token." \
"A workflow file on any pushed branch can read a repository or organisation secret, and a job in the Environment $env_name falls back to it when the Environment has none." \
"Delete it under Settings > Secrets and variables > Actions > Repository secrets (or, for an organisation secret, remove $REPOSITORY from its repository access)." \
"Keep the token only as an Environment secret of $env_name." ;;
*)
die "The run could not tell whether a repository secret FORK_PUSH_TOKEN exists (REPO_SECRET_PRESENT='${REPO_SECRET_PRESENT:-}'), so it does not use the token." \
"This is a fault in fork-follow.yml: its guard job must pass \`secrets.FORK_PUSH_TOKEN != ''\`." ;;
esac

if ! environment="$(gh api "repos/$REPOSITORY/environments/$env_name" 2> "$err")"; then
if grep -q "404" "$err"; then
die "The Environment $env_name does not exist, so this run does not use the token." \
"Create it under Settings > Environments > New environment, named $env_name." \
"Under Deployment branches and tags pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." \
"Then add the secret FORK_PUSH_TOKEN there: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write."
fi
die "Could not read the Environment $env_name of $REPOSITORY, so this run does not use the token. gh said:" \
'```' "$(tail -n 5 "$err")" '```'
fi

policy="$(jq -r 'if .deployment_branch_policy == null then "all"
elif .deployment_branch_policy.protected_branches then "protected"
elif .deployment_branch_policy.custom_branch_policies then "custom"
else "unknown" end' <<< "$environment")"
case "$policy" in
all)
die "Any branch may deploy to the Environment $env_name, so a workflow file on any pushed branch could get FORK_PUSH_TOKEN; this run does not use it." \
"Under $settings pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." ;;
protected)
die "The Environment $env_name allows \"Protected branches only\", which the API cannot prove means only $SERIES_BRANCH; this run does not use FORK_PUSH_TOKEN." \
"Under $settings pick \"Selected branches and tags\" instead and add one rule: branch $SERIES_BRANCH (nothing else)." ;;
custom) ;;
*)
die "The deployment branch rule of the Environment $env_name is not one this guard knows, so this run does not use FORK_PUSH_TOKEN." \
"Under $settings pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." ;;
esac

rules="$(gh api "repos/$REPOSITORY/environments/$env_name/deployment-branch-policies?per_page=100" --paginate \
--jq '.branch_policies[] | "\(.type // "branch") \(.name)"' 2> "$err")" \
|| die "Could not read the deployment branch rules of the Environment $env_name, so this run does not use FORK_PUSH_TOKEN. gh said:" \
'```' "$(tail -n 5 "$err")" '```'
if [[ "$rules" != "branch $SERIES_BRANCH" ]] || [[ "$SERIES_BRANCH" == *[][*?]* ]]; then
die "The Environment $env_name must let only the branch $SERIES_BRANCH deploy, so this run does not use FORK_PUSH_TOKEN. Its rules now:" \
"$(if [[ -n "$rules" ]]; then sed 's/^\([a-z]*\) \(.*\)$/- \1 `\2`/' <<< "$rules"; else echo "- (none)"; fi)" "" \
"Under $settings keep exactly one rule, branch $SERIES_BRANCH, and delete the others (a tag rule or a pattern lets other refs deploy too)."
fi

if [[ "$secret_check" == true ]]; then
echo "FORK_PUSH_TOKEN is only an Environment secret, and only $SERIES_BRANCH may deploy to $env_name."
else
echo "Only $SERIES_BRANCH may deploy to $env_name (the repository secret was checked by the Guard job)."
fi
128 changes: 128 additions & 0 deletions .github/scripts/fork-follow-push.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# The pushes of fork-follow.yml, all made with FORK_PUSH_TOKEN, one subcommand each (tests:
# fork-follow.test.sh). Every refusal writes its reason to $REPORT, prints a ::error:: line and
# exits non-zero; nothing falls back to another token.
#
# check Fails unless FORK_PUSH_TOKEN is set, accepted by GitHub and may push here.
# candidate SHA Force-pushes SHA as the branch fork-follow/candidate.
# advance PREV SHA Moves SERIES_BRANCH from PREV to SHA with a lease. First keeps PREV as the tag
# fork-follow/pre-<BASE_TAG>-<PREV, 12 hex>, so the old series stays reachable.
# Already at SHA (a re-run) is success; moved elsewhere is a refusal.
# cleanup Deletes the branch fork-follow/candidate; a failure is only a warning.
#
# Environment: FORK_PUSH_TOKEN, FORK_PUSH_ENVIRONMENT (the GitHub Environment that should hold the
# token; named when it is missing), REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), PUSH_URL
# (default https://github.com/$REPOSITORY.git), SERIES_BRANCH (default main), BASE_TAG (advance),
# REPORT. Output (candidate_pushed, moved, backup_tag) goes to $GITHUB_OUTPUT, else stdout.
#
# The token reaches git through GIT_CONFIG_* variables as an HTTP header: never in a URL, a command
# line or .git/config, so git's error messages (copied into reports) cannot contain it.
set -euo pipefail

REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}"
PUSH_URL="${PUSH_URL:-https://github.com/$REPOSITORY.git}"
SERIES_BRANCH="${SERIES_BRANCH:-main}"
REPORT="${REPORT:-fork-follow-report.md}"
OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}"
CANDIDATE_BRANCH=fork-follow/candidate
err="$(mktemp)"
trap 'rm -f "$err"' EXIT

die() {
printf '%s\n' "$@" > "$REPORT"
printf '::error::%s\n' "$1" >&2
[[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2
exit 1
}

git_said() { printf '%s\n' '```' "$(tail -n 15 "$err")" '```'; }

with_token() {
if [[ -z "${FORK_PUSH_TOKEN:-}" && -n "${FORK_PUSH_ENVIRONMENT:-}" ]]; then
die "The secret FORK_PUSH_TOKEN is missing in the Environment $FORK_PUSH_ENVIRONMENT (or the Environment does not exist yet), so this run cannot push." \
"Under Settings > Environments, create $FORK_PUSH_ENVIRONMENT (a run that names a missing Environment makes an empty one with no rules)," \
"limit its deployment branches to $SERIES_BRANCH, and add the secret FORK_PUSH_TOKEN there: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \
"Keep it out of the repository secrets: a workflow file on any pushed branch can read those, while an Environment limited to $SERIES_BRANCH gives it only to runs from $SERIES_BRANCH." \
"GITHUB_TOKEN cannot replace it: it may not push commits that change workflow files."
fi
[[ -n "${FORK_PUSH_TOKEN:-}" ]] \
|| die "The secret FORK_PUSH_TOKEN is missing, so this run cannot push." \
"Add it under Settings > Secrets and variables > Actions: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \
"GITHUB_TOKEN cannot replace it: it may not push commits that change workflow files."
local header
header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$FORK_PUSH_TOKEN" | base64 -w0)"
[[ "${GITHUB_ACTIONS:-}" != true ]] || echo "::add-mask::$header"
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0="$header"
export GIT_TERMINAL_PROMPT=0
}

remote_ref() { # prints the remote's commit for a ref, or nothing; fails if the remote cannot be read
git ls-remote "$PUSH_URL" "$1" 2> "$err" | cut -f1
}

cmd_check() {
with_token
git ls-remote "$PUSH_URL" HEAD > /dev/null 2> "$err" \
|| die "GitHub refused FORK_PUSH_TOKEN: it is expired, revoked, or not valid for $REPOSITORY." \
"Renew it (a fine-grained token for $REPOSITORY only, Contents and Workflows read and write) and update the secret. git said:" \
"$(git_said)"
# A dry run asks for push access (receive-pack) without changing anything.
git push --dry-run "$PUSH_URL" "$(git rev-parse HEAD):refs/heads/fork-follow/token-check" > /dev/null 2> "$err" \
|| die "FORK_PUSH_TOKEN may not push to $REPOSITORY." \
"Give the token Contents read and write (and Workflows read and write) on $REPOSITORY. git said:" "$(git_said)"
echo "FORK_PUSH_TOKEN is accepted and may push to $REPOSITORY."
}

cmd_candidate() {
local sha="${1:?SHA}"
with_token
git push --force "$PUSH_URL" "$sha:refs/heads/$CANDIDATE_BRANCH" > /dev/null 2> "$err" \
|| die "Could not push the candidate ${sha:0:12} to $CANDIDATE_BRANCH." \
"If the rebase brought workflow file changes, the token also needs Workflows read and write. git said:" "$(git_said)"
echo "candidate_pushed=true" >> "$OUTPUT"
echo "Pushed ${sha:0:12} to $CANDIDATE_BRANCH."
}

cmd_advance() {
local prev="${1:?PREV}" sha="${2:?SHA}" now backup
: "${BASE_TAG:?BASE_TAG}"
with_token
now="$(remote_ref "refs/heads/$SERIES_BRANCH")" \
|| die "Could not read $SERIES_BRANCH of $REPOSITORY, so it was not moved. git said:" "$(git_said)"
if [[ "$now" == "$sha" ]]; then
echo "moved=already" >> "$OUTPUT"
echo "$SERIES_BRANCH is already at ${sha:0:12}."
return 0
fi
[[ "$now" == "$prev" ]] \
|| die "$SERIES_BRANCH moved since the plan (now ${now:-missing}, planned from ${prev:0:12}), so it was not moved to ${sha:0:12}." \
"A fork pull request merged meanwhile, or someone pushed it. The next run rebases again with that change."
backup="fork-follow/pre-$BASE_TAG-${prev:0:12}"
git push "$PUSH_URL" "$prev:refs/tags/$backup" > /dev/null 2> "$err" \
|| die "Could not keep the old series ${prev:0:12} as tag $backup, so $SERIES_BRANCH was not moved. git said:" "$(git_said)"
echo "backup_tag=$backup" >> "$OUTPUT"
git push --force-with-lease="refs/heads/$SERIES_BRANCH:$prev" "$PUSH_URL" "$sha:refs/heads/$SERIES_BRANCH" > /dev/null 2> "$err" \
|| die "Could not move $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12}." \
"If a rule protects $SERIES_BRANCH, the token's user must be allowed to bypass it. The old series is kept as tag $backup. git said:" "$(git_said)"
echo "moved=true" >> "$OUTPUT"
echo "Moved $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12} (old series kept as tag $backup)."
}

cmd_cleanup() {
with_token
local now
if ! now="$(remote_ref "refs/heads/$CANDIDATE_BRANCH")"; then
echo "::warning::Could not read $CANDIDATE_BRANCH; it may be left over (the next run force-pushes over it)."
elif [[ -n "$now" ]] && ! git push "$PUSH_URL" --delete "refs/heads/$CANDIDATE_BRANCH" > /dev/null 2> "$err"; then
echo "::warning::Could not delete $CANDIDATE_BRANCH (the next run force-pushes over it): $(tail -n 3 "$err" | tr '\n' ' ')"
fi
return 0
}

case "${1:-}" in
check) cmd_check ;;
candidate) cmd_candidate "${@:2}" ;;
advance) cmd_advance "${@:2}" ;;
cleanup) cmd_cleanup ;;
*) echo "usage: $0 check | candidate SHA | advance PREV SHA | cleanup" >&2; exit 2 ;;
esac
Loading
Loading