Skip to content

feat(git): check git stacks on a schedule, deploy new commits, and try a deploy first (#138, part 4) - #181

Merged
mstrhakr merged 18 commits into
mstrhakr:devfrom
grantemsley:feat/git-scheduled-checks
Oct 15, 2026
Merged

mstrhakr merged 18 commits into
mstrhakr:devfrom
grantemsley:feat/git-scheduled-checks

Conversation

@grantemsley

Copy link
Copy Markdown

This is the last part I have ready for the git stacks feature. I've got a few additional feature ideas already, but don't plan to add any more to it besides any needed bug fixes until after everything has been fully reviewed and merged into release.

What. Git stacks can now be checked every 15 minutes. Per stack, choose what happens when a new commit on its
branch changes it: off (the default), notify (one Unraid notification per commit), or deploy (deployed
as Pull and Redeploy does it). It is set on the stack's Sources tab or with compose-git auto-deploy. And
compose-git deploy <stack> --dry-run runs every check a deploy runs, shows how the compose configuration
changes, and deploys nothing.

Why. This is the "poll and deploy" half of #138. The dry run lets a person or a script try a commit before
deploying it. It is a separate command: scheduled deploys do not run it first.

What it adds

  • A commit "changes" a stack when it changes the stack's own folder in the repository, so in a repository with
    one folder per stack, a commit to one stack leaves the others alone.
  • Each commit is tried once. A stack whose last deploy failed, whose clone was changed by hand, or that is not
    running is held back, with one notification saying why. A failed deploy pauses the stack until a deploy works
    or Resume Automatic Deploys (stack menu, compose-git resume) clears it.
  • Its own cron line, #compose-gitcheck, at 7 past the quarter hours, present while any stack asks for it.
    State goes in git_check.json in the stack folder, written only when it changes.
  • The stack list shows "behind" while automatic deploys are on.
  • deploy --dry-run prints variables as written (--no-interpolate --no-env-resolution), so nothing from the
    .env is printed. Exit status 0 when every check passed, 1 when one did not.
  • Two small fixes the dry run needed: reading a clone's status without git's index lock, and forgetting PHP's
    cached paths after a checkout.
1-sources-automatic-deploys 2-stack-list-behind

How to read it: in order. Each commit is one step with its tests, and the messages carry the detail.
Commits 1-8 are the automatic deploys, 9-11 a refactor and the two fixes, 12-13 the dry run. The docs are in
docs/git-stacks.md ("Automatic deploys", "What is checked before a deploy").

Testing: 55 new PHPUnit tests, and the full suite and PHPStan pass at every commit. On Unraid 7.3.2 with
local repositories:

  • notify once per commit;
  • a commit to another stack's folder ignored;
  • deploy through the background runner;
  • a failed up pausing the stack, and Resume;
  • the real cron line deploying a pushed commit;
  • the scheduled check leaving a stack alone while another operation holds it;
  • a crash-looping stack's fix deployed by the next check;
  • --dry-run checking a good commit, a bad one, an older one and a symlink out of the clone, with the clone,
    state and containers unchanged afterwards and no .env value in the output.

grantemsley and others added 13 commits October 13, 2026 12:49
…edule

git.json gets an optional autoDeploy key: what the scheduled check (next
commits) does when a new commit changes the stack. "off" does nothing and
is the default, so it is left out of the file and a stack written before
this still loads; "notify" sends a notification; "deploy" deploys it.

compose-git auto-deploy <stack> off|notify|deploy sets it, and status
shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed deploy pauses a git stack: the scheduled check (next commits)
leaves it alone until a deploy works. compose-git resume <stack> clears
the failure for someone who fixed the stack another way, by hand or with
the web UI's Update, keeping the deployed commit as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web UI's deploy turns the stack's wait-for-healthy settings into
compose.sh's --wait arguments inline; that moves into
gitDeployWaitArguments() in Helpers.php, so the scheduled deploy (next
commit) waits the same way. compose-git deploy's --wait arguments end in
the same function, so the three cannot drift. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…do not throw

withStackLock() throws when another operation holds the stack's lock.
tryStackLock() is the part that takes the lock, returning null instead,
for a caller that treats a busy stack as "try again later" rather than
as an error: the scheduled check in the next commit. withStackLock()
now uses it; nothing changes for its callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compose-git scheduled-check makes one pass over the git stacks whose
auto-deploy is not off (the next commit runs it from cron):

- It asks the remote for the branch's latest commit, once per repository,
  branch and credential in a pass, and fetches only when that is new.
- "Behind" means the new commit changes the stack's own folder in the
  repository. In a repository shared by several stacks, a commit to
  another stack's folder changes nothing here. The comparison is with the
  deployed commit or, after a failed deploy, with the commit that failed:
  that is what the containers were last started from.
- Each new commit that changes a stack is handled once: "notify" sends a
  notification; "deploy" runs compose.sh gitdeploy through
  compose_background.sh, which logs to the stack's last_cmd.log and
  notifies whether it worked.
- A commit is not deployed, and one notification says why and what to do,
  when the stack's last deploy failed, its clone has local changes or a
  commit made by hand, or it is not running (stopped, or never deployed:
  as the image auto-update does, a commit never starts a stopped stack).
  Once that is cleared (or a notify stack is switched to deploy) the
  waiting commit is handled again. A stack whose project name is not
  settled is skipped, as the image auto-update skips it.
- A scheduled deploy that fails before up (an image pull, say) is
  recorded as failed and not tried again: compose-git status shows it,
  and compose-git resume now clears it as well as a failed up, so the
  next pass tries again.
- A stack another operation holds is tried again on the next pass, also
  when nothing needs fetching: what the stack runs from is read under its
  lock, so a deploy or rollback that is running is never acted on half
  done. An unreachable repository does not stop the other stacks being
  checked.

What a pass learned is kept in git_check.json in the stack folder, written
only when it changes, so a pass with nothing new writes nothing to the
flash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k asks for it

The plugin's cron file gets a third line, marked #compose-gitcheck, that
runs compose-git scheduled-check every 15 minutes. It is there while any
git stack's auto-deploy is not off, and every rebuild of the file (image
auto-update on or off, the backup schedule, compose-git auto-deploy)
works that out again, so turning image auto-update off never removes it.

The check runs on its own, not inside AutoUpdateRunner: that runner stops
when no image auto-update is configured, and holds its own lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck is behind

- The Sources tab of a git stack gets "Automatic deploys": Off, notify me
  when a new commit changes the stack, or deploy it. It is saved at once,
  through the same code as compose-git auto-deploy, cron line included.
- After a failed deploy, the stack menu offers "Resume Automatic
  Deploys", as compose-git resume does.
- The stack list shows "behind" next to a git stack's branch and commit
  when the scheduled check last saw a newer commit that changes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The git stacks guide gets an "Automatic deploys" section: the three
choices, what "changes the stack" means in a shared repository, why a
commit is held back (a failed deploy, local changes, a stack that is not
running) and how it goes on, and the cron line. The web UI table and the
command reference gain auto-deploy, resume and scheduled-check, and "No
automatic deploys yet" leaves the limitations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checks a git deploy runs before up now return the problems they
found, and prepare() still turns them into the error that stops the
deploy. Nothing changes for a deploy. The next commit, a dry run of a
deploy, lists the problems instead of stopping on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plain git status refreshes the index and writes it back, taking
.git/index.lock while it does. The Sources tab and the scheduled check
ask for a clone's local changes without the stack's lock, so a checkout
in the clone at that moment (a deploy's, or putting the previous commit
back after a failed check) could fail on "index.lock: File exists" and
leave the clone at the wrong commit.

git status --no-optional-locks reads without writing the index, which is
what git documents for background status readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP keeps what realpath() and file_exists() found for a while. After a
checkout replaced the compose file with a symlink, a process that had
already resolved the file's path got the old answer, and the check
that the compose file stays inside the clone passed.

A deploy runs its checks in a fresh process (git_stack.php), so it was
not affected. The dry run in the next commits resolves the compose file
before and after the checkout in one process, and its test found this.
Every checkout now clears PHP's file cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dry run runs every check a deploy runs before it pulls the images,
and stops there: fetch, check out the commit, run the checks, then
check the commit from before out again. It also compares the compose
configuration of the two commits, and says whether up would recreate
every container. No container is started, stopped or recreated, nothing
is pulled or built, and the stack's state is not changed. Like a
deploy, it checks the commit out in the clone itself, so for as long as
the checks run, a container reading a folder of the clone sees the
tried commit's files.

The configuration is compared as "docker compose config
--no-interpolate --no-env-resolution" prints it: variables as written,
not their values, so nothing from the .env or the env files is printed.
When it cannot be read or compared, the dry run says why and still
reports the checks.

The checks prune the plugin's override as a deploy's do; the dry run
puts the override back as it was, unless it was saved again meanwhile
(from the Labels tab, say). Local changes in the clone stop the dry run,
as they stop a deploy, and are kept. The dry run takes the stack's lock
and is refused while another operation on the stack runs, and refused
for a stack whose project name is not settled, as a deploy is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the dry run from the previous commit and prints what it found:
which commit would be deployed (and the last one deployed successfully,
when the clone holds another after a failed up), whether every check
passed or what would stop the deploy, whether up would recreate every
container, and how the compose configuration changes. The exit status
is 0 when every check passed and 1 when one did not, so a script can
try a commit before deploying it. A dry run does not pull images, so it
says that a missing image or tag is found only by the deploy.

--dry-run takes only --commit and --profile: the options for saving
local changes and waiting for healthy containers mean nothing without a
deploy, and are refused rather than ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This comment was marked as resolved.

grantemsley and others added 5 commits October 15, 2026 11:02
The scheduled check's lock, git-scheduled-check.lock, sits in the folder
where each stack's lock is <project name>.lock. A stack named
git-scheduled-check therefore had the pass's own lock as its stack lock:
the pass already held it, so that stack always looked busy and was never
checked.

The pass's lock is now .git-scheduled-check.lock. A project name never
starts with a dot, so no stack's lock can be that file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scheduled check compares one commit, the branch's head when it asked,
and records that commit as handled. But it started the deploy without a
commit, so the deploy fetched again and took whatever the branch's head
was by then. If the branch moved from B to C in between, C was deployed
while B was recorded: a failure of C before up was reported as B's, and
the next pass could try C again despite the once-per-commit rule.

The pass now passes the commit it compared to the deploy (compose.sh
--git-commit). A newer commit is compared and handled by the next pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ride

The dry run kept a copy of the plugin's override from before its checks,
and after them wrote that copy back if the override was still as the
checks' prune left it. A save from the Labels tab after the copy was
taken but before the prune was lost that way: the prune read the newer
save (and, when it had nothing to remove, left it as it was), so the
override still matched what the prune left, and the older copy was
written over it.

The prune now says what it read and what it wrote when it changed the
override. The dry run puts back what the prune read, and only when the
prune changed something and nothing saved the override since. A save
before the prune is what the prune read, so it is kept.

The dry run also now puts back the file the prune writes, the project
override (getProjectOverridePath()). It used to read getOverridePath(),
which on a stack with an indirect override is the indirect file, not the
one the prune changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dry run's documentation said nothing from the .env or the env files
is printed. That holds for the configuration diff, which is read with
--no-interpolate, but not for what the checks find: those are the
deploy's own messages, read from the resolved configuration, so a
missing folder or external volume whose path or name is built from a
variable is named as it resolves. A deploy's log says the same.

The docs, the --dry-run help and the code comment now say so, and keep
the promise to the diff it holds for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… applies

The scheduled check decides to deploy a commit (automatic deploys set to
Deploy, no failed deploy, no local changes, the stack running) without
the stack's lock, since the deploy it starts must take that lock itself.
Things can change before the deploy gets it: a Stop or Down queued
meanwhile ran first, and the deploy then started the stack someone had
just stopped; or the stack was switched to Off or Notify while the check
asked the remote, and it was deployed anyway. And since the deploy now
gets the commit the check compared, a Pull and Redeploy (or a rollback)
that ran in between would be replaced by that commit, which may be older.

compose.sh gitdeploy takes a new --automatic-deploy-from option, which
the scheduled check passes with the deployed commit it compared from
("none" if none). Under the stack's lock, before fetching, git_stack.php
checks all of that again, and that the deployed commit is still the one
the check compared from (GitDeploy::assertAutomaticDeployStillApplies()).
When one no longer holds, nothing is changed and gitdeploy exits 75
(GIT_DEPLOY_EXIT_SKIPPED, EX_TEMPFAIL): no failed result is saved, the
background runner logs it without a failure notification, and the
scheduled check records the commit as not handled, so its next pass
decides again with what it finds then (and says so once, as for any
stopped stack). A clone or docker that cannot be asked is a skip too, as
it is for the scheduled check itself. A failed up that happens to exit 75
exits 1 instead, so it is never read as a skip.

The docker ps question moves to GitDeploy::hasRunningContainers(), and
the local-changes question to GitDeploy::localChanges(); the scheduled
check and the deploy both use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grantemsley

Copy link
Copy Markdown
Author

Thanks, all five fixed as new commits on top:

  • Scheduler lock shared a name with a stack's lock: 2667071. The pass's lock is now
    .git-scheduled-check.lock. A project name never starts with a dot, so no stack's lock can be that file.
    There's a test with a stack named git-scheduled-check.
  • Deploying the branch head instead of the compared commit: eb22d9b. The pass now deploys the commit it
    compared (--git-commit). A newer commit is left for the next pass. There's a test with the branch moving
    between the comparison and the deploy.
  • Pruning could overwrite a newer Labels-tab save: b4f3084. The prune now returns what it read and what
    it wrote. The dry run puts back what the prune read, and only when the prune changed the file and nobody
    has saved it since. A save made before the prune is what the prune read, so it is kept. There are tests for
    a save before a prune that changed nothing and before one that removed a service.
  • Dry-run diagnostics can show .env values: 2357f82. The dry run prints the same problem messages a
    deploy writes to its log, and those need the resolved path or name to be useful. So the docs now say that,
    and promise "variables as written" only for the configuration diff, where --no-interpolate makes it hold.
  • Eligibility checked outside the deployment lock: 7da5f93. The scheduled check now passes
    --automatic-deploy-from <deployed commit> to compose.sh gitdeploy. Under the stack's lock, before
    fetching, that re-checks the mode, a failed deploy, local changes, whether the stack runs, and that no other
    commit was deployed since the check compared (so a Pull and Redeploy in between is never replaced by an
    older commit). When one no longer holds, nothing is
    changed and it exits 75. No failure is recorded or notified, and the next pass decides again. The pass
    holds no lock while it waits for the deploy.

@mstrhakr
mstrhakr merged commit f649c80 into mstrhakr:dev Oct 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants