diff --git a/.github/workflows/install-e2e-run.yml b/.github/workflows/install-e2e-run.yml new file mode 100644 index 0000000000..354ff6bd47 --- /dev/null +++ b/.github/workflows/install-e2e-run.yml @@ -0,0 +1,122 @@ +name: Install & Update E2E (reusable) + +# Runs ONE update route against ONE starting commit, in the dev sandbox, with a +# real install (uv, a managed Python, Node, the venv) behind it. +# +# Reusable so callers can fan out over the combinations that matter -- update +# from the tip vs. from an older release, `hermes update` vs. re-running the +# installer -- without duplicating the runner setup. Each leg is independent: +# its own sandbox, its own install, nothing rewound or shared. +# +# Call it: +# +# jobs: +# tip: +# uses: ./.github/workflows/install-e2e-run.yml +# with: +# route: update +# install-ref: refs/heads/main + +on: + workflow_call: + inputs: + route: + description: 'Update path to exercise: update (hermes update) or installer (re-run install.sh).' + required: true + type: string + install-ref: + description: 'What to install before updating: a branch, a tag (v2026.7.7), or a SHA reachable from main.' + required: false + type: string + default: refs/heads/main + runner: + description: 'Runner label.' + required: false + type: string + default: ubuntu-latest + timeout-minutes: + description: 'Job timeout. A cold run installs real toolchains twice.' + required: false + type: number + default: 45 + +permissions: + contents: read + +jobs: + e2e: + name: ${{ inputs.route }} from ${{ inputs.install-ref }} + runs-on: ${{ inputs.runner }} + timeout-minutes: ${{ inputs.timeout-minutes }} + + steps: + # Full history: the sandbox fetches the starting commit and the test + # compares against this commit, so a shallow clone is not enough. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + # bubblewrap + slirp4netns are what the sandbox is built on; util-linux + # supplies the `unshare` that builds the multi-uid userns for the + # user-level (non-root) install. + - name: Install sandbox dependencies + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap slirp4netns uidmap util-linux + + # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor, + # which is exactly what bwrap needs. Report the state before touching it + # so a future runner-image change is visible in the log rather than + # silently altering what this job proves. + - name: Permit unprivileged user namespaces + run: | + set -euo pipefail + echo "--- kernel userns settings (before)" + sysctl kernel.unprivileged_userns_clone 2>/dev/null || echo " (sysctl absent)" + sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo " (sysctl absent)" + if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + echo "--- subuid/subgid for $(id -un)" + grep "^$(id -un):" /etc/subuid /etc/subgid || echo " (none — sandbox will say so)" + + - name: Run install + update E2E + run: | + set -euo pipefail + tests/install/install-update-e2e.sh \ + --route '${{ inputs.route }}' \ + --install-ref '${{ inputs.install-ref }}' + env: + # Outside the workspace on purpose: the script creates this directory + # up front, and an untracked dir inside the repo makes the worktree + # dirty -- which dev-sandbox reacts to by snapshotting the working + # copy into a fresh fake-main commit on every invocation, moving the + # update target mid-run. + HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs + + # Artifact names cannot contain '/', and install-ref may be a full ref + # like refs/heads/main. GitHub Actions expressions have no string-replace + # function, so build the safe name here. Runs even on failure -- that is + # exactly when the logs are wanted. + - name: Build artifact name + if: always() + id: artifact + run: | + set -euo pipefail + safe_ref='${{ inputs.install-ref }}' + safe_ref="${safe_ref//\//-}" + echo "name=install-e2e-${{ inputs.route }}-${safe_ref}" >> "$GITHUB_OUTPUT" + + # The installer's own transcripts say far more than the assertion that + # tripped when a real install breaks. + - name: Upload installer logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # Unique per leg: a matrix over releases runs this workflow several + # times per route, and same-named artifacts collide. + name: ${{ steps.artifact.outputs.name }}-${{ github.sha }} + path: ${{ runner.temp }}/e2e-logs + retention-days: 14 + if-no-files-found: ignore diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml new file mode 100644 index 0000000000..03c9a1d0b8 --- /dev/null +++ b/.github/workflows/install-e2e.yml @@ -0,0 +1,110 @@ +name: Install & Update E2E + +# Can a user on a released version get to this commit? +# +# For each release we sample, a leg installs that release through the real +# `curl | install.sh` one-liner (uv, a managed Python, Node, the venv) inside +# scripts/dev-sandbox.sh, then applies one update route and requires the +# checkout to land on this commit with a working `hermes`. +# +# The starting versions are chosen at runtime from the repo's release tags +# (scripts/sandbox/pick-release-tags.sh): newest, oldest, and a spread between. +# A hardcoded list would stop covering the newest release the day after it +# ships, and would pin an "oldest" that nobody still runs. +# +# Triggers: +# * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change) +# surfaces on a schedule rather than in someone's review cycle; +# * when a release tag is created -- the moment the set of versions users can +# update FROM changes, and the moment a broken updater would strand them; +# * manually, where you can pick the route and how many releases to sample. +# +# Deliberately NOT on pull_request: a leg takes ~11 minutes of real toolchain +# installation, and the matrix multiplies that. Updating is release-shaped work, +# so it is gated on releases and the clock instead. + +on: + workflow_dispatch: + inputs: + route: + description: 'Which update route to exercise.' + required: false + type: choice + default: both + options: [both, update, installer] + tag-count: + description: 'How many release tags to sample (newest, oldest, and a spread between).' + required: false + type: string + default: '5' + schedule: + # Every 12 hours, off the hour to avoid the top-of-hour runner crunch. + - cron: '20 7,19 * * *' + push: + tags: + # Release tags only: the repo also carries backup/* and one-off tags. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+.[0-9]+' + +permissions: + contents: read + +concurrency: + group: install-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Which released versions do we test updating FROM? Resolved once and shared + # by both route matrices, so the two routes cover the same set. + pick-releases: + name: Pick release tags + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tags: ${{ steps.pick.outputs.tags }} + steps: + # This job only reads tag names and runs one script, so take the cheap + # checkout: no blobs (filter), no other files (sparse), but DO fetch tags + # -- they are the whole input, and the default shallow checkout has none. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + filter: blob:none + fetch-tags: true + sparse-checkout: scripts/sandbox/pick-release-tags.sh + sparse-checkout-cone-mode: false + - id: pick + run: | + set -euo pipefail + tags="$(scripts/sandbox/pick-release-tags.sh --count '${{ inputs.tag-count || 5 }}')" + echo "Testing updates from: $tags" + echo "tags=$tags" >> "$GITHUB_OUTPUT" + + # `hermes update` -- the route most users take. + update: + if: github.event_name != 'workflow_dispatch' || inputs.route != 'installer' + needs: pick-releases + strategy: + # One release breaking is worth knowing about even if another already + # failed, so let every leg report. + fail-fast: false + matrix: + install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + uses: ./.github/workflows/install-e2e-run.yml + with: + route: update + install-ref: ${{ matrix.install-ref }} + + # Re-running the curl one-liner over an existing checkout: autostash + pull + # rather than the updater's own git handling. + installer: + if: github.event_name != 'workflow_dispatch' || inputs.route != 'update' + needs: pick-releases + strategy: + fail-fast: false + max-parallel: 3 + matrix: + install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + uses: ./.github/workflows/install-e2e-run.yml + with: + route: installer + install-ref: ${{ matrix.install-ref }}