Skip to content

fix: make repeated runs on the same runner idempotent - #217

Merged
brendaneamon merged 4 commits into
mainfrom
fix/persistent-runner-idempotency
Jul 30, 2026
Merged

fix: make repeated runs on the same runner idempotent#217
brendaneamon merged 4 commits into
mainfrom
fix/persistent-runner-idempotency

Conversation

@brendaneamon

@brendaneamon brendaneamon commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

On a runner whose disk survives between jobs, this action fails on the second run and every run after it. Two defects share one cause: the action assumes it always starts on a fresh machine, so everything it writes is written append-if-absent, a pattern that is correct exactly once.

The Flox packages ship their own Nix and symlink it into /usr/bin, so a successful first run creates /usr/bin/nix. Detection read that as a Nix the user had brought and tried to install Flox through nix profile install on a machine that already had it. Asking whether flox is present answers the question directly, so that is now the first check. When Flox is already there the installation is skipped and the new flox-preinstalled output reports it; a new force-reinstall input, or a pinned version that does not match what is installed, still installs over it.

configureNixExtra appended to /etc/nix/nix.conf and declined to write a token whenever an access-tokens line was already present. That guard exists to avoid clobbering a token the user set themselves, but on a persistent runner the line it found was this action's own, holding a token that expired the moment the earlier job ended. A stale token is worse than no token: Nix falls back to anonymous, rate-limited access when none is configured, but fails outright with HTTP error 401 when it finds one that has expired. It also explains why passing github-token explicitly made no difference, since the guard never consulted the input.

The action now owns a file under /etc/nix/ named for the job that wrote it, install-flox-action-<run>-<attempt>-<id>.conf, with one matching !include line in nix.conf, following the precedent Flox's own postinst sets with include flox.conf. Per job rather than shared, because a machine can run several jobs at once and one job's teardown must not delete a token another is still using; include lines whose file is gone get pruned, so a job killed before its post step leaves nothing behind. The optional !include form matters: a bare include of a missing file is fatal to Nix, and the file is removed at job end. An access-tokens line the user wrote themselves is still left alone, and that deference is logged, since silence there is indistinguishable from the bug being fixed: 401s, with github-token appearing to do nothing. Settings written into nix.conf by earlier versions are removed on first run, and that removal is logged too, since it is the moment a stale token stops being served.

Config writes go over stdin rather than in a command line, because the runner echoes commands into the job log and nix.conf may hold a token this action did not write. Any access-tokens value found there is registered for masking regardless.

Where configuration sits relative to installation depends on the path. On the existing-Nix path it goes first, because the flake fetch reads access-tokens from disk. On the package path it comes second: Flox's postinst writes /etc/nix/nix.conf only when it finds none (hooks/after-install.bash:78), and the defaults it puts there include the empty build-users-group a single-user install depends on.

A post step removes the config file when the job ends, so the token granted to a job does not outlive it. That file has to stay world-readable for Nix to read it, and on a shared runner anything running in the meantime can read it too.

rpm refuses to install over a package already present, which the reinstall paths now reach, so the rpm branch uses -U --replacepkgs. --oldpackage is deliberately absent: installing an older flox over a newer one is refused before anything is installed. Flox brings its own Nix, a Nix store migrates only forward, and no package manager declines the swap on those grounds, so the install would otherwise succeed and the machine would break later at first use with nothing tying it to the pin. A reference with no version ordering, a commit-hash channel for instance, cannot be judged either way and proceeds with a warning.

configureNixSubstituter is removed: it was exported and unit-tested but never called, and its substituters are supplied directly as flags by installViaExistingNix and through inputs by configureNixExtra.

Fixes #191.

How to review this

Read the four commits in order. The repo is rebase-only, so they land on
main individually and each stands on its own.

fix(runners) The fix, and the only commit with shipped behavior in it. src/main.js checks flox before nix; src/nixconf.js owns the config file; src/cleanup.js is the post step; scripts/install-flox.sh gets the rpm flags
test(runners) The test-repeat-run CI job and the container harnesses
docs(readme) The self-hosted section of the README
test(verification) Moves the by-hand checks into verification/, with a README

Two directories, deliberately. scripts/install-flox.sh is shipped code the
action executes at runtime. Everything under verification/ is a developer tool
CI never runs. They were previously mixed with nothing marking which was which.

The reviewable surface is smaller than the diff suggests. Most of the
non-generated lines are tests and harnesses; dist/index.js is generated by
ncc and not worth reading.

Run the checks yourself. Docker is all the first two need:

./verification/persistent-runner.sh main   # reproduces the failure
./verification/persistent-runner.sh        # shows the fix
./verification/rpm-reinstall.sh

The first runs the action twice against one long-lived container, which is a
persistent runner for these purposes; the git ref argument is how two revisions
are compared. The second covers fresh install, same-version reinstall, and that a
downgrade is refused, exercising the store after each rather than reading
flox --version, and reading the rpm command out of install-flox.sh so the
check cannot drift from what the action runs.

For a genuine runner, with a scratch repository and a registration token:

GITHUB_REPOSITORY=you/scratch RUNNER_TOKEN=$(gh api -X POST \
  repos/you/scratch/actions/runners/registration-token --jq .token) \
  ./verification/self-hosted-runner.sh

Then copy verification/self-hosted-compare.yml into that repository's
.github/workflows/ and dispatch it: two refs, twice each, chained so they stay
in order on the one machine, with state printed before and after every run. Use a
scratch repository, since the runner accepts any job that repository schedules.

Why any of this is by hand. CI cannot see three things, and this bug fell
into all of them: hosted runners never keep a filesystem between jobs, the matrix
never executes the rpm branch, and a job token cannot be watched expiring into a
later job. verification/README.md covers each in detail.

Test plan

  • npm run all passes: formatting, unit tests, and the bundle
  • Unit tests cover each detection branch, the token rewrite, the legacy strip, and the include line
  • Local reproduction fails against main and passes here, with nix.conf keeping the defaults Flox's postinst writes
  • verification/rpm-reinstall.sh passes on x86_64 and aarch64: fresh install, same-version reinstall, downgrade refused, store usable after each
  • test-repeat-run passes on ubuntu-latest, ubuntu-slim, and macos-latest
  • test-existing-nix still passes for both the Determinate Systems and Cachix installers, covering the foreign-Nix path
  • Confirmed on a real self-hosted runner, where the shipped v2.5.3 produced the 401 from its own expired token and this branch healed the machine (detail)

@github-actions github-actions Bot added the team-developer-support Work related to the developer support team label Jul 29, 2026
@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch from 9dc3b99 to fe6fcc2 Compare July 29, 2026 17:24
@brendaneamon
brendaneamon marked this pull request as ready for review July 29, 2026 17:30
@brendaneamon brendaneamon changed the title fix(runners): stop mistaking Flox's own Nix for a foreign one fix: make repeated runs on the same runner idempotent Jul 29, 2026
@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch 2 times, most recently from c47d028 to e9a1219 Compare July 29, 2026 17:57
@brendaneamon

Copy link
Copy Markdown
Member Author

PR review by Claudius (claude-fable-5):

note: The test plan's last open item, reproduction on an actual self-hosted runner, is now done, against head e9a1219, and the fix behaves exactly as designed. The setup was a real GitHub Actions runner (ghcr.io/actions/actions-runner in a long-lived container, registered to a scratch repository, aarch64 Linux) whose filesystem persists across jobs. Nothing was planted: the broken state was created by the shipped v2.5.3, and the recorded token expired the way it does in production, when the job that owned it completed.

Four sequential jobs ran on that one machine:

  1. v2.5.3 on the clean machine installs and works, and appends the job's token directly into /etc/nix/nix.conf beneath the # Added by install-flox-action marker, as the old code did.
  2. v2.5.3 a second time fails precisely as issue Fails when run twice on self-hosted runner #191 and the support threads describe: Nix found at /usr/bin/nix, then nix profile install dies with HTTP error 401 / Bad credentials on the previous job's expired token. The genuine failure, genuinely reproduced.
  3. This branch, against that inherited state: installation skipped (flox-preinstalled: true), the legacy block stripped from nix.conf, a fresh token recorded in /etc/nix/install-flox-action.conf, exactly one !include line, and flox init, flox install hello, and flox activate -- hello all succeed. The post step removed the token file when the job ended.
  4. This branch again: the token file was confirmed absent at job start, installation was skipped again, the include line did not duplicate, and the token on disk is the current job's.

One provisioning note for self-hosted operators: the flox deb pre-depends on xz-utils, which GitHub's hosted images include but a minimal runner may not; the first attempt failed on exactly that until the machine was provisioned. Worth a line in the README's self-hosted section if you are inclined.

Separately, scripts/check-rpm-reinstall.sh was run from this head on both x86_64 and aarch64 Rocky Linux 9 containers: fresh install, same-version reinstall, pinned downgrade, and upgrade back all pass under rpm -U --replacepkgs --oldpackage --notriggers.

@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch from e9a1219 to 9380cbc Compare July 29, 2026 18:16
@brendaneamon
brendaneamon requested review from devusb and garbas July 29, 2026 18:18
@brendaneamon brendaneamon self-assigned this Jul 29, 2026
@brendaneamon brendaneamon added the bug Something isn't working label Jul 29, 2026
@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch 2 times, most recently from dbda4c1 to 4b40be4 Compare July 29, 2026 18:25
@brendaneamon
brendaneamon enabled auto-merge (rebase) July 29, 2026 18:31
@brendaneamon
brendaneamon disabled the stack merge July 29, 2026 18:40
@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch 2 times, most recently from a550a2e to 831768f Compare July 29, 2026 19:10
@devusb

devusb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Downgrades shouldn't be a supported path here. The PR builds them in deliberately (--oldpackage, the downgrade case in check-rpm-reinstall.sh, and the README's "pin version to reinstall whenever the pinned version is not the one already installed"), but installing an older flox in place isn't something we can really do: flox ships its own Nix, and the Nix store database schema only migrates forward. Once a newer Nix has touched /nix/var/nix/db, an older one can refuse to operate against it. The package swap itself succeeds — rpm/dpkg know nothing about /nix state — so the machine breaks later, at first use, in a way nobody will connect to the pin change. Unlike the 401 this PR fixes, that state doesn't heal on the next run.

Silently keeping the installed version would make the pin lie, so failing loudly seems right:

  • Guard before any reinstall path: when flox is present and the requested version is older than the installed one (needs a real version compare — versionSatisfies is string equality today), fail with remediation in the message, e.g. "flox 1.14.0 is installed; downgrading to 1.13.0 in place is not supported. Update the pin, or remove flox and /nix from the runner to install an older version." The guard matters most on deb and macOS, where nothing at the package level refuses a downgrade.
  • Drop --oldpackage from the rpm command. Keep --replacepkgs — same-version reinstall via force-reinstall still needs it.
  • Flip the downgrade case in check-rpm-reinstall.sh to assert refusal, and reword the README line to "when the pinned version is newer than the installed one," plus a sentence saying downgrades fail with an error.
  • A unit test for the failure branch.

One thing to decide explicitly: pins that don't parse as versions — nightly and commit-hash pins have no ordering, so the guard can't tell direction there. I'd let those through with a warning and a doc note rather than block them, but flagging it so it's a decision rather than an accident.

@devusb

devusb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

One more, on token handling: a credential an admin hand-wrote into /etc/nix/nix.conf gets printed unmasked into the job log, twice per job.

The mechanism: writeAsRoot embeds the full file content in the command string itself (sudo bash -c "cat > ... << 'FLOX_ACTION_EOF' ..."), and @actions/exec echoes the complete command line into the log unless silent is set. In steady state on a persistent runner, nix.conf is rewritten every job — the post step strips the !include line and the next main step re-adds it — so the whole file transits the log on every run.

The action's own token is fine: it's masked via core.setSecret and lives in install-flox-action.conf anyway. The exposure is credentials the action doesn't know about — an access-tokens PAT an admin put in nix.conf on a self-hosted runner (for instance, as a workaround for the very 401 this PR fixes). Nothing calls setSecret on it, so it lands in plaintext in the log, visible to anyone with log read access — a much wider audience than the machine-local world-readable file the README already documents.

Two-part fix, both cheap:

  • Move content out of argv: exec.exec('sudo', ['tee', path], { input: Buffer.from(content), silent: true }) instead of the heredoc. Content goes over stdin, nothing gets echoed. This also removes the (theoretical) heredoc-delimiter collision, where a line reading FLOX_ACTION_EOF in extra-nix-config would terminate the heredoc early and execute the remainder as root shell.
  • Belt and suspenders: when reading nix.conf, call core.setSecret on the token portion of any access-tokens line found, so the runner masks it even if the content reaches the log some other way.

Applies to both writeAsRoot call sites (migration in configureNixExtra, and the include-strip in cleanup.js).

@devusb

devusb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

One more edge case, from the #191 thread itself: the workaround posted there interacts badly with the new userSuppliedToken deference, and since everyone rides the floating @v2 tag, the upgrade lands on those runners silently.

The workaround is a step before this action that sed-deletes access-tokens lines from nix.conf and appends a fresh github.token line at end of file. Walking it forward through the upgrade:

  • First run of the new version: the workaround's line sits below a legacy marker (old versions appended one every run), so stripLegacyBlocks removes it and the action manages the token. But migration also strips all the markers, so nix.conf is now markerless.
  • If they keep the workaround: each later job appends a token to the markerless file, the action sees an access-tokens line with no marker, concludes it is user-authored, and defers. In-job everything works, but nothing cleans that line at job end — between jobs the machine permanently serves an expired token again, breaking every other Nix consumer on the box.
  • If they later remove the workaround: the last token it wrote stays in nix.conf, unmarked. The action treats it as user-owned forever, declines to write its own, and Nix uses the expired token — the 401 from Fails when run twice on self-hosted runner #191 returns in-job, permanently, with nothing to self-heal it.

The action can't distinguish a workaround line from a genuinely user-owned PAT, so I wouldn't ask for smarter stripping. Two asks instead:

  1. Log the deference decision. Today the skip is silent (the "leaves a user-authored access-tokens line alone" branch writes nothing to the log), so someone hitting the trap sees exactly the Fails when run twice on self-hosted runner #191 symptom — 401s, and passing github-token "doesn't help" — with no clue why. One line fixes it: "Found an existing access-tokens line in /etc/nix/nix.conf; leaving it in place and not writing the job token. If this is a stale workaround, remove it."
  2. Once this merges, please comment on Fails when run twice on self-hosted runner #191 letting folks know the workaround itself can now cause problems, and how to back it out: remove the workaround step from the workflow, and delete the leftover access-tokens line from /etc/nix/nix.conf on each runner. That guidance belongs in the issue thread where the workaround was published, not the README.

@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch 2 times, most recently from 607962e to d9af705 Compare July 29, 2026 22:59
The Flox packages ship their own Nix and symlink it into /usr/bin, so a
runner whose disk survives the job starts its next run with a `nix` this
action installed. Detection read that as a Nix the user had brought and
tried to install Flox through `nix profile install` on a machine that
already had it. Asking for `flox` first answers the question directly:
when it is already present the installation is skipped, and
`force-reinstall` or a pinned `version` still install over it.

Nix settings now live in `/etc/nix/install-flox-action.conf`, replaced in
full each run and pulled in by a single optional include. The token
granted to a job expires with that job, so rewriting the file is what
keeps a later run from being handed a dead one: Nix falls back to
anonymous, rate-limited access when no token is configured, but fails with
HTTP 401 when it finds one that has expired. A token the user placed in
nix.conf themselves is left alone. Settings written into nix.conf by
earlier versions are removed, and that removal is logged, since it is the
moment a stale token stops being served.

A post step deletes the file when the job ends, so the token does not
outlive the job it was granted to.

On the existing-Nix path the configuration is written before installing,
because the flake fetch needs the token. On the package path it comes
after: flox's postinst writes nix.conf only when it finds none, and the
defaults it puts there include the empty build-users-group a single-user
install depends on.

rpm refuses to install over a package already present, so the rpm path
uses `-U --replacepkgs --oldpackage`, covering a fresh install, a
reinstall, and a downgrade to a pinned version alike.

The config file is named for the job that owns it rather than shared, and its
name handed to the post step, so a machine running several jobs at once cannot
have one job's teardown delete a token another job is still using; include
lines whose file is gone are pruned, so a job killed before its post step
leaves nothing behind. Writes go over stdin rather than in a command line,
because the runner echoes commands into the job log and nix.conf may hold a
token this action did not write and cannot mask; any it finds there is
registered for masking regardless. Deferring to such a token is now logged,
since silence there is indistinguishable from the bug being fixed.

Installing a version older than the one already present now fails before
anything is installed. Flox brings its own Nix, a Nix store migrates only
forward, and a Nix older than the one that last wrote the store can refuse to
operate against it; no package manager declines the swap on those grounds, so
the install would succeed and the machine would break later at first use, with
nothing tying it to the pin. The error names the remedy: change the pin, or
remove flox and /nix and install again. `--oldpackage` is gone from the rpm
command so rpm refuses as well. A reference with no version ordering, a
commit-hash channel for instance, cannot be judged either way and proceeds with
a warning.
Neither failure can occur in this repository's CI, because every job on a
hosted runner starts on a fresh VM. One CI job now runs the action twice in
a single job, expiring the recorded token and planting a block of the older
format in between, which puts the second run in the state a runner that
keeps its disk reaches on its own.

The script does the same locally against a container that stays alive
across both runs. It takes a git ref, so a given revision can be exercised
against that state.

There is no rpm runner in the CI matrix, so the rpm branch of the install
script has a check of its own, taking the command from the script rather
than repeating it so the two cannot drift. It covers a fresh install, a
reinstall at the same version, and a downgrade.
Nothing in the README distinguished a runner that starts fresh from one
that keeps its filesystem, which is the distinction governing almost
everything the action does. The new section covers why detection looks for
flox rather than Nix, which file the action owns and why it is rewritten
rather than appended to, that a token found in nix.conf is left alone, and
that the token is removed when the job ends.

The packages pre-depend on sudo and xz, and sudo is needed whichever way
flox is installed because this action uses it to write the Nix
configuration. Hosted images carry both and a minimal self-hosted machine
may not, so the README says to provision them.

CLAUDE.md is brought up to date with the same changes.
`scripts/` held two unrelated kinds of file: `install-flox.sh`, which the
action executes at runtime, and tools a developer runs deliberately.
Nothing distinguished them. The developer tools move to `verification/`
with a README describing what each answers and what it needs;
`install-flox.sh` stays where the bundle resolves it.

The checks are there because CI has three standing blind spots rather than
because of any one bug: a hosted runner never keeps its filesystem between
jobs, the matrix never executes the rpm branch of the install script, and a
job token cannot be observed expiring into a later job. A runner-registration
script and a workflow to dispatch against it cover the third, which nothing
here could reach before.

The workflow deliberately asserts little beyond "flox still works", since
what counts as correct depends on the investigation. CLAUDE.md records the
three gaps so a green CI run is not mistaken for full coverage.

The rpm check exercises the store after every install rather than reading
`flox --version`, which passes whether or not /nix is usable and so proved
nothing about the version changes it was meant to cover. It also asserts that a
downgrade is refused rather than that it succeeds.
@brendaneamon
brendaneamon force-pushed the fix/persistent-runner-idempotency branch from d9af705 to fdb5e9e Compare July 30, 2026 09:48
@brendaneamon
brendaneamon merged commit c7abbe5 into main Jul 30, 2026
24 checks passed
@brendaneamon
brendaneamon deleted the fix/persistent-runner-idempotency branch July 30, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working team-developer-support Work related to the developer support team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fails when run twice on self-hosted runner

2 participants