diff --git a/.github/scripts/fork-release-workflow.test.py b/.github/scripts/fork-release-workflow.test.py new file mode 100644 index 0000000000000..2ee5be72787cc --- /dev/null +++ b/.github/scripts/fork-release-workflow.test.py @@ -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") diff --git a/.github/scripts/fork-release.sh b/.github/scripts/fork-release.sh new file mode 100755 index 0000000000000..62e62a4816736 --- /dev/null +++ b/.github/scripts/fork-release.sh @@ -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 / (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 diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh new file mode 100755 index 0000000000000..2a1e2240ecba1 --- /dev/null +++ b/.github/scripts/fork-release.test.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Tests for fork-release.sh against local git repositories and a fake `gh`; no network. +# bash .github/scripts/fork-release.test.sh (FORK_RELEASE_SH picks another script) +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$(realpath "${FORK_RELEASE_SH:-$here/fork-release.sh}")" +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the workflow uses. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN + +# Fake gh: `gh api [-X M] PATH [--paginate] [--jq EXPR] [-f/-F k=v]...` served from $root/api. +mkdir -p "$root/bin" "$root/api" +cat > "$root/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +api="$FAKE_API" +echo "gh $*" >> "$api/calls" +[[ "$1" == api ]] || { echo "fake gh: only api" >&2; exit 2; } +shift +method=GET path="" jq="" fields=() +while (( $# )); do + case "$1" in + -X) method="$2"; shift 2 ;; + --jq) jq="$2"; shift 2 ;; + --paginate) shift ;; + -f|-F) fields+=("$2"); shift 2 ;; + *) path="$1"; shift ;; + esac +done +case "$method $path" in + "GET repos/me/t3code") body='{"parent":{"full_name":"up/t3code"}}' ;; + "GET repos/up/t3code/releases/latest") body="{\"tag_name\":\"$(cat "$api/upstream_latest")\"}" ;; + "GET repos/me/t3code/releases") + [[ ! -e "$api/fail_list" ]] || { echo "HTTP 502: Bad Gateway" >&2; exit 1; } + body="$(cat "$api/releases.json")" ;; + "DELETE repos/me/t3code/releases/"*) + id="${path##*/}" + jq --argjson id "$id" 'map(select(.id != $id))' "$api/releases.json" > "$api/r.tmp" + mv "$api/r.tmp" "$api/releases.json" + echo "deleted $id" >> "$api/writes"; exit 0 ;; + "PATCH repos/me/t3code/releases/"*) + echo "patched ${path##*/} ${fields[*]}" >> "$api/writes"; exit 0 ;; + *) echo "fake gh: no route for $method $path" >&2; exit 1 ;; +esac +if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi +GH +chmod +x "$root/bin/gh" +export PATH="$root/bin:$PATH" FAKE_API="$root/api" + +failures=0 +pass() { echo "ok $1"; } +fail() { + echo "FAIL $1" + sed 's/^/ | /' "$root/out" 2> /dev/null || true + failures=$((failures + 1)) +} +run() { (cd "$1" && shift && "$script" "$@") > "$root/out" 2>&1; } + +reset_api() { + rm -f "$root/api"/* + echo '[]' > "$root/api/releases.json" + echo "${1:-v0.1.9}" > "$root/api/upstream_latest" +} + +# Upstream history: u1 (v0.1.8) - u2 (v0.1.9) - u3 (unreleased, upstream main). +new_upstream() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + git commit -q --allow-empty -m u1 && git tag v0.1.8 + git commit -q --allow-empty -m u2 && git tag -a -m v0.1.9 v0.1.9 + git commit -q --allow-empty -m u3 + git push -q https://github.com/up/t3code.git main --tags + ) +} + +# The fork's checkout: $1 (an upstream rev) plus one fork commit, pushed as the fork's main. +new_fork() { + rm -rf "$root/work" + git clone -q https://github.com/up/t3code.git "$root/work" + ( + cd "$root/work" + git remote set-url origin https://github.com/me/t3code.git + git checkout -q -B main "$1" + [[ "${2:-}" == bare ]] || git commit -q --allow-empty -m "fork: own change" + git push -q --force origin main + ) +} + +fork_tag() { (cd "$root/work" && git tag "$1" && git push -q origin "refs/tags/$1"); } + +expect_resolved() { + local name="$1" want="$2" + if run "$root/work" resolve && grep -qx "version=$want" "$root/out"; then pass "$name"; else fail "$name"; fi +} +expect_refused() { + local name="$1" pattern="$2" + shift 2 + if ! run "$@" && grep -q "::error::.*$pattern" "$root/out"; then pass "$name"; else fail "$name"; fi +} + +# --- resolve ----------------------------------------------------------------------------------- +new_upstream +reset_api +new_fork v0.1.9 +expect_resolved "resolve: v0.1.9 plus a fork commit is 0.1.9-fork.1" 0.1.9-fork.1 +fork_tag v0.1.9-fork.1 +fork_tag v0.1.9-fork.x +expect_resolved "resolve: the next build counts the fork's numbered tags" 0.1.9-fork.2 + +new_fork main bare +expect_refused "resolve: refuses unreleased upstream commits (upstream main)" "unreleased" "$root/work" resolve +new_fork main +expect_refused "resolve: refuses unreleased upstream commits under fork commits" "unreleased" "$root/work" resolve + +(cd "$root/upwork" && git tag v1.0.0 v0.1.8 && git push -q https://github.com/up/t3code.git v1.0.0) +new_fork v0.1.9 +expect_refused "resolve: refuses a stray higher tag on an old commit" "not the newest" "$root/work" resolve + +new_upstream +reset_api v0.1.9 +(cd "$root/upwork" && git tag v0.1.10 main && git push -q https://github.com/up/t3code.git v0.1.10) +new_fork v0.1.10 +expect_refused "resolve: refuses a base above upstream's latest release" "above up/t3code's latest" "$root/work" resolve +echo v0.1.10 > "$root/api/upstream_latest" +expect_resolved "resolve: accepts it once upstream marks it latest" 0.1.10-fork.1 + +reset_api v0.1.10 +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10-fork.3"}]' > "$root/api/releases.json" +new_fork v0.1.9 +expect_refused "resolve: refuses a build below a published release (older ref after a newer one)" \ + "v0.1.10-fork.3 is published and not below 0.1.9-fork.1" "$root/work" resolve +echo '[{"id":5,"draft":true,"prerelease":false,"tag_name":"v0.1.10-fork.3"}]' > "$root/api/releases.json" +expect_resolved "resolve: a higher draft does not block (updaters skip drafts)" 0.1.9-fork.1 + +# --- remove-drafts ----------------------------------------------------------------------------- +new_upstream +reset_api +new_fork v0.1.9 +echo '[{"id":7,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.1"}, + {"id":8,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.9"}]' > "$root/api/releases.json" +if run "$root/work" remove-drafts v0.1.9-fork.1 && grep -qx "deleted 7" "$root/api/writes" \ + && ! grep -q "deleted 8" "$root/api/writes" && grep -q "Removed leftover draft 7" "$root/out"; then + pass "remove-drafts: deletes only that tag's draft and says so" +else + fail "remove-drafts: deletes only that tag's draft and says so" +fi + +reset_api +touch "$root/api/fail_list" +expect_refused "remove-drafts: a failed release listing fails the step" "Could not list the releases" "$root/work" remove-drafts v0.1.9-fork.1 +[[ ! -e "$root/api/writes" ]] || fail "remove-drafts: nothing written after a failed listing" + +reset_api +fork_tag v0.1.9-fork.1 +expect_refused "remove-drafts: refuses a version whose tag exists (re-run failed jobs)" "already exists" \ + "$root/work" remove-drafts v0.1.9-fork.1 + +reset_api +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +expect_refused "remove-drafts: refuses a version with a published release" "published release" \ + "$root/work" remove-drafts v0.1.9-fork.2 + +# --- publish ----------------------------------------------------------------------------------- +reset_api +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +if run "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 && grep -qx "patched 9 draft=false make_latest=true" "$root/api/writes" \ + && grep -qx "published=true" "$root/out"; then + pass "publish: a version above every release is marked latest" +else + fail "publish: a version above every release is marked latest" +fi + +reset_api +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +expect_refused "publish: refuses a version below a published release (server takes the newest created)" \ + "v0.1.10-fork.1 is published and not below 0.1.9-fork.2" "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 +[[ ! -e "$root/api/writes" ]] || fail "publish: nothing written for an older version" + +reset_api +echo '[{"id":5,"draft":false,"prerelease":true,"tag_name":"v0.1.10-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +expect_refused "publish: a higher prerelease also blocks (the server ignores the prerelease flag)" \ + "not below" "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 + +reset_api +echo '[{"id":4,"draft":false,"prerelease":false,"tag_name":"v0.2.0-nightly.20261101.3"}, + {"id":6,"draft":false,"prerelease":false,"tag_name":"notes"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +if run "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 && grep -qx "patched 9 draft=false make_latest=true" "$root/api/writes"; then + pass "publish: nightly and non-version tags are other channels and do not block" +else + fail "publish: nightly and non-version tags are other channels and do not block" +fi + +reset_api +echo '[{"id":4,"draft":false,"prerelease":false,"tag_name":"v0.1.8-rc.1"}]' > "$root/api/releases.json" +expect_refused "publish: a stable-channel tag it cannot order fails loudly" "cannot be ordered" \ + "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 + +reset_api +echo '[{"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.1"}]' > "$root/api/releases.json" +expect_refused "publish: re-checks the tag right before un-drafting" "already exists" "$root/work" publish 9 v0.1.9-fork.1 0.1.9-fork.1 +[[ ! -e "$root/api/writes" ]] || fail "publish: nothing written when the tag exists" + +reset_api +touch "$root/api/fail_list" +expect_refused "publish: a failed release listing fails the step" "Could not list the releases" "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 +[[ ! -e "$root/api/writes" ]] || fail "publish: nothing written after a failed listing" + +# --- check-archives ---------------------------------------------------------------------------- +# Layout of scripts/build-cli-archive.ts: /t3 (t3.exe in the Windows zip) plus /client/. +# The web client names this fork on every Actions build (upstream's apps/web/vite.config.ts), so it +# must not count; only the program does. +stage() { # stage DIR STEM PROGRAM PROGRAM_REPO CLIENT_REPO + mkdir -p "$1/$2/client/assets" + printf 'x\0const repo = "%s";\0' "$4" > "$1/$2/$3" + printf 'const u = "%s";' "$5" > "$1/$2/client/assets/index.js" +} +pack() { # pack DIR STEM OUT + case "$3" in + *.tar.gz) tar -czf "$3" -C "$1" "$2" ;; + *.zip) (cd "$1" && python3 -I -m zipfile -c "$3" "$2") ;; + esac +} +lin=t3-0.1.9-fork.1-linux-x64 win=t3-0.1.9-fork.1-win-x64 +mkdir -p "$root/assets-good" "$root/assets-bad" "$root/assets-repro" "$root/assets-noprog" "$root/assets-none" +stage "$root/arch/good" "$lin" t3 me/t3code me/t3code +stage "$root/arch/good" "$win" t3.exe me/t3code me/t3code +pack "$root/arch/good" "$lin" "$root/assets-good/$lin.tar.gz" +pack "$root/arch/good" "$win" "$root/assets-good/$win.zip" +cp "$root/assets-good/$lin.tar.gz" "$root/assets-bad/" +stage "$root/arch/bad" "$win" t3.exe up/t3code up/t3code +pack "$root/arch/bad" "$win" "$root/assets-bad/$win.zip" +# The review's repro: t3 names upstream, the web client names the fork. +stage "$root/arch/repro" "$lin" t3 up/t3code me/t3code +pack "$root/arch/repro" "$lin" "$root/assets-repro/$lin.tar.gz" +# No program at all, but the client names the fork. +mkdir -p "$root/arch/noprog/$lin/client" && printf '"me/t3code"' > "$root/arch/noprog/$lin/client/x.js" +printf '"me/t3code"' > "$root/arch/noprog/$lin/t3.sh" +pack "$root/arch/noprog" "$lin" "$root/assets-noprog/$lin.tar.gz" +if run "$root" check-archives "$root/assets-good"; then pass "check-archives: archives whose program names this fork pass"; else fail "check-archives: archives whose program names this fork pass"; fi +expect_refused "check-archives: an archive naming upstream fails" "$win/t3.exe in $win.zip does not name me/t3code" "$root" check-archives "$root/assets-bad" +expect_refused "check-archives: t3 names upstream, client/ names the fork -> fails" "$lin/t3 in $lin.tar.gz does not name me/t3code" "$root" check-archives "$root/assets-repro" +expect_refused "check-archives: a missing program fails" "has no $lin/t3" "$root" check-archives "$root/assets-noprog" +expect_refused "check-archives: no archive at all fails" "No CLI archive" "$root" check-archives "$root/assets-none" + +# --- check-bundle (apps/server/dist with the web client in dist/client) ------------------------ +bundle() { # bundle NAME SERVER_REPO CLIENT_REPO + mkdir -p "$root/dist-$1/client/assets" + printf 'const repo = "%s";' "$2" > "$root/dist-$1/bin.mjs" + printf 'const u = "%s";' "$3" > "$root/dist-$1/client/assets/index.js" +} +bundle good me/t3code me/t3code +bundle repro up/t3code me/t3code +mkdir -p "$root/dist-clientonly/client" && printf '"me/t3code"' > "$root/dist-clientonly/client/x.js" +if run "$root" check-bundle "$root/dist-good"; then pass "check-bundle: a server bundle naming this fork passes"; else fail "check-bundle: a server bundle naming this fork passes"; fi +expect_refused "check-bundle: server names upstream, client/ names the fork -> fails" "outside client/) does not name me/t3code" "$root" check-bundle "$root/dist-repro" +expect_refused "check-bundle: only client/ files -> fails" "no files outside client/" "$root" check-bundle "$root/dist-clientonly" +expect_refused "check-bundle: a missing bundle fails" "No server bundle" "$root" check-bundle "$root/dist-missing" + +# --- version order ----------------------------------------------------------------------------- +check_gt() { + if (cd "$root" && "$script" version-gt "$1" "$2") 2> /dev/null; then got=gt; else got=not; fi + if [[ "$got" == "$3" ]]; then pass "version-gt $1 $2 is $3"; else fail "version-gt $1 $2 is $3"; fi +} +check_gt 0.1.9-fork.10 0.1.9-fork.9 gt +check_gt v0.1.10-fork.1 v0.1.9-fork.12 gt +check_gt 0.1.9-fork.2 0.1.9-fork.2 not +check_gt 0.1.9-fork.2 0.2.0-fork.1 not +check_gt v1.0.0 v0.1.10 gt + +if (( failures )); then + echo "$failures failed" + exit 1 +fi +echo "all passed" diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000000..fb7e08d5ae4d1 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,530 @@ +name: Fork release + +# Builds this fork's release `v-fork.`: upstream's tests, the Linux x64 server archive +# with SHA256SUMS, and the desktop apps (Linux x64, Windows x64, macOS arm64 and x64), all on +# GitHub-hosted runners and unsigned unless signing secrets exist. is the newest stable +# upstream tag the commit contains; counts the fork's earlier builds of it. +# +# Builds name this repository as their release repository (scripts/lib/release-repository.ts reads +# T3CODE_DESKTOP_UPDATE_REPOSITORY, else GITHUB_REPOSITORY in Actions), so the server's update and +# the desktop app's updater look only at this fork. Publishing makes a normal (not pre-) release, a +# draft until every file is uploaded, and only above every published release: the server's updater +# takes the newest-created release, so an older one published later would read as a downgrade. +# +# The guards live in .github/scripts/fork-release.sh (tests: fork-release.test.sh and +# fork-release-workflow.test.py, both run in preflight): only released upstream code, never a +# version that is already published or below a published one, no silent API failures. Actions are +# pinned by commit. Only the publish job holds a write token; it runs no package code, and its guard +# script comes from this workflow's own commit, not from the commit being built. +on: + workflow_dispatch: + inputs: + ref: + description: Commit or branch to build + required: false + default: main + type: string + publish: + description: Publish the release (off builds and keeps the files on the run only) + required: false + default: false + type: boolean + workflow_call: + inputs: + ref: + required: true + type: string + publish: + required: false + default: false + type: boolean + # Only the signing secrets release-desktop.yml declares; a caller passes these, never others. + secrets: + CSC_LINK: + required: false + CSC_KEY_PASSWORD: + required: false + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false + outputs: + version: + value: ${{ jobs.preflight.outputs.version }} + tag: + value: ${{ jobs.preflight.outputs.tag }} + published: + value: ${{ jobs.publish.outputs.published == 'true' }} + +concurrency: + group: fork-release + cancel-in-progress: false + +permissions: + # The Windows desktop job reads this run's artifact list to wait for the Linux CLI archive. + actions: read + contents: read + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + ref: ${{ steps.meta.outputs.ref }} + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + base_tag: ${{ steps.meta.outputs.base_tag }} + upstream: ${{ steps.meta.outputs.upstream }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Test the release guards + shell: bash + run: bash .github/scripts/fork-release.test.sh + + - name: Test the workflow's shape + shell: bash + run: | + set -euo pipefail + python3 -c 'import yaml' 2> /dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + python3 .github/scripts/fork-release-workflow.test.py + + # Refuses unreleased upstream commits, a stray upstream tag and a base above upstream's latest. + - id: meta + name: Resolve fork release version + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/fork-release.sh resolve + + tests: + name: Tests + needs: [preflight] + uses: ./.github/workflows/fork-tests.yml + with: + ref: ${{ needs.preflight.outputs.ref }} + + build_bundle: + name: Build JS bundle + needs: [preflight] + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + persist-credentials: false + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... + + - name: Align package versions to release version + shell: bash + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: node scripts/update-release-package-versions.ts "$VERSION" + + - name: Check bundle-only build support + id: bundle_build + shell: bash + run: | + set -euo pipefail + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + + - name: Build JS bundle + shell: bash + env: + T3CODE_TELEMETRY_CHANNEL: dev + T3CODE_DESKTOP_UPDATE_REPOSITORY: ${{ github.repository }} + run: vp run ${{ steps.bundle_build.outputs.script }} + + # A build that still names upstream would update a fork install to upstream's releases. This + # is the server bundle without dist/client (the web client names this repository on every + # Actions build); the assemble job checks the `t3` program in each CLI archive. + - name: Check the server bundle names this repository + shell: bash + run: bash .github/scripts/fork-release.sh check-bundle apps/server/dist + + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron + if-no-files-found: error + retention-days: 3 + + desktop_linux_x64: + name: Desktop Linux x64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: Linux x64 + runner: ubuntu-latest + platform: linux + target: AppImage + arch: x64 + rust_target: x86_64-unknown-linux-gnu + resource_key: linux-x64 + cli_archive: true + launch_check: true + + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: Windows x64 + runner: ubuntu-latest + verify_runner: windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: true + launch_check: false + + desktop_mac_arm64: + name: Desktop macOS arm64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: macOS arm64 + runner: ${{ vars.FORK_MACOS_RUNNER || 'macos-26' }} + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + launch_check: true + + desktop_mac_x64: + name: Desktop macOS x64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: macOS x64 + runner: ${{ vars.FORK_MACOS_RUNNER || 'macos-26' }} + platform: mac + target: dmg + arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 + cli_archive: false + launch_check: false + + # Collects and checks every release file with a read-only token; package code runs here only. + assemble: + name: Assemble release files + needs: + [preflight, tests, desktop_linux_x64, desktop_win_x64, desktop_mac_arm64, desktop_mac_x64] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/scripts... + + - name: Download desktop builds + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-* + merge-multiple: true + path: release-assets + + - name: Download CLI archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cli-* + merge-multiple: true + path: release-assets + + - name: Write CLI archive checksums + shell: bash + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + cd release-assets + shopt -s nullglob + archives=(t3-*.tar.gz t3-*.zip) + if [[ ! -f "t3-$VERSION-linux-x64.tar.gz" ]]; then + echo "::error::The Linux x64 server archive is missing." >&2 + exit 1 + fi + sha256sum "${archives[@]}" > SHA256SUMS + cat SHA256SUMS + + # The `t3` executable in each archive bundles the server again (release-desktop.yml build-exe). + - name: Check the CLI archives name this repository + shell: bash + run: bash .github/scripts/fork-release.sh check-archives release-assets + + - name: Merge macOS updater manifests + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + for x64_manifest in release-assets/*-mac-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" + rm -f "$x64_manifest" + fi + done + + - name: Name the Windows updater manifest + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + for x64_manifest in release-assets/*-win-x64.yml; do + mv "$x64_manifest" "${x64_manifest%-win-x64.yml}.yml" + done + + # The desktop app on each platform reads one of these from the latest release. + - name: Check updater manifests + shell: bash + run: | + set -euo pipefail + for manifest in latest.yml latest-mac.yml latest-linux.yml; do + if [[ ! -f "release-assets/$manifest" ]]; then + echo "::error::release-assets/$manifest is missing, so that platform's desktop app would find no update." + exit 1 + fi + done + + - name: Write release notes + shell: bash + env: + UPSTREAM: ${{ needs.preflight.outputs.upstream }} + BASE_TAG: ${{ needs.preflight.outputs.base_tag }} + REF: ${{ needs.preflight.outputs.ref }} + run: | + set -euo pipefail + git fetch --no-tags "https://github.com/$UPSTREAM.git" "refs/tags/$BASE_TAG:refs/upstream/$BASE_TAG" + { + echo "Upstream [$BASE_TAG](https://github.com/$UPSTREAM/releases/tag/$BASE_TAG) with this fork's changes on top:" + echo + git log --reverse --format='- %s (%h)' "refs/upstream/$BASE_TAG..$REF" + } > release-assets/release-notes.md + cat release-assets/release-notes.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload release files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-files + path: release-assets + if-no-files-found: error + retention-days: 7 + + # The only job with a write token: it checks out just the guard script, from this workflow's own + # commit (a caller's commit for workflow_call), and runs no package code. + publish: + name: Publish release + needs: [preflight, assemble] + if: ${{ inputs.publish }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + outputs: + published: ${{ steps.publish.outputs.published }} + steps: + - name: Checkout the guard script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: true + + - name: Download release files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-files + path: release-assets + + - name: Move the release notes aside + shell: bash + run: mv release-assets/release-notes.md release-notes.md + + # "Re-run failed jobs" reuses the preflight's version: refuse it once a tag or published + # release has it. Drafts have no tag; one left by a stopped run of this version is removed. + - name: Refuse a published version, remove leftover drafts + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.preflight.outputs.tag }} + run: bash .github/scripts/fork-release.sh remove-drafts "$TAG" + + - name: Create the release as a draft + id: draft + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: T3 Code ${{ needs.preflight.outputs.tag }} + body_path: release-notes.md + draft: true + prerelease: false + fail_on_unmatched_files: true + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + release-assets/*.tar.gz + release-assets/SHA256SUMS + + # Updaters skip drafts, so nobody sees a release until every file above is uploaded. The tag + # and "nothing published is as high" checks run again right before; it is then made latest. + - name: Publish the draft + id: publish + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.draft.outputs.id }} + TAG: ${{ needs.preflight.outputs.tag }} + VERSION: ${{ needs.preflight.outputs.version }} + run: bash .github/scripts/fork-release.sh publish "$RELEASE_ID" "$TAG" "$VERSION" diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml new file mode 100644 index 0000000000000..199b43a462e8b --- /dev/null +++ b/.github/workflows/fork-tests.yml @@ -0,0 +1,132 @@ +name: Fork tests + +# Upstream's release checks and tests (release.yml: quality, test, test_web, test_server) on +# GitHub-hosted runners, so a fork without upstream's runners can run them. Keep the commands in +# step with release.yml; only the runners and time limits differ. +on: + workflow_call: + inputs: + ref: + description: Commit to test + required: true + type: string + workflow_dispatch: + inputs: + ref: + description: Commit or branch to test + required: true + type: string + +permissions: + contents: read + +jobs: + quality: + name: Quality checks + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Check + run: vp check + + - name: Typecheck + run: vp run typecheck + + test: + name: Package tests + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential + + - name: Test + run: vp run --parallel --concurrency-limit 2 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + + test_web: + name: Web tests + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Install Chromium for web browser tests + run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + + - name: Test + run: vp run --filter @t3tools/web test + + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + + test_server: + name: Server tests ${{ matrix.shard }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + # Eight shards share one install; one saves the store. + save-pnpm-store: ${{ matrix.shard == 1 }} + + - name: Install Chromium for server browser integration tests + run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + + - name: Test + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}