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
42 changes: 42 additions & 0 deletions .github/actions/realm-deployment/finish/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Finish a realm deployment
description: |
Marks a deployment realm-deployment/start recorded as succeeded or failed,
from the outcome of the step that pushed. Run it with
`if: always() && steps.<start>.outputs.deployment-id`, so a push that failed
or never ran still marks it failed. The next sync then compares against its
revision too, since the realm may hold part of it.

inputs:
deployment-id:
description: The `deployment-id` output of realm-deployment/start.
required: true
outcome:
description: The push step's outcome. Anything but `success` marks the deployment failed.
required: true
realm-url:
description: The realm the push wrote to, recorded as the environment URL.
required: true
token:
description: "A token with `deployments: write` on the repository."
default: ${{ github.token }}

runs:
using: composite
steps:
- name: Finish the deployment record
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DEPLOYMENT_ID: ${{ inputs.deployment-id }}
OUTCOME: ${{ inputs.outcome }}
REALM_URL: ${{ inputs.realm-url }}
with:
github-token: ${{ inputs.token }}
script: |
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: Number(process.env.DEPLOYMENT_ID),
state: process.env.OUTCOME === 'success' ? 'success' : 'failure',
environment_url: process.env.REALM_URL,
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
});
103 changes: 103 additions & 0 deletions .github/actions/realm-deployment/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Finds the revisions a realm may hold, and the `boxel realm push` arguments
# that skip a file only when it's unchanged against every one of them. The
# start action runs it; its action.yml describes the inputs, which arrive here
# as environment variables. Writes `revisions` and `push-args` to
# $GITHUB_OUTPUT.
#
# The newest deployment to the environment that succeeded names a revision the
# realm holds in full. Each deployment recorded after it failed or never
# finished, so the realm may hold part of its revision too. A file any of them
# may have written is therefore sent again. KNOWN_REVISION, when set, is a
# revision the realm is known to hold, and stands in for a successful
# deployment when none is found.
#
# With no revision to compare against, or one the checkout's history doesn't
# reach, the push sends every file, unless REQUIRE_REVISIONS refuses that.

set -euo pipefail

: "${ENVIRONMENT:?}" "${GITHUB_REPOSITORY:?}" "${GITHUB_OUTPUT:?}"
REPOSITORY_PATH="${REPOSITORY_PATH:-.}"
KNOWN_REVISION="${KNOWN_REVISION:-}"
REQUIRE_REVISIONS="${REQUIRE_REVISIONS:-false}"
title="$ENVIRONMENT realm sync"

# The push arguments come from the installed boxel-cli's --help, so a missing
# CLI would read as an old one and the push would send every file.
command -v boxel > /dev/null || {
echo "::error title=$title::boxel isn't on PATH. Run realm-deployment/start after installing @cardstack/boxel-cli."
exit 1
}

revisions=""
add_revision() {
case " $revisions " in
*" $1 "*) ;;
*) revisions="${revisions:+$revisions }$1" ;;
esac
}

if [ -n "$KNOWN_REVISION" ]; then
add_revision "$KNOWN_REVISION"
fi

# A failed listing reads as no deployments, the same as a repository that has
# never recorded one.
rows=$(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=$ENVIRONMENT&per_page=30" --jq '.[] | "\(.id):\(.sha)"') || {
echo "::warning title=$title::Couldn't list the $ENVIRONMENT deployments."
rows=""
}
succeeded=false
for row in $rows; do
add_revision "${row#*:}"
if [ "$(gh api "repos/$GITHUB_REPOSITORY/deployments/${row%%:*}/statuses?per_page=100" --jq 'any(.[]; .state == "success")' || true)" = "true" ]; then
succeeded=true
break
fi
done

if [ "$succeeded" != "true" ] && [ -z "$KNOWN_REVISION" ]; then
echo "::notice title=$title::No successful $ENVIRONMENT deployment is recorded, so this sync sends every file."
revisions=""
fi
for revision in $revisions; do
if ! git -C "$REPOSITORY_PATH" cat-file -e "$revision^{commit}" 2>/dev/null; then
echo "::warning title=$title::$revision, which the $ENVIRONMENT realm may hold, isn't in this checkout's history, so this sync sends every file."
revisions=""
break
fi
done
echo "The $ENVIRONMENT realm may hold: ${revisions:-(unknown)}"

# A boxel-cli that doesn't list an option rejects it, so each one is passed
# only when `realm push --help` lists it.
push_help=$(boxel realm push --help 2>&1 || true)
Comment on lines +73 to +75

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] When boxel isn't on PATH, this probe treats it as an old CLI. push-args comes out empty and the step still succeeds, so a staging sync sends every file, with a notice that blames the CLI version. Under require-revisions, the refusal reads "No revision to compare against", which isn't the real cause either. I ran start.sh with boxel missing from PATH: it exits 0 with push-args= empty, and with REQUIRE_REVISIONS=true it exits 1 with that error.

The only thing guarding this is the "after … the boxel-cli install" line in start/action.yml. Both catalog workflows, sync-to-workspace.yml and deploy-production.yml, record their deployment before their Install Boxel CLI step. A step-for-step move onto this action lands right here: staging re-sends the whole catalog, and production refuses with a misleading message.

Please fail fast at the top of start.sh, before anything is listed or recorded:

command -v boxel > /dev/null || {
  echo "::error title=$title::boxel isn't on PATH. Run realm-deployment/start after installing @cardstack/boxel-cli."
  exit 1
}

Please also add a start.test.sh case that removes the boxel stub. (Checking the exit status of --help wouldn't work here: it would turn the intended old-CLI fallback into a failure.)

Regression in the new action. Non-blocking: sync-workspace.yml already installs the CLI before start.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 54d8c29. start.sh now checks command -v boxel before it lists or records anything. A missing CLI fails the step with "boxel isn't on PATH. Run realm-deployment/start after installing @cardstack/boxel-cli." It no longer falls through to the older-CLI path. start.test.sh has a case that runs it on a PATH with no boxel and expects exit 1 with that error.

push_args=()
if grep -q -- '--changed-since' <<< "$push_help"; then
for revision in $revisions; do
push_args+=(--changed-since "$revision")
done
elif [ -n "$revisions" ]; then
echo "::notice title=$title::This boxel-cli has no --changed-since, so every file goes up."
fi

if [ "$REQUIRE_REVISIONS" = "true" ] && [ ${#push_args[@]} -eq 0 ]; then
echo "::error title=$title::No revision to compare against, so the push would send every file."
exit 1
fi

# A sync is system-initiated, so the realm indexes it without announcing it to
# its open tabs, which pick the new content up on their next load instead of
# resetting mid-session.
if grep -q -- '--no-realm-events' <<< "$push_help"; then
push_args+=(--no-realm-events)
echo "Pushing with --no-realm-events: open tabs pick the changes up on their next load."
else
echo "::notice title=$title::This boxel-cli has no --no-realm-events, so the push is announced to open tabs."
fi

{
echo "revisions=$revisions"
echo "push-args=${push_args[*]}"
} >> "$GITHUB_OUTPUT"
150 changes: 150 additions & 0 deletions .github/actions/realm-deployment/start.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Runs start.sh against a scratch git repository, with `gh` and `boxel`
# replaced by stubs, and checks the revisions and push arguments it writes.
#
# The `gh` stub serves the deployment listing from $STUB_DIR/deployments, one
# `id:sha` per line, newest first, and reports a deployment as succeeded when
# its id is in $STUB_DIR/succeeded. The `boxel` stub's `realm push --help`
# prints $STUB_DIR/push-help.

set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

export STUB_DIR="$work/stub"
mkdir -p "$STUB_DIR/bin"
cat > "$STUB_DIR/bin/gh" <<'EOF'
#!/usr/bin/env bash
case "$2" in
*/deployments\?*)
[ -f "$STUB_DIR/list-fails" ] && exit 1
cat "$STUB_DIR/deployments" ;;
*/deployments/*/statuses*)
id="${2#*/deployments/}"
id="${id%%/*}"
if grep -qx "$id" "$STUB_DIR/succeeded"; then echo true; else echo false; fi ;;
*) echo "unexpected gh call: $*" >&2; exit 2 ;;
esac
EOF
cat > "$STUB_DIR/bin/boxel" <<'EOF'
#!/usr/bin/env bash
cat "$STUB_DIR/push-help"
EOF
chmod +x "$STUB_DIR/bin/gh" "$STUB_DIR/bin/boxel"
export PATH="$STUB_DIR/bin:$PATH"

repo="$work/repo"
git init -q "$repo"
commit() {
git -C "$repo" -c user.name=test -c user.email=test@example.com commit -q --allow-empty -m "$1"
git -C "$repo" rev-parse HEAD
}
a=$(commit a)
b=$(commit b)
c=$(commit c)
outside=0123456789abcdef0123456789abcdef01234567

export GITHUB_REPOSITORY=example/repo
export ENVIRONMENT=staging
export REPOSITORY_PATH="$repo"
both_flags=$' --changed-since <revision>\n --no-realm-events'

failures=0
# run <name> <expected exit> <expected revisions> <expected push-args>, after
# arranging the stubs with `deployments`, `succeeded` and `help`. Extra
# environment for start.sh goes before the call.
run() {
local name="$1" want_status="$2" want_revisions="$3" want_args="$4"
export GITHUB_OUTPUT="$work/output"
: > "$GITHUB_OUTPUT"
local status=0
bash "$here/start.sh" > "$work/log" 2>&1 || status=$?
local revisions push_args
revisions=$(sed -n 's/^revisions=//p' "$GITHUB_OUTPUT")
push_args=$(sed -n 's/^push-args=//p' "$GITHUB_OUTPUT")
if [ "$status" != "$want_status" ] || [ "$revisions" != "$want_revisions" ] || [ "$push_args" != "$want_args" ]; then
echo "FAIL: $name"
echo " exit: $status (want $want_status)"
echo " revisions: '$revisions' (want '$want_revisions')"
echo " push-args: '$push_args' (want '$want_args')"
sed 's/^/ | /' "$work/log"
failures=$((failures + 1))
else
echo "ok: $name"
fi
}
deployments() { printf '%s\n' "$@" > "$STUB_DIR/deployments"; }
succeeded() { printf '%s\n' "$@" > "$STUB_DIR/succeeded"; }
cli_help() { printf '%s\n' "$1" > "$STUB_DIR/push-help"; }

deployments 3:"$c" 2:"$b" 1:"$a"
succeeded 2
cli_help "$both_flags"
run "compares against the newest success and every deployment after it" 0 \
"$c $b" "--changed-since $c --changed-since $b --no-realm-events"

deployments 4:"$c" 3:"$b" 2:"$c" 1:"$a"
succeeded 1
run "lists a revision deployed twice once" 0 \
"$c $b $a" "--changed-since $c --changed-since $b --changed-since $a --no-realm-events"

deployments 2:"$c" 1:"$b"
succeeded
run "sends every file with no successful deployment" 0 "" "--no-realm-events"
grep -q 'No successful staging deployment' "$work/log" || { echo "FAIL: no notice for a missing success"; failures=$((failures + 1)); }

deployments 2:"$outside" 1:"$b"
succeeded 1
run "sends every file when a revision is outside the history" 0 "" "--no-realm-events"
grep -q "$outside, which the staging realm may hold, isn't in this checkout's history" "$work/log" || { echo "FAIL: no warning for a revision outside the history"; failures=$((failures + 1)); }

touch "$STUB_DIR/list-fails"
run "sends every file when the listing fails" 0 "" "--no-realm-events"
rm "$STUB_DIR/list-fails"

deployments
succeeded
KNOWN_REVISION="$a" run "a known revision stands in for a success" 0 \
"$a" "--changed-since $a --no-realm-events"

deployments 3:"$c" 2:"$b"
succeeded
KNOWN_REVISION="$a" run "a known revision adds the deployments that never succeeded" 0 \
"$a $c $b" "--changed-since $a --changed-since $c --changed-since $b --no-realm-events"

deployments 3:"$c" 2:"$b"
succeeded 2
cli_help ""
run "an older boxel-cli gets neither option" 0 "$c $b" ""
grep -q 'no --changed-since' "$work/log" || { echo "FAIL: no notice for a CLI without --changed-since"; failures=$((failures + 1)); }
grep -q 'no --no-realm-events' "$work/log" || { echo "FAIL: no notice for a CLI without --no-realm-events"; failures=$((failures + 1)); }

cli_help " --changed-since <revision>"
run "a boxel-cli without --no-realm-events still skips unchanged files" 0 \
"$c $b" "--changed-since $c --changed-since $b"

cli_help "$both_flags"
deployments 2:"$c"
succeeded
REQUIRE_REVISIONS=true run "require-revisions refuses to send every file" 1 "" ""

deployments 3:"$c" 2:"$b"
succeeded 2
cli_help " --no-realm-events"
REQUIRE_REVISIONS=true run "require-revisions refuses a boxel-cli without --changed-since" 1 "" ""

# PATH without the boxel stub, or any installed boxel. git and the shell
# utilities come from the system directories.
mkdir -p "$STUB_DIR/no-boxel"
ln -s "$STUB_DIR/bin/gh" "$STUB_DIR/no-boxel/gh"
deployments 3:"$c" 2:"$b"
succeeded 2
PATH="$STUB_DIR/no-boxel:/usr/bin:/bin" run "a missing boxel-cli fails before anything is recorded" 1 "" ""
grep -q "boxel isn't on PATH" "$work/log" || { echo "FAIL: no error for a missing boxel-cli"; failures=$((failures + 1)); }

if [ "$failures" -ne 0 ]; then
echo "$failures failure(s)"
exit 1
fi
Loading
Loading