From 0730d413d363230399b3980a082ef0f0fda8bbc8 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 10:46:58 -0400 Subject: [PATCH 1/2] Move realm sync deployment tracking into a composite action sync-workspace.yml, and boxel-catalog's staging and production deploys, each carried their own copy of the shell that finds the revisions a realm may hold, records the GitHub deployment, and builds the `boxel realm push` arguments. The copies had started to drift. realm-deployment/start finds the revisions (with an optional known revision and a require-revisions guard for the production catalog), builds the --changed-since / --no-realm-events arguments from the CLI's --help, and records an in-progress deployment. realm-deployment/finish marks it success or failure. Composite actions have no post step, so the finish is its own action. sync-workspace.yml uses both by name at @main, since a reusable workflow resolves `./` actions against the caller's checkout. start.sh is tested against stubbed gh and boxel and a scratch repo, run by a path-gated self-test workflow. Co-Authored-By: Claude Opus 5.5 --- .../realm-deployment/finish/action.yml | 42 +++++ .github/actions/realm-deployment/start.sh | 96 ++++++++++ .../actions/realm-deployment/start.test.sh | 141 +++++++++++++++ .../actions/realm-deployment/start/action.yml | 115 ++++++++++++ .github/workflows/realm-deployment-test.yml | 26 +++ .github/workflows/sync-workspace.yml | 171 +++--------------- 6 files changed, 446 insertions(+), 145 deletions(-) create mode 100644 .github/actions/realm-deployment/finish/action.yml create mode 100755 .github/actions/realm-deployment/start.sh create mode 100755 .github/actions/realm-deployment/start.test.sh create mode 100644 .github/actions/realm-deployment/start/action.yml create mode 100644 .github/workflows/realm-deployment-test.yml diff --git a/.github/actions/realm-deployment/finish/action.yml b/.github/actions/realm-deployment/finish/action.yml new file mode 100644 index 00000000000..4504ebc0249 --- /dev/null +++ b/.github/actions/realm-deployment/finish/action.yml @@ -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..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}`, + }); diff --git a/.github/actions/realm-deployment/start.sh b/.github/actions/realm-deployment/start.sh new file mode 100755 index 00000000000..340cc87c0bf --- /dev/null +++ b/.github/actions/realm-deployment/start.sh @@ -0,0 +1,96 @@ +#!/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" + +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) +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" diff --git a/.github/actions/realm-deployment/start.test.sh b/.github/actions/realm-deployment/start.test.sh new file mode 100755 index 00000000000..26a4f785113 --- /dev/null +++ b/.github/actions/realm-deployment/start.test.sh @@ -0,0 +1,141 @@ +#!/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 \n --no-realm-events' + +failures=0 +# run , 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 " +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 "" "" + +if [ "$failures" -ne 0 ]; then + echo "$failures failure(s)" + exit 1 +fi diff --git a/.github/actions/realm-deployment/start/action.yml b/.github/actions/realm-deployment/start/action.yml new file mode 100644 index 00000000000..4ceea909221 --- /dev/null +++ b/.github/actions/realm-deployment/start/action.yml @@ -0,0 +1,115 @@ +name: Start a realm deployment +description: | + Prepares a `boxel realm push` from a git checkout and records it as a GitHub + deployment. It finds the revisions the realm may hold, from the deployments + recorded for the environment, builds the push arguments that skip files + unchanged against all of them, and records an in-progress deployment for + this run. realm-deployment/finish marks that deployment succeeded or failed. + + Run it after the checkout (with `fetch-depth: 0`, so the history reaches + those revisions) and the boxel-cli install, and inside a concurrency group + for the environment: two overlapping syncs could finish out of order and + leave the realm holding the older revision while the newer deployment is + recorded as the one it got. It has to run before the push, so the + deployment it records isn't among the revisions it reads. + + The caller splices `push-args` into the push: + + read -ra push_args <<< "$PUSH_ARGS" + boxel realm push . "$REALM_URL" --delete "${push_args[@]}" + + The token needs `deployments: write`. + +inputs: + environment: + description: The deployment environment, such as `staging` or `production`. + required: true + realm-url: + description: The realm the push writes to, recorded as the environment URL. + required: true + description: + description: The deployment's description. Empty describes it by the realm URL. + default: "" + ref: + description: The revision being deployed. Empty is the commit the run is for. + default: "" + payload: + description: A JSON object recorded as the deployment's payload. + default: "{}" + known-revision: + description: | + A revision the realm is known to hold, compared against along with the + recorded deployments. With it, the push compares against it even when no + recorded deployment succeeded. + default: "" + require-revisions: + description: | + When true, fail instead of sending every file when there is no revision + to compare against or the boxel-cli has no `--changed-since`. Nothing is + recorded then. + default: "false" + repository-path: + description: The checkout the push sends, where the revisions have to be. + default: "." + token: + description: "A token with `deployments: write` on the repository." + default: ${{ github.token }} + +outputs: + revisions: + description: Space-separated revisions the realm may hold. Empty when unknown. + value: ${{ steps.revisions.outputs.revisions }} + push-args: + description: Space-separated arguments for `boxel realm push`. + value: ${{ steps.revisions.outputs.push-args }} + deployment-id: + description: The recorded deployment, for realm-deployment/finish. + value: ${{ steps.deployment.outputs.id }} + +runs: + using: composite + steps: + - name: Find the revisions the realm may hold + id: revisions + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + ENVIRONMENT: ${{ inputs.environment }} + KNOWN_REVISION: ${{ inputs.known-revision }} + REQUIRE_REVISIONS: ${{ inputs.require-revisions }} + REPOSITORY_PATH: ${{ inputs.repository-path }} + run: bash "$GITHUB_ACTION_PATH/../start.sh" + + - name: Record the deployment + id: deployment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ENVIRONMENT: ${{ inputs.environment }} + REALM_URL: ${{ inputs.realm-url }} + DESCRIPTION: ${{ inputs.description }} + REF: ${{ inputs.ref }} + PAYLOAD: ${{ inputs.payload }} + with: + github-token: ${{ inputs.token }} + script: | + const { data } = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: process.env.REF || context.sha, + environment: process.env.ENVIRONMENT, + description: process.env.DESCRIPTION || `Sync to ${process.env.REALM_URL}`, + payload: JSON.parse(process.env.PAYLOAD), + required_contexts: [], + auto_merge: false, + transient_environment: false, + production_environment: process.env.ENVIRONMENT === 'production', + }); + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: data.id, + state: 'in_progress', + environment_url: process.env.REALM_URL, + log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + }); + core.setOutput('id', String(data.id)); diff --git a/.github/workflows/realm-deployment-test.yml b/.github/workflows/realm-deployment-test.yml new file mode 100644 index 00000000000..ddedd2e57c4 --- /dev/null +++ b/.github/workflows/realm-deployment-test.yml @@ -0,0 +1,26 @@ +name: Realm Deployment Self-Test + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/actions/realm-deployment/**" + - ".github/workflows/realm-deployment-test.yml" + +permissions: + contents: read + +jobs: + start: + name: Finds the revisions a realm may hold and the push arguments + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Runs against a scratch repository with `gh` and `boxel` stubbed, so it + # reads no real deployments and pushes nothing. + - name: Test start.sh + run: bash .github/actions/realm-deployment/start.test.sh + + - name: Shellcheck + run: shellcheck .github/actions/realm-deployment/*.sh diff --git a/.github/workflows/sync-workspace.yml b/.github/workflows/sync-workspace.yml index e993c32f7e2..fd2d42bbe6d 100644 --- a/.github/workflows/sync-workspace.yml +++ b/.github/workflows/sync-workspace.yml @@ -10,8 +10,8 @@ name: Sync to Boxel workspace (reusable) # # Each push to staging or production is recorded as a GitHub deployment # to that environment in the caller's repository, and uploads only the -# files that changed since a revision the workspace may hold (see "Find -# the revisions the workspace may hold" below). The caller's token needs +# files that changed since a revision the workspace may hold (see the +# realm-deployment/start action). The caller's token needs # `deployments: write`, so a caller that declares a `permissions:` block # has to grant it. # @@ -121,84 +121,17 @@ jobs: - name: Install Boxel CLI run: npm install -g @cardstack/boxel-cli@${{ inputs.boxel-cli-version }} - # The newest deployment to the environment that succeeded names a - # revision the workspace holds in full. Each deployment recorded after - # it failed or never finished, so the workspace may hold part of its - # revision. The push skips a file only when it's unchanged against every - # one of these, so a file any of them may have written is sent again. - # With no successful deployment among the last 30, or a revision the - # checkout's history doesn't reach, the push sends every file. This runs - # before this run records its own deployment. - - name: Find the revisions the workspace may hold + # Finds the revisions the workspace may hold and records this sync as a + # deployment to the environment. The action has to come from this + # repository by name: a reusable workflow resolves a `./` action against + # the caller's workspace, which doesn't have it. + - name: Start the deployment if: github.event_name == 'push' || (github.event_name == 'release' && inputs.enable-release-sync) - id: baseline - env: - GH_TOKEN: ${{ github.token }} - ENVIRONMENT: ${{ github.event_name == 'release' && 'production' || 'staging' }} - run: | - revisions="" - succeeded=false - for row in $(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=$ENVIRONMENT&per_page=30" --jq '.[] | "\(.id):\(.sha)"'); do - id="${row%%:*}" - sha="${row#*:}" - case " $revisions " in - *" $sha "*) ;; - *) revisions="$revisions $sha" ;; - esac - if [ "$(gh api "repos/$GITHUB_REPOSITORY/deployments/$id/statuses?per_page=100" --jq 'any(.[]; .state == "success")')" = "true" ]; then - succeeded=true - break - fi - done - - if [ "$succeeded" != "true" ]; then - echo "::notice title=workspace sync::No successful $ENVIRONMENT deployment is recorded, so this sync sends every file." - revisions="" - fi - for revision in $revisions; do - if ! git cat-file -e "$revision^{commit}" 2>/dev/null; then - echo "::warning title=workspace sync::$revision, which the $ENVIRONMENT workspace may hold, isn't in this checkout's history, so this sync sends every file." - revisions="" - break - fi - done - - echo "The $ENVIRONMENT workspace may hold:${revisions:- (unknown)}" - { - echo "environment=$ENVIRONMENT" - echo "revisions=${revisions# }" - } >> "$GITHUB_OUTPUT" - - - name: Record the deployment - if: steps.baseline.outcome == 'success' id: deployment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - ENVIRONMENT: ${{ steps.baseline.outputs.environment }} - WORKSPACE_URL: ${{ steps.baseline.outputs.environment == 'production' && inputs.production-workspace-url || inputs.staging-workspace-url }} + uses: cardstack/boxel/.github/actions/realm-deployment/start@main with: - script: | - const { data } = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: context.sha, - environment: process.env.ENVIRONMENT, - description: `Sync to ${process.env.WORKSPACE_URL}`, - required_contexts: [], - auto_merge: false, - transient_environment: false, - production_environment: process.env.ENVIRONMENT === 'production', - }); - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: data.id, - state: 'in_progress', - environment_url: process.env.WORKSPACE_URL, - log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - }); - core.setOutput('id', String(data.id)); - core.setOutput('url', process.env.WORKSPACE_URL); + environment: ${{ github.event_name == 'release' && 'production' || 'staging' }} + realm-url: ${{ github.event_name == 'release' && inputs.production-workspace-url || inputs.staging-workspace-url }} - name: Dry-run sync for PR if: github.event_name == 'pull_request' && github.event.action != 'closed' && inputs.enable-pr-dry-run @@ -254,7 +187,7 @@ jobs: MATRIX_USERNAME: ${{ secrets.staging-matrix-username }} BOXEL_PASSWORD: ${{ secrets.staging-matrix-password }} WORKSPACE_URL: ${{ inputs.staging-workspace-url }} - REVISIONS: ${{ steps.baseline.outputs.revisions }} + PUSH_ARGS: ${{ steps.deployment.outputs.push-args }} run: | if echo "$MATRIX_URL" | grep -q "boxel.ai"; then DOMAIN="boxel.ai" @@ -274,33 +207,12 @@ jobs: echo "" # Skips files unchanged against every revision the workspace may - # hold. With none, every file goes up. - # A boxel-cli without --changed-since rejects it, so that one sends - # every file. - changed_since=() - push_help=$(boxel realm push --help 2>&1 || true) - if grep -q -- '--changed-since' <<< "$push_help"; then - for revision in $REVISIONS; do - changed_since+=(--changed-since "$revision") - done - elif [ -n "$REVISIONS" ]; then - echo "::notice::This boxel-cli has no --changed-since, so every file goes up." - fi - - # The push is indexed without announcing it to the workspace's open - # tabs, which pick the new content up on their next load instead of - # resetting mid-session. A boxel-cli without --no-realm-events - # rejects it, so that one announces. - realm_events=() - if grep -q -- '--no-realm-events' <<< "$push_help"; then - realm_events+=(--no-realm-events) - echo "Pushing with --no-realm-events: open tabs pick the changes up on their next load." - else - echo "::notice::This boxel-cli has no --no-realm-events, so the push is announced to open tabs." - fi + # hold, and doesn't announce the push to open tabs, when the + # boxel-cli supports it (see realm-deployment/start). + read -ra push_args <<< "$PUSH_ARGS" set +e - OUTPUT=$(boxel realm push . "${WORKSPACE_URL}" --delete "${changed_since[@]}" "${realm_events[@]}" 2>&1) + OUTPUT=$(boxel realm push . "${WORKSPACE_URL}" --delete "${push_args[@]}" 2>&1) STATUS=$? set -e echo "$OUTPUT" @@ -324,7 +236,7 @@ jobs: MATRIX_USERNAME: ${{ secrets.production-matrix-username }} BOXEL_PASSWORD: ${{ secrets.production-matrix-password }} WORKSPACE_URL: ${{ inputs.production-workspace-url }} - REVISIONS: ${{ steps.baseline.outputs.revisions }} + PUSH_ARGS: ${{ steps.deployment.outputs.push-args }} run: | if echo "$MATRIX_URL" | grep -q "boxel.ai"; then DOMAIN="boxel.ai" @@ -345,33 +257,12 @@ jobs: echo "" # Skips files unchanged against every revision the workspace may - # hold. With none, every file goes up. - # A boxel-cli without --changed-since rejects it, so that one sends - # every file. - changed_since=() - push_help=$(boxel realm push --help 2>&1 || true) - if grep -q -- '--changed-since' <<< "$push_help"; then - for revision in $REVISIONS; do - changed_since+=(--changed-since "$revision") - done - elif [ -n "$REVISIONS" ]; then - echo "::notice::This boxel-cli has no --changed-since, so every file goes up." - fi - - # The push is indexed without announcing it to the workspace's open - # tabs, which pick the new content up on their next load instead of - # resetting mid-session. A boxel-cli without --no-realm-events - # rejects it, so that one announces. - realm_events=() - if grep -q -- '--no-realm-events' <<< "$push_help"; then - realm_events+=(--no-realm-events) - echo "Pushing with --no-realm-events: open tabs pick the changes up on their next load." - else - echo "::notice::This boxel-cli has no --no-realm-events, so the push is announced to open tabs." - fi + # hold, and doesn't announce the push to open tabs, when the + # boxel-cli supports it (see realm-deployment/start). + read -ra push_args <<< "$PUSH_ARGS" set +e - OUTPUT=$(boxel realm push . "${WORKSPACE_URL}" --delete "${changed_since[@]}" "${realm_events[@]}" 2>&1) + OUTPUT=$(boxel realm push . "${WORKSPACE_URL}" --delete "${push_args[@]}" 2>&1) STATUS=$? set -e echo "$OUTPUT" @@ -389,23 +280,13 @@ jobs: # A sync that failed partway leaves the deployment marked failed, so # the next sync still compares against its revision. - - name: Finish the deployment record - if: always() && steps.deployment.outputs.id - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} - WORKSPACE_URL: ${{ steps.deployment.outputs.url }} - SYNC_OUTCOME: ${{ steps.baseline.outputs.environment == 'production' && steps.sync_production.outcome || steps.sync_staging.outcome }} + - name: Finish the deployment + if: always() && steps.deployment.outputs.deployment-id + uses: cardstack/boxel/.github/actions/realm-deployment/finish@main with: - script: | - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: Number(process.env.DEPLOYMENT_ID), - state: process.env.SYNC_OUTCOME === 'success' ? 'success' : 'failure', - environment_url: process.env.WORKSPACE_URL, - log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - }); + deployment-id: ${{ steps.deployment.outputs.deployment-id }} + outcome: ${{ github.event_name == 'release' && steps.sync_production.outcome || steps.sync_staging.outcome }} + realm-url: ${{ github.event_name == 'release' && inputs.production-workspace-url || inputs.staging-workspace-url }} - name: Compute report fields if: ${{ !cancelled() }} From 54d8c291bc09845becfa6c36acf891bced05e47d Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 10:53:57 -0400 Subject: [PATCH 2/2] Fail fast without boxel-cli; resolve the actions with $/ start.sh reads the push arguments from `boxel realm push --help`, so a missing CLI looked like an old one and the push would send every file. It now fails before listing or recording anything, with a test case. sync-workspace.yml references the actions as `$/.github/actions/...`, which resolves them at the revision of the reusable workflow the caller runs, so a caller pinned to a tag or SHA gets the matching actions rather than main's. Co-Authored-By: Claude Opus 5.5 --- .github/actions/realm-deployment/start.sh | 7 +++++++ .github/actions/realm-deployment/start.test.sh | 9 +++++++++ .github/workflows/sync-workspace.yml | 10 +++++----- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/actions/realm-deployment/start.sh b/.github/actions/realm-deployment/start.sh index 340cc87c0bf..053b78e7382 100755 --- a/.github/actions/realm-deployment/start.sh +++ b/.github/actions/realm-deployment/start.sh @@ -23,6 +23,13 @@ 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 diff --git a/.github/actions/realm-deployment/start.test.sh b/.github/actions/realm-deployment/start.test.sh index 26a4f785113..f7e35c85f5c 100755 --- a/.github/actions/realm-deployment/start.test.sh +++ b/.github/actions/realm-deployment/start.test.sh @@ -135,6 +135,15 @@ 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 diff --git a/.github/workflows/sync-workspace.yml b/.github/workflows/sync-workspace.yml index fd2d42bbe6d..f93e25241e5 100644 --- a/.github/workflows/sync-workspace.yml +++ b/.github/workflows/sync-workspace.yml @@ -122,13 +122,13 @@ jobs: run: npm install -g @cardstack/boxel-cli@${{ inputs.boxel-cli-version }} # Finds the revisions the workspace may hold and records this sync as a - # deployment to the environment. The action has to come from this - # repository by name: a reusable workflow resolves a `./` action against - # the caller's workspace, which doesn't have it. + # deployment to the environment. `$/` resolves the action in this + # repository at the revision of this workflow the caller runs, where a + # `./` path would resolve against the caller's checkout. - name: Start the deployment if: github.event_name == 'push' || (github.event_name == 'release' && inputs.enable-release-sync) id: deployment - uses: cardstack/boxel/.github/actions/realm-deployment/start@main + uses: $/.github/actions/realm-deployment/start with: environment: ${{ github.event_name == 'release' && 'production' || 'staging' }} realm-url: ${{ github.event_name == 'release' && inputs.production-workspace-url || inputs.staging-workspace-url }} @@ -282,7 +282,7 @@ jobs: # the next sync still compares against its revision. - name: Finish the deployment if: always() && steps.deployment.outputs.deployment-id - uses: cardstack/boxel/.github/actions/realm-deployment/finish@main + uses: $/.github/actions/realm-deployment/finish with: deployment-id: ${{ steps.deployment.outputs.deployment-id }} outcome: ${{ github.event_name == 'release' && steps.sync_production.outcome || steps.sync_staging.outcome }}