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
142 changes: 142 additions & 0 deletions .github/scripts/fork-release-workflow.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Checks the shape of .github/workflows/fork-release.yml that its guards rely on.

python3 .github/scripts/fork-release-workflow.test.py [path/to/fork-release.yml]

Needs PyYAML. Each check prints ok/FAIL; any FAIL exits 1.
"""

import pathlib
import re
import sys

import yaml

root = pathlib.Path(__file__).resolve().parents[2]
path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else root / ".github/workflows/fork-release.yml"
text = path.read_text()
wf = yaml.safe_load(text)
desktop = yaml.safe_load((root / ".github/workflows/release-desktop.yml").read_text())
jobs = wf["jobs"]
failures = []


def check(name, ok, detail=""):
print(("ok " if ok else "FAIL ") + name + ("" if ok or not detail else f"\n | {detail}"))
if not ok:
failures.append(name)


def steps(job):
return job.get("steps", [])


def all_steps():
for job_id, job in jobs.items():
for step in steps(job):
yield job_id, step


# Actions run by commit, so a moved tag cannot change what runs (the release action holds a write
# token). Local actions and workflows are part of this repository.
unpinned = []
for line in text.splitlines():
m = re.match(r"\s*(?:-\s*)?uses:\s*(\S+)(.*)$", line)
if not m or m.group(1).startswith("./"):
continue
if not re.fullmatch(r"[\w.-]+/[\w./-]+@[0-9a-f]{40}", m.group(1)) or not re.match(r"\s*#\s*v\d", m.group(2)):
unpinned.append(m.group(1))
check("external actions are pinned by commit SHA with a version comment", not unpinned, ", ".join(unpinned))

# Named secrets only: the signing secrets release-desktop.yml declares, nothing inherited.
declared = set(desktop[True]["workflow_call"]["secrets"])
inherit = [j for j, job in jobs.items() if job.get("secrets") == "inherit"]
check("no job passes secrets: inherit", not inherit, ", ".join(inherit))
wrong = []
for job_id, job in jobs.items():
if job.get("uses") == "./.github/workflows/release-desktop.yml":
passed = job.get("secrets")
if not isinstance(passed, dict) or set(passed) != declared or any(
v != "${{ secrets.%s }}" % k for k, v in passed.items()
):
wrong.append(job_id)
check("release-desktop.yml gets exactly the secrets it declares", not wrong, ", ".join(wrong))
check("FORK_PUSH_TOKEN appears nowhere", "FORK_PUSH_TOKEN" not in text)

bad = [j for j, s in all_steps() if str(s.get("uses", "")).startswith("actions/checkout@")
and (s.get("with") or {}).get("persist-credentials") is not False]
check("every checkout sets persist-credentials: false", not bad, ", ".join(bad))

release_steps = [(j, s) for j, s in all_steps() if str(s.get("uses", "")).startswith("softprops/action-gh-release@")]
check("one release-action step", len(release_steps) == 1)
check("the release action fails on unmatched files",
all((s.get("with") or {}).get("fail_on_unmatched_files") is True for _, s in release_steps))

# The write token: only the publish job has it, and only its three release-writing steps use it.
write_jobs = [j for j, job in jobs.items() if (job.get("permissions") or {}).get("contents") == "write"]
check("only the publish job has contents: write", write_jobs == ["publish"], ", ".join(write_jobs))
package_code = re.compile(r"(^|[\s;&|(])(vp|node|npm|npx|pnpm|bun)\s")
for job_id in write_jobs:
job_steps = steps(jobs[job_id])
runs_packages = [s.get("name") for s in job_steps
if str(s.get("uses", "")).startswith("./") or package_code.search(str(s.get("run", "")))]
check(f"{job_id}: runs no package code", not runs_packages, ", ".join(map(str, runs_packages)))
token_steps = [s for s in job_steps if "github.token" in yaml.safe_dump(s) or "secrets." in yaml.safe_dump(s)
or str(s.get("uses", "")).startswith("softprops/")]
allowed = [s for s in token_steps if str(s.get("uses", "")).startswith("softprops/")
or re.fullmatch(r"bash \.github/scripts/fork-release\.sh (remove-drafts|publish) .*", str(s.get("run", "")).strip())]
check(f"{job_id}: the token reaches only the remove-drafts, create and publish steps",
len(token_steps) == len(allowed) == 3, ", ".join(str(s.get("name")) for s in token_steps))

def index(pred):
return next((i for i, s in enumerate(job_steps) if pred(s)), -1)

remove = index(lambda s: "fork-release.sh remove-drafts" in str(s.get("run", "")))
create = index(lambda s: str(s.get("uses", "")).startswith("softprops/"))
publish = index(lambda s: "fork-release.sh publish" in str(s.get("run", "")))
check(f"{job_id}: refuse/remove drafts, then create the draft, then publish it",
-1 < remove < create < publish)
if create >= 0 and publish >= 0:
check(f"{job_id}: publishes the draft it created, by id",
job_steps[create].get("id") == "draft"
and "steps.draft.outputs.id" in yaml.safe_dump(job_steps[publish]))

# Shell steps fail on any error in a pipeline.
lax = []
for job_id, s in all_steps():
run = s.get("run")
if run is None:
continue
lines = [l for l in run.strip().splitlines() if l.strip() and not l.strip().startswith("#")]
if s.get("shell") != "bash" or (len(lines) > 1 and lines[0].strip() != "set -euo pipefail"):
lax.append(f"{job_id}/{s.get('name')}")
check("run steps use bash and multi-line ones start with set -euo pipefail", not lax, "; ".join(lax))

runs = "\n".join(str(s.get("run", "")) for _, s in all_steps())
check("preflight resolves the version through the guard script",
"fork-release.sh resolve" in "\n".join(str(s.get("run", "")) for s in steps(jobs["preflight"])))
check("preflight runs the guard tests", "fork-release.test.sh" in runs)
check("the CLI archives are checked for the release repository", "fork-release.sh check-archives release-assets" in runs)
check("preflight runs this shape test", "fork-release-workflow.test.py" in
"\n".join(str(s.get("run", "")) for s in steps(jobs["preflight"])))

# The server bundle check skips dist/client, which names this repository on every Actions build.
bundle_steps = [s for s in steps(jobs["build_bundle"]) if "apps/server/dist" in str(s.get("run", ""))]
check("the server bundle is checked through check-bundle (without dist/client)",
[str(s.get("run", "")).strip() for s in bundle_steps]
== ["bash .github/scripts/fork-release.sh check-bundle apps/server/dist"])

# The build names this fork through the setting scripts/lib/release-repository.ts reads.
build = next((s for s in steps(jobs["build_bundle"]) if s.get("name") == "Build JS bundle"), {})
check("the build sets T3CODE_DESKTOP_UPDATE_REPOSITORY to this repository",
(build.get("env") or {}).get("T3CODE_DESKTOP_UPDATE_REPOSITORY") == "${{ github.repository }}")
check("the former name T3CODE_RELEASE_REPOSITORY appears nowhere", "T3CODE_RELEASE_REPOSITORY" not in text)

# The write job's guard script comes from the workflow's own commit, not the commit being built.
guard = [s for s in steps(jobs["publish"]) if str(s.get("uses", "")).startswith("actions/checkout@")]
check("publish checks out its guard script at github.workflow_sha",
len(guard) == 1 and (guard[0].get("with") or {}).get("ref") == "${{ github.workflow_sha }}")

if failures:
print(f"{len(failures)} failed")
sys.exit(1)
print("all passed")
233 changes: 233 additions & 0 deletions .github/scripts/fork-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
#!/usr/bin/env bash
# The guards of fork-release.yml, one subcommand each, so they can be tested without a run
# (fork-release.test.sh). Every refusal exits non-zero with a ::error:: line; nothing falls back.
#
# resolve Preflight: picks base tag, version and tag for HEAD (key=value lines).
# remove-drafts TAG Refuses a published TAG, then deletes drafts with TAG left by earlier runs.
# publish ID TAG VERSION Refuses a published TAG or a published release that is not below
# VERSION, then publishes draft ID as the latest release.
# check-bundle DIR The server bundle DIR (apps/server/dist) outside DIR/client must name
# REPOSITORY as its release repository.
# check-archives DIR The `t3` program of every CLI archive in DIR must name REPOSITORY.
#
# Environment: REPOSITORY (owner/name of this fork; GITHUB_REPOSITORY in a run), UPSTREAM
# (owner/name, resolve looks it up), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN
# (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout.
set -euo pipefail

REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}"
ORIGIN="${ORIGIN:-origin}"
OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}"

die() {
printf '::error::%s\n' "$*" >&2
exit 1
}

stable_version_re='^v[0-9]+\.[0-9]+\.[0-9]+$'
fork_version_re='^v?[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$'

# version_gt A B: A sorts above B. Takes X.Y.Z or X.Y.Z-fork.N (leading v allowed). A fork
# release of X.Y.Z sorts above X.Y.Z itself here, which is what this fork's ordering needs.
version_gt() {
local a="${1#v}" b="${2#v}" i
local -a x y
IFS=. read -r -a x <<< "${a/-fork./.}"
IFS=. read -r -a y <<< "${b/-fork./.}"
for i in 0 1 2 3; do
if (( 10#${x[i]:-0} != 10#${y[i]:-0} )); then
(( 10#${x[i]:-0} > 10#${y[i]:-0} ))
return
fi
done
return 1
}

# Prints the releases of this fork through a jq filter; a failed listing stops everything.
releases() {
gh api "repos/$REPOSITORY/releases" --paginate --jq "$1" \
|| die "Could not list the releases of $REPOSITORY; nothing was changed."
}

# A published (non-draft) release or a tag with this name means the version is taken: "Re-run failed
# jobs" reuses the preflight's version, and publishing again would replace a live release's files.
refuse_published() {
local tag="$1" published
if git ls-remote --exit-code --tags "$ORIGIN" "refs/tags/$tag" > /dev/null; then
die "Tag $tag already exists, so $tag is published or being published. Start a new run (Re-run all jobs) for the next number; nothing was changed."
else
# 2 means no such ref; anything else is a failed lookup, which must not read as "free".
[[ $? == 2 ]] || die "Could not list the tags of $ORIGIN."
fi
published="$(releases ".[] | select((.draft | not) and .tag_name == \"$tag\") | .id")"
if [[ -n "$published" ]]; then
die "A published release ($published) already has tag $tag. Start a new run (Re-run all jobs); nothing was changed."
fi
}

# The server's updater (packages/shared/src/cliRelease.ts newestCliReleaseVersion) takes the
# newest-created non-draft release on its channel, not the highest version. So every new release
# must be above every published one, or `t3 update` would see a downgrade and stall until a higher
# build exists. Stable channel = any vX.Y.Z[-...] tag except -nightly./-preview. builds.
refuse_older() {
local version="$1" other published
published="$(releases '.[] | select(.draft | not) | .tag_name')"
for other in $published; do
[[ "$other" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]] || continue
[[ ! "$other" =~ -(nightly|preview)\. ]] || continue
[[ "$other" =~ $stable_version_re || "$other" =~ $fork_version_re ]] \
|| die "Published release $other is on the server's stable channel but has no X.Y.Z or X.Y.Z-fork.N version, so $version cannot be ordered against it; nothing was changed."
version_gt "$version" "$other" \
|| die "$other is published and not below $version. The server updates to the newest-created release, so publishing $version would show every server a downgrade. Build a newer upstream release instead; to go back, install the older release with t3 update --version X --allow-downgrade. Nothing was changed."
done
}

cmd_resolve() {
local upstream="${UPSTREAM:-}"
if [[ -z "$upstream" ]]; then
upstream="$(gh api "repos/$REPOSITORY" --jq '.parent.full_name // empty')"
[[ -n "$upstream" ]] || die "$REPOSITORY is not a fork, so there is no upstream release to build on."
fi
local url="${UPSTREAM_URL:-https://github.com/$upstream.git}"
local head
head="$(git rev-parse HEAD)"

# Read-only fetch of upstream's tags and default branch into refs this run alone uses.
git fetch --quiet --no-tags "$url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head"

# Lists are read into variables first: a failure inside <(...) would go unnoticed.
local refs tag
local -a contained=()
refs="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags)"
for tag in $(grep -E "$stable_version_re" <<< "$refs" | sort -rV || true); do
if git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" HEAD; then
contained+=("$tag")
fi
done
(( ${#contained[@]} > 0 )) || die "$head contains no stable $upstream release tag."
local base_tag="${contained[0]}"
local base
base="$(git rev-parse "refs/upstream-tags/$base_tag^{commit}")"

# The base must be the newest stable tag in the series' history, not just the highest name: a
# stray tag on an old commit (say v1.0.0) would otherwise outrank every later real release.
for tag in "${contained[@]:1}"; do
git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" "$base" \
|| die "$base_tag sorts highest but is not the newest stable $upstream tag in this history ($tag is not part of it). Delete or fix the stray tag; nothing was built."
done

# Upstream's own ordering: never above the release upstream marks latest.
local upstream_latest
upstream_latest="$(gh api "repos/$upstream/releases/latest" --jq '.tag_name')"
[[ "$upstream_latest" =~ $stable_version_re ]] \
|| die "$upstream's latest release is '$upstream_latest', not a stable vX.Y.Z tag."
if version_gt "$base_tag" "$upstream_latest"; then
die "$base_tag sorts above $upstream's latest release $upstream_latest. Only released upstream versions are built."
fi

# Releases only: every commit after the base tag must be the fork's own. Upstream commits there
# are unreleased upstream code (for example a branch that still equals upstream's default branch).
local total own
total="$(git rev-list --count "$base..HEAD")"
own="$(git rev-list --count "$base..HEAD" --not refs/upstream-head)"
if [[ "$total" != "$own" ]]; then
die "$head holds $((total - own)) unreleased $upstream commits after $base_tag. Build a branch of $base_tag plus the fork's own commits."
fi

local version="${base_tag#v}" last
last="$(git ls-remote --tags --refs "$ORIGIN" "v$version-fork.*" \
| sed -n "s|.*refs/tags/v$version-fork\.\([0-9]\+\)$|\1|p" | sort -n | tail -n1)"
version="$version-fork.$(( ${last:-0} + 1 ))"
refuse_older "$version"
{
echo "ref=$head"
echo "upstream=$upstream"
echo "base_tag=$base_tag"
echo "version=$version"
echo "tag=v$version"
} >> "$OUTPUT"
echo "Building $version from $(git rev-parse --short HEAD) on $upstream $base_tag." \
| tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" >&2
}

cmd_remove_drafts() {
local tag="${1:?tag}" id
refuse_published "$tag"
local ids
ids="$(releases ".[] | select(.draft and .tag_name == \"$tag\") | .id")"
for id in $ids; do
gh api -X DELETE "repos/$REPOSITORY/releases/$id" > /dev/null
echo "Removed leftover draft $id for $tag."
done
[[ -n "$ids" ]] || echo "No leftover draft for $tag."
}

cmd_publish() {
local id="${1:?release id}" tag="${2:?tag}" version="${3:?version}"
[[ "$version" =~ $fork_version_re ]] || die "'$version' is not a fork version."
refuse_published "$tag"
# Checked again here: a release published since preflight must not end up above this one. After
# this check VERSION is the highest release, so the server and the desktop updater agree on it.
refuse_older "$version"
gh api -X PATCH "repos/$REPOSITORY/releases/$id" -F draft=false -f make_latest=true > /dev/null
echo "Published $tag (latest: true)."
{
echo "published=true"
echo "latest=true"
} >> "$OUTPUT"
}

# The web client (DIR/client) names this repository on every Actions build anyway: upstream's
# apps/web/vite.config.ts bakes GITHUB_REPOSITORY into it. Only the server code counts here.
cmd_check_bundle() {
local dir="${1:?directory}" file files
[[ -d "$dir" ]] || die "No server bundle at $dir."
files="$(find "$dir" -path "$dir/client" -prune -o -type f -print)"
[[ -n "$files" ]] || die "The server bundle $dir holds no files outside client/."
while IFS= read -r file; do
if grep -qaF "\"$REPOSITORY\"" "$file"; then
echo "The server bundle names $REPOSITORY (${file#"$dir"/})."
return 0
fi
done <<< "$files"
die "The server bundle $dir (outside client/) does not name $REPOSITORY as its release repository, so it would update from elsewhere."
}

cmd_check_archives() {
local dir="${1:?directory}" archive work name stem program found=0
shopt -s nullglob
for archive in "$dir"/t3-*.tar.gz "$dir"/t3-*.zip; do
found=1
work="$(mktemp -d)"
case "$archive" in
*.tar.gz) tar -xzf "$archive" -C "$work" ;;
*.zip) python3 -I -m zipfile -e "$archive" "$work" ;;
esac
# Only the `t3` program at <stem>/ (scripts/build-cli-archive.ts): it bundles the server again,
# and its updater is what counts. client/ names this repository on every Actions build anyway.
name="$(basename "$archive")"
stem="${name%.tar.gz}"
stem="${stem%.zip}"
program="$work/$stem/t3"
[[ "$archive" != *.zip ]] || program="$work/$stem/t3.exe"
[[ -f "$program" ]] \
|| die "$name has no ${program#"$work"/}, so the server program in it cannot be checked."
grep -qaF "\"$REPOSITORY\"" "$program" \
|| die "${program#"$work"/} in $name does not name $REPOSITORY as its release repository, so it would update from elsewhere."
rm -rf "$work"
echo "${program#"$work"/} in $name names $REPOSITORY."
done
(( found )) || die "No CLI archive in $dir."
}

command="${1:-}"
shift || true
case "$command" in
resolve) cmd_resolve "$@" ;;
remove-drafts) cmd_remove_drafts "$@" ;;
publish) cmd_publish "$@" ;;
check-bundle) cmd_check_bundle "$@" ;;
check-archives) cmd_check_archives "$@" ;;
version-gt) version_gt "$@" ;;
*) die "usage: $0 resolve | remove-drafts TAG | publish ID TAG VERSION | check-bundle DIR | check-archives DIR" ;;
esac
Loading