GitHub
09/03/2026, 11:01 AMGitHub
09/03/2026, 5:52 PM% ./atlantis testdrive
Welcome to Atlantis testdrive!
This mode sets up Atlantis on a test repo so you can try it out. We will
- fork an example terraform project to your username
- install terraform (if not already in your PATH)
- install ngrok so we can expose Atlantis to GitHub
- start Atlantis
Press Ctrl-c at any time to exit
<http://github.com|github.com> username: KevinCarterDev
To continue, we need you to create a GitHub personal access token
with "repo" scope so we can fork an example terraform project.
Follow these instructions to create a token (we don't store any tokens):
<https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-fine-grained-personal-access-token>
- use "atlantis" for the token description
- add "repo" scope
- copy the access token
GitHub access token (will be hidden):
=> forking repo
=> fork completed!
=> terraform found in $PATH at /opt/homebrew/bin/terraform
=> ngrok found in $PATH at /opt/homebrew/bin/ngrok
=> creating secure tunnel
Error: creating ngrok tunnel: timeout, logs:
t=2024-02-07T11:39:51-0700 lvl=info msg="open config file" path=/var/folders/pz/1s31jdrx0fz4gbxgjhkvxmhc0000gp/T/atlantis-testdrive-ngrok-config290247732 err=nil
t=2024-02-07T11:39:51-0700 lvl=info msg="starting web service" obj=web addr=localhost:41414 allow_hosts=[]
t=2024-02-07T11:39:52-0700 lvl=eror msg="failed to reconnect session" obj=tunnels.session obj=csess id=8627fe3d2a25 err="authentication failed: Usage of ngrok requires a verified account and authtoken
.\n\nSign up for an account: <https://dashboard.ngrok.com/signup>\nInstall your authtoken: <https://dashboard.ngrok.com/get-started/your-authtoken\r>\n\r\nERR_NGROK_4018\r\n"
t=2024-02-07T11:39:52-0700 lvl=eror msg="session closing" obj=tunnels.session err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSign up for an account: https://d
<http://ashboard.ngrok.com/signup|ashboard.ngrok.com/signup>\nInstall your authtoken: <https://dashboard.ngrok.com/get-started/your-authtoken\r>\n\r\nERR_NGROK_4018\r\n"
t=2024-02-07T11:39:52-0700 lvl=info msg="received stop request" obj=app stopReq="{err:{Remote:true Inner:{Inner:0x140005b72c0}} restart:false}"
t=2024-02-07T11:39:52-0700 lvl=eror msg="terminating with error" obj=app err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSign up for an account: <https://dashbo>
<http://ard.ngrok.com/signup|ard.ngrok.com/signup>\nInstall your authtoken: <https://dashboard.ngrok.com/get-started/your-authtoken\r>\n\r\nERR_NGROK_4018\r\n"
t=2024-02-07T11:39:52-0700 lvl=crit msg="command failed" err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSign up for an account: <https://dashboard.ngrok.com/si>
gnup\nInstall your authtoken: <https://dashboard.ngrok.com/get-started/your-authtoken\r>\n\r\nERR_NGROK_4018\r\n"
ERROR: authentication failed: Usage of ngrok requires a verified account and authtoken.
ERROR:
ERROR: Sign up for an account: <https://dashboard.ngrok.com/signup>
ERROR: Install your authtoken: <https://dashboard.ngrok.com/get-started/your-authtoken>
ERROR:
ERROR: ERR_NGROK_4018
ERROR:
Usage:
atlantis testdrive [flags]
Flags:
-h, --help help for testdrive
Logs
Environment details
% ./atlantis version
atlantis 0.27.1 (commit: da67e7d) (build date: 2024-01-21T23:42:15Z)
% ngrok -v
ngrok version 3.5.0
Additional Context
Manually running the command that testdrive runs results in the same error
```
% ngrok start atlantis --config /var/folders/pz/1s31jdrx0fz4gbxgjhkvxmhc0000gp/T/atlantis-testdrive-ngrok-config290247732 --log stderr --log-format term
INFO[02-07|114054] open config file path=/var/folders/pz/1s31jdrx0fz4gbxgjhkvxmhc0000gp/T/atlantis-testdrive-ngrok-config290247732 err=nil
INFO[02-07|114054] starting web service obj=web addr=localhost:41414 allow_hosts=[]
EROR[02-07|114054] failed to reconnect session obj=tunnels.session obj=csess id=b5a7b057bd2d err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSig
n up for an account: https://dashboard.ngrok.com/signup\nInstall your authtoken: https://dashboard.ngrok.com/get-started/your-authtoken\r\n\r\nERR_NGROK_4018\r\n"
EROR[02-07|114054] session closing obj=tunnels.session err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSign up for an account: https
://dashboard.ngrok.com/signup\nInstall your authtoken: https://dashboard.ngrok.com/get-started/your-authtoken\r\n\r\nERR_NGROK_4018\r\n"
INFO[02-07|114054] received stop request obj=app stopReq="{err{Remotetrue Inner{Inner0x140006064d0}} restart:false}"
EROR[02-07|114054] terminating with error obj=app err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSign up for an account: https://dashboard
.ngrok.com/signup\nInstall your authtoken: https://dashboard.ngrok.com/get-started/your-authtoken\r\n\r\nERR_NGROK_4018\r\n"
WARN[02-07|114054] failed to check for update obj=updater err="Post \"https://update.equinox.io/check\": context canceled"
CRIT[02-07|114054] command failed err="authentication failed: Usage of ngrok requires a verified account and authtoken.\n\nSign up for an account: https://dashboard.ngrok.c
om/signup\nInstall your authtoken: https://dashboard.ngrok.com/get-started/your-authtoken\r\n\r\nERR_NGROK_4018\r\n"
INFO[02-07|114054] no more state changes obj=tunnels.session
NAME:
start - start tunnels by name from the configuration file
USAGE:
ngrok start [flags]
DESCRIPTION:
Starts tunnels by name from the configuration file. You may specify any
number of tunnel names. You may start all tunnels in the configuration
file with the --all switch.
TERMS OF SERVICE: https://ngrok.com/tos
EXAMPLES:
ngrok start dev # start tunnel named 'dev' in the configuration file
ngrok start web blog # start tunnels named 'web' and 'blog'
ngrok start --all # start all tunnels defined in the config file
OPTIONS:
--all start all tunnels in the configuration file
--authtoken string ngrok.com authtoken identifying a user
--config strings path to config files; they are merged if multiple
-h, --help help for start
--log string path to log file, 'stdout', 'stderr' or 'false' (default "false")
--log-format string log record format: 'term', 'logfmt', 'json' (default "term")
--log-level string logging level: 'debug', 'info', 'warn', 'error', 'crit' (default "info")
--none start running no tunnels
ERROR: authentication failed: Usage of ngrok requires a verified account and authtoken.
ERROR:
ERROR: Sign up f…
runatlantis/atlantisGitHub
09/04/2026, 11:21 AMatlantis cancel. The docs now state explicitly: "There is currently no mechanism in Atlantis to interrupt the currently running process." This issue proposes that mechanism.
Describe the solution you'd like
A new comment command (new verb, so atlantis cancel queue semantics stay untouched):
atlantis stop # safe: SIGINT only, no escalation
atlantis stop --force # SIGINT → SIGINT (tf force-abort) → grace → SIGKILL
Two explicit tiers, because interruption safety differs fundamentally:
• Safe stop (default): send SIGINT to the terraform process group once, then wait. Terraform handles SIGINT gracefully: stops scheduling new operations, finishes in-flight provider calls, persists state, releases the state lock. No timer, no escalation — if a hung provider never exits, the project stays visibly in Stopping and a human decides whether to escalate. Permission: same as plan.
• Force stop (--force): escalation ladder with one rung per grace period — second SIGINT (terraform's own force-abort, which still best-effort releases the state lock), then SIGKILL to the process group. The result comment must warn that the state lock may be orphaned (terraform force-unlock needed) and resources may exist that were never recorded in state. Permission: same as apply, and additionally gated behind a server flag.
Design outline:
• Kill the process, not the goroutine. Cancellation propagates a real context.Context down the call stack; the child process dies, the step runner returns an error, and the existing unwind path (working-dir lock release, commit status, PR comment) is reused as-is. Only the exit-cause label differs (Stopped / ForceStopped vs generic failure).
• Context plumbing. RunCommandWithVersion takes command.ProjectContext (not a context.Context) and spawns via plain exec.Command (server/core/terraform/tfclient/terraform_client.go), so today nothing can reach the child process. A context.Context needs to be threaded from the command runner through the step runners to the terraform client. exec.CommandContext alone is not enough (it SIGKILLs): the child runs in its own process group (Setpgid) with a small supervisor that owns signal delivery, so custom run steps and provider subprocesses are covered too.
• Run registry. In-memory map keyed by repo/pull/project/workspace holding the supervisor handle; registered at project-command start, deregistered on exit. Same single-instance scope as the existing command queue.
• Queue interaction. stop is PR-wide by design — no `-d`/`-w` scoping. It drains all of the PR's queued commands first, then signals every running process for the PR. Draining before signalling matters: otherwise a queued apply starts the instant the running command dies. Keeping it unscoped avoids the footgun of stopping one project while another project's queued apply proceeds; atlantis cancel remains the queue-only tool.
• State machine. New project statuses Stopping → Stopped / ForceStopped, so re-planning after a stop behaves, commit statuses don't hang, and stop while Stopping is a no-op (while stop --force escalates).
• Server flags: --enable-stop-command (off by default), --allow-force-stop, --force-stop-grace-period (default 30s).
Suggested phasing to keep PRs reviewable: (1) context plumbing, pure refactor; (2) plan-only stop — zero state risk, immediate value on long plans; (3) apply stop, safe tier; (4) --force.
Describe the drawbacks of your solution
• The context plumbing touches the whole command-execution call stack — it's the expensive part and the reason #187 stalled. Phase 1 is a large, behavior-neutral refactor that needs careful review.
• Force-stopping an apply can orphan the terraform state lock and leave provisioned resources unrecorded in state. Mitigated by: off by default, separate server flag, apply-level permissions, and an explicit warning comment — but the residual risk is inherent to SIGKILL and cannot be engineered away.
• The run registry is in-memory, so stop only works on the instance running the command — consistent with the existing queue, but one more thing that doesn't transfer to a future HA story.
• Process groups/SIGINT are Unix semantics; on Windows the ladder degrades (no clean SIGINT equivalent). Realistically: full support on Unix, best-effort kill on Windows.
• A stopped run leaves a partially initialized working dir; the next plan re-inits, but users may be surprised by the extra init time.
Describe alternatives you've considered
• Extending atlantis cancel with a --running flag: rejected to keep the existing queue-only semantics unambiguous and avoid changing the behavior of a shipped command; a separate verb also lets the two commands carry different permission levels.
• Restarting the Atlantis pod: works today but kills all runs server-wide, loses the queue, and can orphan state locks — strictly worse than a targeted SIGINT.
• Server-side execution timeouts (existing per-command timeout flags): complementary, but they can't cover the "human realizes the plan is wrong 10 seconds in" case, and a timeout firing mid-apply has the same safety problem without a human choosing the risk tier.
• Automatic escalation in the safe tier (SIGINT, then SIGKILL after a timeout): rejected — it silently converts a safe operation into a dangerous one; escalation should be an explicit human decision.
---
This proposal was drafted with AI assistance (Claude); the design and the text were reviewed by the author.
runatlantis/atlantisGitHub
09/04/2026, 5:32 PMatlantis apply -w <workspace> comments on the same PR can no longer run in parallel:
cannot run "apply": pull request 22 is currently locked by "apply" for commit 12345678.
Wait until the previous command is complete and try again
On previous versions locks look to have been keyed by repo/pull/workspace/path/project. That means targeted applies for different workspaces were permitted.
Our workflow is to have one repo with multiple workspaces each representing an enviornment/cluster. In many cases we want to apply multiple at once (daily-1, daily-2) while waiting to apply additional environments (integration-1, integration-2).
We use one command per workspace because we want each environment to have its own comment and job link and to be independently re-runnable when one fails. To that end we also set --disable-apply-all to avoid someone applying all clusters at once without an initial smoke deploy.
Reproduction Steps
1. A repo with a single root module and two or more workspaces, and an atlantis.yaml with parallel_apply: true and one project per workspace all sharing dir: ./.
2. Push a commit that touches a shared .tf so autoplan plans every workspace. Let it finish.
3. Comment atlantis apply -w daily-1.
4. Within a few seconds, comment atlantis apply -w daily-2.
5. The second command is rejected with the error above. On v0.45.0 both applies run concurrently.
Logs
Apply Error comment on the PR
cannot run "apply": pull request 22 is currently locked by "apply" for commit a1b2c3d.
Wait until the previous command is complete and try again
The first atlantis apply -w daily-1 was commented at 135017Z and completed at 135807Z. The second, atlantis apply -w daily-2, was commented at 135023Z and rejected at 135027Z.
Environment details
• Atlantis version: v0.47.1
• Deployment method: Kubernetes StatefulSet running the official ghcr.io/runatlantis/atlantis image
• Atlantis flags: --disable-apply-all=true, --parallel-pool-size=25, --automerge=true, --default-tf-distribution=opentofu, --gh-allow-mergeable-bypass-apply=true
Sever-side repo config:
Atlantis server-side config file (plan steps elided):
repos:
- id: /.*/
branch: /(main|master)/
apply_requirements: [mergeable]
plan_requirements: [undiverged]
pre_workflow_hooks:
- run: sh ./generate-atlantis-yaml.sh && cat ./atlantis.yaml
description: Generate dynamic atlantis.yaml
commands: plan
workflows:
default:
apply:
steps:
- run: tofu workspace select -no-color $WORKSPACE
- run: tofu apply -parallelism=100 -no-color $PLANFILE 2>&1 | tee ./apply.out
- run: sh ./rollout-check.sh
- run: sh ./annotate-grafana.sh
Repo atlantis.yaml
version: 3
parallel_plan: true
parallel_apply: true
projects:
- dir: ./
autoplan:
when_modified: ["./**/*","!./**/*md","!./*tfvars","./daily-1.tfvars"]
workspace: daily-1
- dir: ./
autoplan:
when_modified: ["./**/*","!./**/*md","!./*tfvars","./daily-2.tfvars"]
workspace: daily-2
- dir: ./
autoplan:
when_modified: ["./**/*","!./**/*md","!./*tfvars","./integration-1.tfvars"]
workspace: integration-1
- dir: ./
autoplan:
when_modified: ["./**/*","!./**/*md","!./*tfvars","./integration-2.tfvars"]
workspace: integration-2
Additional Context
• #6786 appears to hit the same lock in a slightly different way: multi-instance -p targeting and --silence-no-projects.
• Our apply workflow is custom run steps with no built-in apply step, because we need tofu workspace select, a rollout check and a Grafana annotation around the apply. The Custom Workflow Boundary section of #6605 says those steps are outside Atlantis-managed apply-step validation, so we are serialized by a lock whose purpose is protection we dont get. #6781 carved custom workflows out of the managed-plan-file requirement for the same reason.
runatlantis/atlantisGitHub
09/08/2026, 10:36 AM-debian variant as an example, the size of the image (per dive) is around 1GB:
dive ghcr.io/runatlantis/atlantis:v0.44.0-debianImage name: ghcr.io/runatlantis/atlantis:v0.44.0-debian Total Image size: 1.0 GB Potential wasted space: 4.4 MB Image efficiency score: 99 % The vast majority of the image size is coming from two layers: •
sha256:d5c57c40bdff313840546756e697df0bc204b82320f66856ca25304f578b61b1
• sha256:8ffae1a96e8b31a362813c0515e96bf3d739b516732d68d6dcb0da8ae2959549
469 MB COPY /usr/local/bin/terraform/terraform* /usr/local/bin/ # buildkit
211 MB COPY /usr/local/bin/tofu/tofu* /usr/local/bin/ # buildkit
i.e. https://github.com/runatlantis/atlantis/blob/main/Dockerfile#L181-L182, with these directories being populated by https://github.com/runatlantis/atlantis/blob/main/Dockerfile#L152-L161
This approach presents a few problems:
• Users who only use Tofu acquire 469MB of unnecessary terraform dependencies.
• Users who only use Terraform acquire 211MB of unnecessary tofu dependencies.
• Users who build their own images on top of Atlantis or who rely on Atlantis downloading specific versions at runtime potentially acquire 680MB of unnecessary dependencies.
• Some of the versions being acquired have known CVEs: #6569 (comment)
• Whilst a custom docker image using atlantis as a base could remove these versions, they cannot reduce the size as the two layers will still exist in final image.
Given that atlantis also publishes a helm chart (https://github.com/runatlantis/helm-charts/) it is reasonable to expect that many users will be deploying via Kubernetes, where image size will impact the time to pull the container image and thus present scaling challenges.
Describe the solution you'd like
I see two potential solutions.
• Reduce size by simply only installing one default version of terraform and tofu.
• Publish -slim variants for both existing images where neither terraform nor tofu are installed.
The second option could be achieved by passing in a new build-arg and using it as a conditional. The layer would not be removed but instead would be empty on the -slim images, i.e. something similar to:
RUN if [ "$SLIM_BUILD" = "false"] ; then (./download-release.sh \
"terraform" \
"${TARGETPLATFORM}" \
"${DEFAULT_TERRAFORM_VERSION}" \
"1.8.5 1.9.8 1.10.5 ${DEFAULT_TERRAFORM_VERSION}" \
&& ./download-release.sh \
"tofu" \
"${TARGETPLATFORM}" \
"${DEFAULT_OPENTOFU_VERSION}" \
"${DEFAULT_OPENTOFU_VERSION}") ; fi
Describe the drawbacks of your solution
• Increased build complexity.
• Doubles CI compute for building images.
• If no terraform/tofu versions are present on the image there is no fallback versions for issues like an upstream outage (nowhere to download terraform from) or key expiry (i.e. #6405)
Describe alternatives you've considered
• Building a custom atlantis image by copying and adapting the Dockerfile directly.
• Creating a minimal OCI image via something like nix and including the atlantis package
runatlantis/atlantisGitHub
09/08/2026, 11:03 AM/usr/local/bin/terraform...
| Release | Critical | High | Medium | Low |
| ------- | -------- | ---- | ------ | --- |
| 1.10.5 | 10 | 8 | 9 | 2 |
| 1.9.8 | 10 | 9 | 9 | 3 |
| 1.8.5 | 10 | 9 | 12 | 3 |
Proposal
Remove bundling legacy terraform versions part of Atlantis docker image
runatlantis/atlantisGitHub
09/09/2026, 2:29 PMexternal pipeline for its first status write and then abandons it: the pipeline sits at
running forever, even after the MR is merged. When we went looking, we found seven of
these orphans on one repo, the oldest from May 2024: this had been happening quietly for
over a year before anyone noticed.
The mechanism is in UpdateStatus in server/events/vcs/gitlab/client.go (same code at
v0.47.1 and on current main):
1. It fetches the commit, and if commit.LastPipeline is set, posts the status with
PipelineID = commit.LastPipeline.ID.
2. Otherwise it retries the lookup a couple of times, then falls back to
Ref = pull.HeadBranch, the short branch name, which makes GitLab find-or-create a
pipeline.
Nothing checks that commit.LastPipeline is the pipeline Atlantis itself created. So if
another integration creates a newer pipeline on the same commit between Atlantis's first
and second writes, Atlantis silently switches to it, and the pipeline it created never
reaches a terminal state.
The reason there are two pipelines at all: GitLab's set-commit-status API is
find-or-create keyed on the ref string, and the two posters spell the same branch
differently: Atlantis's fallback uses the bare branch name (<branch>), CodeRabbit uses
the fully-qualified refs/heads/<branch>. GitLab treats those as distinct refs and
creates a pipeline for each.
Reproduction Steps
1. A GitLab project with no CI jobs of its own, so no MR pipeline exists and Atlantis
always takes the Ref = pull.HeadBranch fallback for its first write.
2. A second integration that posts commit statuses for the same commits using the
fully-qualified refs/heads/<branch> ref.
3. Open an MR. Atlantis autoplans and posts atlantis/plan = running first, creating
pipeline A keyed on the short ref.
4. The second integration posts its status a few seconds later, creating pipeline B keyed
on refs/heads/<branch>.
5. Atlantis's subsequent writes find commit.LastPipeline = B and post there.
6. Pipeline A stays running forever, including after the MR is merged.
For one incident we have the full timeline: Atlantis created pipeline A at 131036.517Z
and wrote to it exactly once, 141 ms later. CodeRabbit created pipeline B at 131040.189Z, 3.7 s after Atlantis gave up looking, and B then received every one of Atlantis's
remaining statuses, ending success at 132016.860Z. Plan and apply both succeeded and
the MR merged normally; only the pipeline record is wrong.
We could also watch the adoption happen live after enabling gitlab-status-retry-enabled
(see Logs below): Atlantis retries, then logs that it found a pipeline and adopts it, but
the pipeline it adopts was created by CodeRabbit, not by Atlantis.
Logs
The bug itself produces no errors: everything succeeds from Atlantis's point of view, so
there is nothing to show for the failure case. What we can show is the adoption decision.
With gitlab-status-retry-enabled: true, on a commit where CodeRabbit's pipeline appeared
while Atlantis was still retrying:
Logs
No pipeline found for commit e19727f35c9d1265febf6e601644c37a00dec2be, retrying in 2s
No pipeline found for commit e19727f35c9d1265febf6e601644c37a00dec2be, retrying in 3.4194057s
Pipeline found for commit e19727f35c9d1265febf6e601644c37a00dec2be, setting pipeline ID to 237707
Pipeline 237707 was created by the other integration. Atlantis attaches all its statuses
to it by ID from then on. In this instance that happens to be harmless (one pipeline, one
poster driving it to terminal state), but it is the same unchecked adoption that strands
Atlantis's own pipeline in the race described above.
One more practical consequence for anyone else cleaning these up: the stranded pipelines
contain no jobs, so GitLab's stuck/timeout handling never touches them, and
POST /pipelines/:id/cancel returns 200 but does not change their status. The only way we
found to clear them was DELETE /pipelines/:id as Owner.
Environment details
• Atlantis v0.47.1 (`ghcr.io/runatlantis/atlantis:v0.47.1-debian`), single instance on
ECS Fargate, EFS-backed data dir
• GitLab self-managed 19.3.1
• Terragrunt workflow; atlantis.yaml generated per run by a pre-workflow hook;
--parallel=false
• gitlab-status-retry-enabled was false (the default) when the orphans were created;
we have since enabled it
Additional Context
• This looks like the same underlying defect as #5228 (still open): both cases come from
adopting commit.LastPipeline.ID without checking that the pipeline belongs to this
branch or to Atlantis. #5228's trigger is two MRs sharing a HEAD commit; ours is a
third-party status poster, which we didn't find covered in any existing issue.
• #5944 / PR #5986 (the gitlab-status-retry-enabled flag) helps but doesn't close the
hole: the longer wait makes it likely Atlantis finds the other pipeline before ever
creating its own, so the window is narrowed, not removed, and when it does find one,
it still adopts it without checking whose it is.
• A durable fix would be to check the ref and/or owner of commit.LastPipeline before
adopting it, or to have Atlantis remember and reuse the pipeline it created for a given
commit.
Happy to test a patch against a live GitLab 19.3.1 setup.
runatlantis/atlantisGitHub
09/10/2026, 2:33 PM--checkout-strategy=merge and a persistent data volume, a PR whose base branch changes while its head commit stays the same wedges its working directory permanently. Every subsequent plan fails with:
running git reset --hard refs/remotes/origin/main: fatal: ambiguous argument 'refs/remotes/origin/main': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
: exit status 128
This is the common "stacked PR" flow. feature/branch-b targets `feature/branch-a`; feature/branch-a merges and is deleted; the forge retargets the open PR onto main. (On GitLab this is automatic — it posts "deleted the feature/branch-a branch. This merge request now targets the main branch". GitHub does the same retarget when a base branch is deleted, and a user can retarget manually on either forge.)
Because the head commit never changed, the working dir is never re-cloned, and it only ever had a remote-tracking ref for the old base branch.
Why the existing guard does not catch it
#6146 / #6177 added remoteHasBranch so a changed base branch forces a re-clone. That guard lives in attemptReuseCloneDir and is unreachable in this scenario. Line numbers below are server/events/working_dir.go at v0.47.1.
1. Clone() fast path (L138–146) runs isBranchAtTargetRef(p.HeadCommit). Under the merge strategy this compares HEAD^2, which is still the unchanged PR head, so it returns early:
repo is at correct commit "<sha>" so will not re-clone (fast path)
attemptReuseCloneDir — and therefore the remoteHasBranch guard at L185 — is never reached. Even in the slow path the guard only runs when isUpToDate == false.
2. MergeAgain() (L206) then calls recheckDiverged() (L265), whose git remote update fails because the old base branch was deleted from the remote:
getting remote update failed: Fetching origin
fatal: couldn't find remote ref refs/heads/feature/branch-a
error: could not fetch origin
Per the documented behaviour it returns true ("we prefer to assume divergence for safety").
3. mergeAgain() (L814) unconditionally runs:
w.wrappedGit(logger, c, "reset", "--hard", fmt.Sprintf("refs/remotes/origin/%s", c.pr.BaseBranch))
refs/remotes/origin/main does not exist, because forceClone had cloned with --branch feature/branch-a --single-branch, leaving exactly one remote-tracking ref. It fails, and there is no remoteHasBranch check on this path and no fallback to forceClone.
The state is not self-healing: every retry takes the same fast path and fails identically. The only recovery is deleting the working directory by hand.
Confirmed on the affected checkout — note the single, stale refspec:
$ git -C <data-dir>/repos/<org>/<repo>/123/default config --get-all remote.origin.fetch
+refs/heads/feature/branch-a:refs/remotes/origin/feature/branch-a
$ git -C <data-dir>/repos/<org>/<repo>/123/default reflog --date=iso
<sha1> HEAD@{...}: merge <pr-head-sha>: Merge made by the 'ort' strategy.
<sha2> HEAD@{...}: reset: moving to origin/feature/branch-a
...
<sha3> HEAD@{...}: clone: from https://<host>/<org>/<repo>.git
Reproduction Steps
1. Run Atlantis with --checkout-strategy=merge and a persistent data volume.
2. Open PR A (feature/branch-a → main).
3. Open PR B (feature/branch-b → feature/branch-a), stacked on A.
4. Let Atlantis plan PR B, so its working dir is cloned with --branch feature/branch-a --single-branch.
5. Merge PR A and delete feature/branch-a. The forge retargets PR B onto main.
6. Comment atlantis plan on PR B without pushing any new commit.
PR B now fails with the git reset --hard refs/remotes/origin/main error above, and keeps failing on every retry.
Logs
Logs
{"level":"info","caller":"events/working_dir.go:143","msg":"repo is at correct commit \"<pr-head-sha>\" so will not re-clone (fast path)","json":{"repo":"<org>/<repo>","pull":"123"}}
{"level":"warn","caller":"events/working_dir.go:311","msg":"getting remote update failed: Fetching origin\nfatal: couldn't find remote ref refs/heads/feature/branch-a\nerror: could not fetch origin\nFetching source\nFrom https://<host>/<org>/<repo>\n <old>..<new> main -> source/main\n","json":{"repo":"<org>/<repo>","pull":"123"}}
{"level":"info","caller":"events/working_dir.go:251","msg":"base branch may have been updated, using merge strategy and will merge again","json":{"repo":"<org>/<repo>","pull":"123"}}
{"level":"error","caller":"events/project_command_runner.go","msg":"Error running plan operation: running git reset --hard refs/remotes/origin/main: fatal: ambiguous argument 'refs/remotes/origin/main': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'\n: exit status 128","json":{"repo":"<org>/<repo>","pull":"123"}}
Environment details
• Atlantis version: v0.47.1
• Deployment method: Helm chart 6.9.2 on Kubernetes, with a persistent volume for --data-dir
• If not running the latest Atlantis version have you tried to reproduce this issue on the latest version: the relevant code in server/events/working_dir.go is identical on main at the time of writing, so it should still reproduce
• Atlantis flags: --checkout-strategy=merge (via ATLANTIS_CHECKOUT_STRATEGY), GitLab VCS
Additional Context
Suggested fix — make the base-branch check reachable on the paths that actually run for an unchanged head commit. Either or both of:
• In mergeAgain() (or in MergeAgain() before it), check remoteHasBranch(c.pr.BaseBranch) and fall back to forceClone when the ref is missing, instead of resetting to a ref that cannot exist.
• In the Clone() fast path (L138–146), also require remoteHasBranch(c.pr.BaseBranch) when w.CheckoutMerge is set, so a retargeted PR does not short-circuit past the existing guard.
The first is the more complete fix, since MergeAgain() is reached even when Clone() legitimately reuses the directory.
Related: #6146, #6177 (added remoteHasBranch to the Clone path).
I am happy to open a PR for this if maintainers agree with the approach.
runatlantis/atlantisGitHub
09/15/2026, 2:23 PMATLANTIS_DISABLE_AUTOPLAN_LABEL
ATLANTIS_ENABLE_AUTOPLAN_LABEL=atlantis-autoplan
and via the cli flag similar to --disable-autoplan-label
--enable-autoplan-label=atlantis-autoplan
When the project has autoplan enabled the label has no affect.
Describe the drawbacks of your solution
Confusion with enable and disable flags.
Describe alternatives you've considered
1. Automatically label all PRs with the ATLANTIS_DISABLE_AUTOPLAN_LABEL value.
race condition with atlantis
1. Users must always explicitly comment atlantis plan
well, this is the status quo that I want to improve on
runatlantis/atlantisGitHub
09/16/2026, 3:31 PMerror downloading terraform version 1.16.3: unexpected content-type: binary/octet-stream (expected any of ["application/x-zip-compressed" "application/zip"]): Downloading terraform Version 1.16.3
Reproduction Steps
Submit a PR or run a plan as of now, without forcing a specific default TF version.
Logs
Logs
{"level":"info","ts":"2026-09-16T14:58:51.395Z","caller":"tfclient/terraform_client.go:519","msg":"could not find terraform version 1.16.3 in PATH or /atlantis/bin","json":{"repo":"...","pull":"810"}}
{"level":"info","ts":"2026-09-16T14:58:51.395Z","caller":"tfclient/terraform_client.go:521","msg":"downloading terraform version 1.16.3 from download URL <https://releases.hashicorp.com>","json":{"repo":"...","pull":"810"}}
{"level":"info","ts":"2026-09-16T14:58:51.871Z","caller":"github/client.go:988","msg":"Updating GitHub Check status for 'atlantis/plan: ...' to 'failure'","json":{"repo":"...","pull":"810"}}
{"level":"error","ts":"2026-09-16T14:58:52.620Z","caller":"events/instrumented_project_command_runner.go:81","msg":"Error running plan operation: error downloading terraform version 1.16.3: unexpected content-type: binary/octet-stream (expected any of [\"application/x-zip-compressed\" \"application/zip\"]): Downl
oading terraform Version 1.16.3\n","json":{"repo":"...","pull":"810"},"stacktrace":"<http://github.com/runatlantis/atlantis/server/events.RunAndEmitStats|github.com/runatlantis/atlantis/server/events.RunAndEmitStats>\n\tgithub.com/runatlantis/atlantis/server/events/instrumented_project_command_runner.go:81\ngithub.com/runatlantis/atlantis/server/events
.(*InstrumentedProjectCommandRunner).Plan\n\<http://tgithub.com/runatlantis/atlantis/server/events/instrumented_project_command_runner.go:41|tgithub.com/runatlantis/atlantis/server/events/instrumented_project_command_runner.go:41>\ngithub.com/runatlantis/atlantis/server/events.RunOneProjectCmd\n\tgithub.com/runatlantis/atlantis/server/events/project_command_pool_executor.go:22\ngithub.com/runatlantis/atlantis
/server/events.runProjectCmds\n\<http://tgithub.com/runatlantis/atlantis/server/events/project_command_pool_executor.go:102|tgithub.com/runatlantis/atlantis/server/events/project_command_pool_executor.go:102>\ngithub.com/runatlantis/atlantis/server/events.runProjectCmdsWithCancellationTracker\n\tgithub.com/runatlantis/atlantis/server/events/project_command_pool_executor.go:178\ngithub.com/runatlantis/atl
antis/server/events.(*PlanCommandRunner).runAutoplan\n\<http://tgithub.com/runatlantis/atlantis/server/events/plan_command_runner.go:156|tgithub.com/runatlantis/atlantis/server/events/plan_command_runner.go:156>\ngithub.com/runatlantis/atlantis/server/events.(*PlanCommandRunner).Run\n\tgithub.com/runatlantis/atlantis/server/events/plan_command_runner.go:325\ngithub.com/runatlantis/atlantis/serv
er/events.(*DefaultCommandRunner).RunAutoplanCommand\n\<http://tgithub.com/runatlantis/atlantis/server/events/command_runner.go:242%22}|tgithub.com/runatlantis/atlantis/server/events/command_runner.go:242"}>
Environment details
• Atlantis version: v0.44.0
• Deployment method: GKE, directly, not using any module or chart
• If not running the latest Atlantis version have you tried to reproduce this issue on the latest version: Did not try the latest version
Additional Context
Nothing.
runatlantis/atlantisGitHub
09/22/2026, 6:40 AMGitHub
09/24/2026, 1:15 PM--ignore-vcs-status-names server flag is ignored when Atlantis computes whether a pull request is mergeable.
VCSClient.PullIsMergeable is defined as PullIsMergeable(logger, repo, pull, vcsStatusName string, ignoreVCSStatusNames []string) and both the GitHub and GitLab clients honor those two parameters. The Azure DevOps client, however, discards them (_ string, _ []string) and instead hardcodes a skip of the status genre Atlantis Bot/atlantis with name apply.
Two consequences:
1. --ignore-vcs-status-names has no effect on Azure DevOps. Operators cannot exclude an additional blocking status-check branch policy (for example a human-only merge gate, or a second Atlantis service) from Atlantis' mergeability evaluation — a capability GitHub users already have via this documented flag.
2. The self-apply skip does not track --vcs-status-name. Atlantis posts its own statuses under the configured VCS status name (genre Atlantis Bot/<vcs-status-name>), but the Azure DevOps skip is hardcoded to the literal Atlantis Bot/atlantis. With a non-default ATLANTIS_VCS_STATUS_NAME, the apply status genre no longer matches the hardcoded value, unlike the GitHub implementation which uses the configured name.
Reproduction Steps
1. Run Atlantis against an Azure DevOps repo with apply_requirements: [mergeable].
2. Add a blocking status-check branch policy that is not Atlantis' own status (for example a SonarQube/quality gate, or a proxy status used as a human merge gate).
3. Start the server with --ignore-vcs-status-names set to that policy's status genre or name.
4. Observe that Atlantis still treats the PR as not mergeable because the blocking policy is not approved — the flag is not honored on Azure DevOps.
Environment details
• Atlantis version: main (reproducible in current code)
• VCS: Azure DevOps
• Relevant flags: --ignore-vcs-status-names, --vcs-status-name
• Relevant code: server/events/vcs/azuredevops/client.go PullIsMergeable (params discarded; hardcoded `Atlantis Bot/atlantis`/`apply` skip), vs server/events/vcs/github/client.go IsMergeableMinusApply which honors both params.
Additional Context
• The flag is documented in <http://runatlantis.io/docs/server-configuration.md|runatlantis.io/docs/server-configuration.md> under --ignore-vcs-status-names, previously noted as "Currently only implemented for GitHub."
• Proposed fix (provider parity, mirroring GitHub): honor vcsStatusName for the self-apply skip (Atlantis Bot/<vcs-status-name> + apply, keeping default atlantis backward compatible) and skip any branch policy whose status genre or name is listed in --ignore-vcs-status-names.
• Pull request implementing this: #6921
runatlantis/atlantisGitHub
09/24/2026, 9:35 PMatlantis apply fails without applying anything if any project on the PR doesn't have a successful plan. On v0.42.0 the successful plans were applied and the failed projects were skipped.
We get one of these errors, depending on which project is in a bad state:
plan for dir "services/standalone_elig" workspace "qa" project "standalone_elig" has status "plan_errored" and cannot be applied; run `atlantis plan`
plan for dir "services/remittance" workspace "qa" project "remittance" errored and cannot be applied without a plan file; run `atlantis plan`
plan for dir "services/operations_automation/change_of_address" workspace "qa" project "operations_automation_change_of_address" has status "applied" and cannot be applied; run `atlantis plan`
The third error means that once you apply one project with -p, bare apply stays blocked on that PR until everything is re-planned.
We don't use depends_on or abort_on_execution_order_fail. The check looks like validatePullStatusHasPlanFiles in server/events/project_command_builder.go, added in #6605 (v0.46.0). It returns at the first project with an errored or missing plan, which fails the whole command.
Reproduction Steps
1. Use a repo with several projects and parallel_apply: true.
2. Open a PR where one project's plan fails (a Terraform error, or a lock held by another PR) and the rest plan successfully.
3. Comment atlantis apply. Nothing is applied and you get an error for the failed project.
4. Comment atlantis apply -p <good project>. It applies.
5. Comment atlantis apply again. Nothing is applied and you get has status "applied" and cannot be applied.
Logs
Redacted PR timeline (50 projects, 2 failed plans): https://gist.github.com/jonanderson10/ab6e0c33c27530f3d7a1da26fab21e5d
Environment details
• Atlantis version: v0.48.0 (`ghcr.io/runatlantis/atlantis:v0.48.0`), upgraded from v0.42.0 with no config changes
• Deployment method: AWS ECS, GitHub
• Relevant server flags: ATLANTIS_ENABLE_REGEXP_CMD=true, ATLANTIS_PARALLEL_POOL_SIZE=3, ATLANTIS_SILENCE_NO_PROJECTS=true. --disable-apply-all is not set. No apply_requirements.
• Repo atlantis.yaml (trimmed, ~170 projects):
version: 3
parallel_plan: true
parallel_apply: true
projects:
- name: standalone_elig
dir: ./services/standalone_elig
workspace: <workspace> # replaced with the base branch by a pre_workflow_hook
execution_order_group: 699
- name: remittance
dir: ./services/remittance
workspace: <workspace>
execution_order_group: 699
Additional Context
• Similar feature requests: #2325, #3042. v0.42.0 already behaved this way for us.
• Possibly related: #6790, #6848, #6849 (0.48 plan verification changes).
• Not duplicates: #6641, #6843, #6642.
• Request: have bare apply skip projects without a valid plan (and projects already applied), report them, and apply the rest. If the new default is intentional, an opt-in (e.g. continue_on_plan_failure) would work for us.
runatlantis/atlantisGitHub
09/25/2026, 2:42 PMPOST /api/drift/detect, I can request human-readable plan_output following #6709. I’d also like Terraform’s machine-readable plan so I can inspect drift programmatically without parsing text output.
Describe the solution you'd like
Add a default-off include_plan_json request field, independent of include_plan_output. When requested, return the full terraform show -json result as a JSON object in each project’s plan_json field. Generate it while the project’s plan execution lock is held.
Return this data only in the immediate detect response, not in stored drift status or webhooks. If a project’s text plan succeeds but JSON cannot be produced, preserve its drift result, omit plan_json, and provide a per-project plan_json_error explaining why.
Describe the drawbacks of your solution
Generating JSON adds a command and can substantially increase response size. Full Terraform plan JSON may contain sensitive values, so it should require an explicit opt-in and be documented accordingly. JSON may be unavailable even after a successful text plan—for example, when remote operations or a custom workflow do not produce a local plan file. Supporting it also adds API fields and per-project error handling to maintain.
Describe alternatives you've considered
• Parse `plan_output`: Avoids another command, but text output is intended for humans and is less reliable for programmatic inspection.
• Read JSON only when a workflow already produces it: Avoids running show, but the default plan workflow does not produce a JSON file. Users would need to change their workflows to benefit.
• Run show -json after plan execution in the API controller: Keeps plan execution unchanged, but separates JSON generation from the project’s execution lock and environment.
runatlantis/atlantisGitHub
09/28/2026, 7:45 PMsilence_pr_comments: ["plan"] in the server-side repo configuration. This option fixes the rate limit problem.
But the option also hides the comment that tells a user why a plan did not run. If another pull request holds the project lock, Atlantis does not post this message:
Plan Failed: This project is currently locked by an unapplied plan from pullImage The user sees only a failed. To continue, delete the lock from#<PR-number>or apply that plan and merge the pull request.#<PR-number>
atlantis/plan commit status and no reason is stated in the failed plan link, as shown below:
Image
The user must then search the Atlantis server logs to find the cause, which leds to wasted time and user frustration. Other short failure messages have the same problem.
Steps to reproduce:
1. Set silence_pr_comments: ["plan"] for a repository.
2. Open pull request A that changes project foo. Atlantis plans it and locks foo.
3. Open pull request B that also changes project foo.
4. Atlantis sets a failed plan status on pull request B, but it posts no comment that names the lock or pull request A.
Describe the solution you'd like
If a project result has a failure message, Atlantis posts it even when the command is in silence_pr_comments. Atlantis continues to silence plan output, apply output, and errors, because they contain Terraform output and can be long.
Failure messages are short, fixed text that Atlantis writes. They include a project lock held by another pull request, unmet plan or apply requirements, and unapplied project dependencies. They tell the user what to do next.
The change is one condition in PullUpdater.updatePull in server/events/pull_updater.go, a unit test, and one sentence in the silence_pr_comments docs. An implementation is ready on this branch. When maintainers accept this issue, I can open a pull request.
Describe the drawbacks of your solution
Repositories that use silence_pr_comments get some comments again. Each comment is short, and Atlantis puts all failed projects of one command into one comment. The rate limit risk is much lower than for plan output, but it is not zero. For example, a pull request with many locked projects gets one comment on each autoplan.
Users who want no comments at all for a stage lose that behavior for failures.
Describe alternatives you've considered
1. Post only the project lock message. This needs a new marker on the project result for lock failures, set at each lock call site in server/events/project_command_runner.go. Unmet requirements cause the same confusion, so the wider rule seems more useful.
2. Add a new configuration value that keeps failure comments while the rest of the stage is silenced. This keeps the current default, but it adds one more option to the configuration and the docs.
3. Rely on the commit status. The status description is short, and Atlantis uses it for project counts. It has no space for the lock reason and the link to the other pull request.
runatlantis/atlantisGitHub
09/29/2026, 1:42 PMatlantis/apply commit status at running as long as planned projects remain unapplied. That is correct and useful: it stops the MR from merging with unapplied changes.
The problem is that this state can outlive the MR. If the MR is merged anyway (apply requirements permitting, or after a partial apply of only some projects), or closed without applying, nothing ever finalizes the combined status. On GitLab the external pipeline that carries it then shows running on the merged commit forever. The state has lost its purpose (there is no merge left to block), it suggests activity that is not happening, and it accumulates: these pipelines never reach a terminal state on their own, and GitLab's stuck-pipeline handling does not touch jobless external pipelines.
Live example from our instance: an MR with two planned projects was merged after applying only one of them. Eight days later the merged commit's pipeline still shows atlantis/apply = running, and it will stay that way indefinitely.
Reproduction Steps
1. GitLab project, MR with two or more Atlantis projects.
2. atlantis plan all of them; atlantis apply only one.
3. Merge the MR (or close it).
4. The combined atlantis/apply status, and with it the external pipeline on the head commit, stays running forever.
Logs
Not applicable: nothing fails, so nothing is logged. The behaviour is the absence of a final status write after the MR is closed or merged.
Environment details
• Atlantis v0.47.1 (`ghcr.io/runatlantis/atlantis:v0.47.1-debian`), ECS Fargate
• GitLab self-managed 19.4.1
• Terragrunt custom workflow, config generated by terragrunt-atlantis-config
Additional Context
• Atlantis already reacts to MR close/merge events (locks and working directories are cleaned up), so there is a natural place to finalize the status.
• Suggested behaviour: when an MR is merged or closed with the combined atlantis/apply (or atlantis/plan) status still pending/running, write a terminal state. GitLab's commit status API accepts canceled (verified on 19.4.1), which describes the situation precisely: the work was abandoned, it neither succeeded nor failed. A description like "merge request closed with unapplied plans" would make it self-explanatory.
• The wrinkle: Atlantis's internal status model only knows pending/success/failed, and not every VCS has a canceled commit state (GitHub does not), so this would be a per-VCS mapping decision. We wanted the maintainers' view on the direction before attempting any code. If the direction is agreed, we are happy to test a patch against our live GitLab 19.4.1, as we did for #6932.
• Related observation, noted here for completeness: the /api/plan and /api/apply endpoints set the combined statuses to pending and never write a terminal state afterwards (server/controllers/api_controller.go, `apiPlan`/`apiApply`), so API-triggered runs against an MR strand `atlantis/plan`/`atlantis/apply` at running from the start. We noticed it while investigating, but we do not use these endpoints and do not have the capacity to propose a fix for that part; mentioning it in case it deserves its own issue.
• Background: we ran into this while chasing stranded running pipelines on our repos (#6871, and the diagnosis that led to closing #5368).
runatlantis/atlantisGitHub
09/29/2026, 1:47 PMatlantis apply via a comment in a PR, all apply pipelines are executed. Going all pipelines' states to "succeeded", the main pipeline, atlantis/apply will remain running.
The logs of the container will show something like this:
{"level":"info","ts":"2025-01-19T164725.659Z","caller":"events/instrumented_project_command_runner.go:88","msg":"apply success. output available at: https://mygitserver.example.com/rikstv/sre/rikstv.terraform.infra.atlantistesting/-/merge_requests/15","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
{"level":"info","ts":"2025-01-19T164727.254Z","caller":"vcs/gitlab_client.go:408","msg":"Updating GitLab commit status for 'atlantis/apply' to 'running'","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
{"level":"info","ts":"2025-01-19T164727.343Z","caller":"vcs/gitlab_client.go:433","msg":"Pipeline found for commit 682ac035b55d8193a729b02edef6f8e71c8944ab, setting pipeline ID to 202381","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
{"level":"warn","ts":"2025-01-19T164727.438Z","caller":"events/apply_command_runner.go:223","msg":"unable to update commit status: POST https://mygitserver.example.com/api/v4/projects/rikstv/sre/rikstv.terraform.infra.atlantistesting/statuses/682ac035b55d8193a729b02edef6f8e71c8944ab: 400 {message: Cannot transition status via :run from :running (Reason(s): Status cannot transition via \"run\")}","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"},"stacktrace":"github.com/runatlantis/atlantis/server/events.(*ApplyCommandRunner).updateCommitStatus\n\tgithub.com/runatlantis/atlantis/server/events/apply_command_runner.go:223\ngithub.com/runatlantis/atlantis/server/events.(*ApplyCommandRunner).Run\n\tgithub.com/runatlantis/atlantis/server/events/apply_command_runner.go:181\ngithub.com/runatlantis/atlantis/server/events.(*DefaultCommandRunner).RunCommentCommand\n\tgithub.com/runatlantis/atlantis/server/events/command_runner.go:383"}
I did some digging in the Go code, tag release-0.31. The error happens in atlantis/server/events/apply_command_runner.go , lines 214-224, running applyCommandRunner.commitStatusUpdater.UpdateCombinedCount.
Unless I am mistaken, that will call the method UpdateCombinedCount from atlantis/server/events/commit_status_updater.go, lines 69-83. In there, we have this definition:
src := fmt.Sprintf("%s/%s", d.StatusName, cmdName.String())
The string src is passed on to some `CommitStatusUpdater`'s method Client.UpdateStatus, whose result is what will be sent back to UpdateCombinedCount.
There is a number of `Client`s defined, one for each VCS supported by Atlantis. Since we are using Gitlab, I went into atlantis/server/events/vcs/gitlab_client.go. In line 411 we have:
logger.Info("Updating GitLab commit status for '%s' to '%s'", src, gitlabState)
Now, I was supposed to find a log line with severity info in the logs, and it's actually the second line in the log above, and that line puts all the pieces back together:
{"level":"info","ts":"2025-01-19T164727.254Z","caller":"vcs/gitlab_client.go:408","msg":"Updating GitLab commit status for 'atlantis/apply' to 'running'","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
So, basically, Atlantis is trying to transition a pipeline, whose current status is running, to running again, which is wrong and is rejected by Gitlab. In fact, the first line in the log I posted reports a success, and Atlantis should transition the atlantis/apply pipeline to success instead, not `running`:
{"level":"info","ts":"2025-01-19T164725.659Z","caller":"events/instrumented_project_command_runner.go:88","msg":"apply success. output available at: https://mygitserver.example.com/rikstv/sre/rikstv.terraform.infra.atlantistesting/-/merge_requests/15","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
So there is something in the Atlantis' logic that selects the destination state that is wrong (from running, it should go to success or failed, definitely not running).
I don't know where that logic is and what should be changed, you guys know better.
Reproduction Steps
• as a simple test, have a terraform stack that just emits outputs, without creating any infrastructure
• have a few terragrunt units, each one with its own state, that use the code mentioned above
In our case, the stack would be located at the path apps/sre/__selftest__, the terraform code would be in the _stack subdirectory, the units would be in environment-related directories, e.g. dev, prod..., each one with a terragrunt.hcl file that refers to the _stack directory. E.g.:
# terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "..//_stack"
}
# _stack/main.tf
output "foo" {
value = "foo"
}
output "bar" {
value = "bar"
}
You will find our custom workflow in the "Environment details" section. Keep in mind that the workflow is tightly bound to the repository's directory structure: if you use a different directory scheme than the one suggested here, you'll have to update the workflow, too.
Logs
{"level":"info","ts":"2025-01-19T164725.659Z","caller":"events/instrumented_project_command_runner.go:88","msg":"apply success. output available at: https://mygitserver.example.com/rikstv/sre/rikstv.terraform.infra.atlantistesting/-/merge_requests/15","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
{"level":"info","ts":"2025-01-19T164727.254Z","caller":"vcs/gitlab_client.go:408","msg":"Updating GitLab commit status for 'atlantis/apply' to 'running'","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
{"level":"info","ts":"2025-01-19T164727.343Z","caller":"vcs/gitlab_client.go:433","msg":"Pipeline found for commit 682ac035b55d8193a729b02edef6f8e71c8944ab, setting pipeline ID to 202381","json":{"repo":"rikstv/sre/rikstv.terraform.infra.atlantistesting","pull":"15"}}
{"level":"warn","ts":"2025-01-19T164727.438Z","caller":"events/apply_command_runner.go:223","msg":"unable to update commit status: POST <https://mygitserver.example.com/api/v4/projects/rikstv/sre/rikstv.…
runatlantis/atlantisGitHub
09/29/2026, 3:47 PMPOST /api/plan
• POST /api/apply
• POST /api/drift/detect
• POST /api/drift/remediate
How it happens (code links pinned to main @ 668fc54):
1. `Server.Start` calls waitForDrain(), then `server.Shutdown` with a 5s timeout, then returns, and the process exits.
2. The only callers of Drainer.StartOp() are `RunAutoplanCommand` and `RunCommentCommand`.
3. `APIController.Plan`, `Apply`, `Remediate` and `DetectDrift` run the whole operation synchronously inside the HTTP handler, and APIController has no reference to the Drainer.
So when the server gets SIGTERM while an API plan/apply is running, the drainer sees 0 in-progress operations (or only the webhook ones) and finishes right away. server.Shutdown then waits at most 5s for the open API request, and the process exits. The terraform/tofu process dies mid-run, even if the orchestrator (e.g. Kubernetes terminationGracePeriodSeconds) would have allowed much more time. The effects:
• Orphaned remote state locks (e.g. S3/DynamoDB) that have to be `force-unlock`ed by hand.
• POST /api/apply and POST /api/drift/remediate (with apply) can be cut off partway through an apply.
• The API caller gets a dropped connection instead of a clean error.
This matters most for drift detection and remediation, which are designed to be driven through the API by scheduled jobs, and for any rolling restart or node drain that happens while those jobs run.
Expected behavior: API operations are drained like webhook operations. In-flight API requests finish before the server exits, and new API requests during shutdown get a clear error (e.g. 503 Service Unavailable), the same way webhook commands get the "Atlantis is shutting down" comment.
Proposed fix (happy to open a PR once this is accepted): add the Drainer to APIController (wired in server.go). In Plan, Apply, Remediate and DetectDrift, call StartOp() before any setup (working dir, locks) and return 503 if it returns false, then defer OpDone(). Add tests for the 503 during shutdown and for ShutdownBlocking() waiting on an in-flight API request.
Reproduction Steps
1. Run Atlantis with the API enabled (--api-secret set) and a repo whose plan takes a while (e.g. a null_resource with a local-exec sleep 120, or any slow real project).
2. Start a plan via the API:
curl -sS -X POST https://<atlantis>/api/plan \
-H "X-Atlantis-Token: $ATLANTIS_API_SECRET" -H 'Content-Type: application/json' \
-d '{"Repository":"<owner>/<repo>","Ref":"main","Type":"Github","Paths":[{"Directory":"<dir>","Workspace":"default"}]}'
3. While the plan is running, send SIGTERM to the Atlantis server (e.g. kubectl delete pod with a long terminationGracePeriodSeconds, or kill -TERM <pid>).
4. Observe: the server logs "All in-progress operations complete, shutting down" right away (or after webhook ops only), exits within ~5s, the curl request fails with a dropped connection, and the terraform process is killed. With a remote backend, the state lock is left behind.
5. For comparison, repeat with atlantis plan commented on a PR: the server waits for the plan to finish before exiting.
Logs
Not captured from a controlled repro yet; this was found while investigating orphaned state locks and confirmed by reading the code. Based on Start() / waitForDrain(), the expected log sequence with only an API plan in flight is:
Received interrupt. Waiting for in-progress operations to complete
All in-progress operations complete, shutting down
with no "Waiting for in-progress operations to complete, current in-progress ops: N" lines, even though the API plan is still running.
Environment details
• Atlantis version: v0.48.0 (also present on main @ 668fc54)
• Deployment method: Kubernetes StatefulSet (plain manifests), custom image based on the official Alpine image
• If not running the latest Atlantis version have you tried to reproduce this issue on the latest version: v0.48.0 is the latest release; the code paths above are unchanged on main
• Atlantis flags (relevant): --enable-drift-detection, --enable-drift-remediation, --api-secret
Any other information you can provide about the environment/deployment: AWS, OpenTofu via Terragrunt custom workflows, S3 backend with state locking. Drift detection/remediation is triggered by a scheduled job through /api/drift/detect and /api/drift/remediate.
Additional Context
• Related: #5949 (best-effort graceful termination of terraform processes on shutdown). That issue is about signalling the terraform processes; this one is about API operations not being waited on at all.
• Original clean-shutdown work: #953, #1052. Recent drainer change: #6850.
• ADR 0002 (API Enhancement and Drift Detection) doesn't cover shutdown behavior for API-driven operations.
runatlantis/atlantisGitHub
10/02/2026, 4:44 PMatlantis.yaml file:
version: 3
projects:
- dir: prod
name: some-project
autoplan:
when_modified:
- "./*.tf"
- "./*.tfvars"
workflow: some-workflow
- dir: prod
name: my-project
autoplan:
when_modified:
- "./my-special-config/*.yaml"
workflow: my-special-workflow
Additional Context
runatlantis/atlantisGitHub
10/02/2026, 6:10 PMError: Backend initialization required, please run "terraform init"
Reason: Unsetting the previously set backend "s3"
Reproduction Steps
1. Open a PR that changes a root directory with a remote backend, e.g. apps/foo/ with an s3 backend.
2. atlantis plan so Atlantis inits that directory
3. Push a new commit that deletes apps/foo/
4. Run atlantis plan again
Expected: apps/foo/ is skipped since it no longer exists
Actual: Atlantis plans apps/foo/ and fails with the backend error above. Running atlantis plan again gives the same result. atlantis unlock doesn't help either, since it only removes plan files.
Workaround: close and reopen the PR appears to be a viable workaround, however the PR still shows a failed status check for the missing directories.
Logs
From the server logs when the delete commit was planned:
repo was already cloned but branch is not at correct commit, updating to "<sha>"
Before the upgrade we only ever saw this one on new commits:
repo appears to have changed base branch, must reclone
Plan output for the deleted directory:
running '/home/atlantis/.atlantis/bin/terraform1.14.4 plan -input=false -refresh -out /home/atlantis/.atlantis/repos/<org>/<repo>/<pr>/default/apps/foo/default.tfplan' in '/home/atlantis/.atlantis/repos/<org>/<repo>/<pr>/default/apps/foo': exit status 1
╷
│ Error: Backend initialization required, please run "terraform init"
│
│ Reason: Unsetting the previously set backend "s3"
╵
Environment details
• Atlantis version: v0.48.0
• Deployment method: ECS on Fargate, custom image built FROM <http://ghcr.io/runatlantis/atlantis:v0.48.0-alpine|ghcr.io/runatlantis/atlantis:v0.48.0-alpine>. No persistent volume, so the data dir resets on task restart.
• Terraform version: 1.14.4
• Checkout strategy: default (branch)
• Atlantis flags (env vars):
• ATLANTIS_DISABLE_AUTOPLAN=true
• ATLANTIS_AUTOPLAN_FILE_LIST=**/*.tf,**/*.yml,**/*.pkr.hcl,...
• ATLANTIS_ENABLE_POLICY_CHECKS=true
• ATLANTIS_QUIET_POLICY_CHECKS=true
• ATLANTIS_ALLOW_DRAFT_PRS=true
• ATLANTIS_ALLOW_REPO_CONFIG=false
• ATLANTIS_LOCKING_DB_TYPE=redis
• TF_CLI_ARGS_init=-reconfigure
Atlantis server-side config file:
repos:
- id: /.*/
apply_requirements: [approved]
allowed_overrides: []
allow_custom_workflows: true
policies:
owners:
teams:
- <team>
policy_sets:
- name: deny_instance_destroy
path: /home/atlantis/conftest_policies/deny_resource_destroy/
source: local
Repo atlantis.yaml file: none.
Additional Context
runatlantis/atlantisGitHub
10/02/2026, 10:19 PMGetModifiedFiles never returns. After the fifth 404, the inner loop ends without a return or a break listloop. The outer for {} starts the same page again, so the goroutine stays in the loop forever. Each loop sends about 5 requests every 26 seconds.
• GetPullRequest returns nil, nil. The loop declares pull, resp, err :=, which shadows the pull and err of the function. After five 404s, the function returns no pull request and no error. ParseGithubPull then reads pull.Head, which panics. PullIsMergeable gets an empty state and reports PR is in state <unknown>, instead of the 404.
A token that cannot see the repo gives such a 404. One case: Atlantis holds the installation of one org and gets a webhook from a second org that is on the allowlist. The PR in the second org gets no comment and no status. Each event from that org starts one more loop, and each loop uses the rate limit of the installation that works.
Reproduction Steps
1. Install one GitHub App in org A and in org B. The app webhook then sends the events of both orgs to Atlantis.
2. Start Atlantis with that app, and with --gh-app-installation-id set to the installation in org A.
3. Put both orgs on --repo-allowlist.
4. Open a PR in org B, so that Atlantis gets the pull_request webhook.
5. Atlantis replies 200 Processing.... Then GET /repos/<org B>/<repo>/pulls/<n>/files returns 404 again and again, and it does not stop.
We ran these steps against a mock of the GitHub API. One webhook caused 1,378 requests to /pulls/1/files in 2.5 hours, all of them 404, until we stopped the container.
On github.com, a token for the installation of one org gets a 404 for a repo in another org. We confirmed this with two installations of one app.
Logs
Logs, from the test against the mock
[attempt 1] GET /repos/infra-test-org/atlantis-multiorg-poc/pulls/1/files returned: 404
[attempt 2] GET /repos/infra-test-org/atlantis-multiorg-poc/pulls/1/files returned: 404
[attempt 3] GET /repos/infra-test-org/atlantis-multiorg-poc/pulls/1/files returned: 404
[attempt 4] GET /repos/infra-test-org/atlantis-multiorg-poc/pulls/1/files returned: 404
[attempt 5] GET /repos/infra-test-org/atlantis-multiorg-poc/pulls/1/files returned: 404
[attempt 1] GET /repos/infra-test-org/atlantis-multiorg-poc/pulls/1/files returned: 404
...
Environment details
• Atlantis version: v0.48.0. The code is the same on main.
• Deployment method: Docker, against a mock of the GitHub API.
• Atlantis flags: --gh-app-id, --gh-app-key-file, --gh-app-installation-id, --repo-allowlist with two orgs, --write-git-creds.
Additional Context
• `GetModifiedFiles`: server/events/vcs/github/client.go, the retry loop under listloop:.
• `GetPullRequest`: server/events/vcs/github/client.go, the retry loop that calls PullRequests.Get.
• A fix with tests for both is on tkensiski/atlantis, branch `fix/github-modified-files-404-loop`. I will open a PR that links this issue.
runatlantis/atlantisGitHub
10/04/2026, 2:27 AM--skip-clone-no-changes cannot skip an autoplan clone when a repository has no atlantis.yaml, even when the pull request changes no files in the configured autoplan file list. The pre-clone check returns false as soon as the remote repo config is absent, then autodiscovery runs after a full clone.
This makes unrelated pull requests vulnerable to unnecessary clone work and to the opened-event/closed-event cleanup race that deletes the workspace during the clone.
Proposed behavior
When the remote repo config is absent, use the configured autoplan-file-list to classify the modified paths before cloning:
• skip the clone when no modified path matches;
• keep the clone and normal autodiscovery when any path matches;
• leave explicit atlantis.yaml project handling unchanged.
This preserves autodiscovery and plan/apply behavior for Terraform, OpenTofu, variable, Terragrunt, and lock-file changes while avoiding clones for unrelated changes.
runatlantis/atlantisGitHub
10/04/2026, 11:44 AMGET /api/locks omitted an existing project lock, and closing the pull request left that lock behind. Direct scans of every cluster primary confirmed both discrepancies. A stale lock can block a later plan for the same project.
Reproduction Steps
1. Run Atlantis v0.48.0 with two replicas and a three-primary Redis-protocol-compatible cluster for locking. Set ATLANTIS_LOCKING_DB_TYPE=redis and `ATLANTIS_REDIS_CLUSTER_ADDRESSES`; do not set the single-node host/port options.
2. Open a pull request selecting three independent Atlantis projects. In our controlled reproduction, the PR changed comments only. All three plans succeeded with no infrastructure changes.
3. Confirm the three pr/* lock keys are distributed across at least two primaries. Scan each primary directly, following its SCAN cursor to completion. Confirm that each stored lock value identifies the PR.
4. Compare these keys with GET /api/locks.
5. Close the PR without applying or merging. Wait for Atlantis to finish processing the close event, then scan every primary for those exact keys again.
| Check | Expected | Observed |
| ------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| After three successful plans | Three locks in both /api/locks and the cluster | Three keys across two primaries; /api/locks returned only two |
| After PR close cleanup finished | All three locks removed | Atlantis removed all three saved plans and reported successful cleanup, but one lock owned by the closed PR remained on another primary |
There were no plan or apply jobs running on either replica when the remaining lock was checked. The PR was closed unmerged and no apply was run. A separate earlier run also left stale locks after PRs closed; this comment-only reproduction isolates the behavior from infrastructure changes.
Logs
The close-event logs reported deletion of all three saved plans and successful lock cleanup. Raw logs are omitted because they contain private repository and storage identifiers. The before/after cluster observations were recorded with exact lock ownership. Narrowly redacted log excerpts can be supplied if maintainers need particular messages.
Environment details
• atlantis version from the running pod: atlantis v0.48.0 (commit: 5a3bd40) (build date: 2026-09-22T02:57:08.336Z). The binary came unchanged from the official <http://ghcr.io/runatlantis/atlantis:v0.48.0|ghcr.io/runatlantis/atlantis:v0.48.0> image. Our surrounding runtime image adds infrastructure tools but does not rebuild Atlantis or change its locking code. v0.48.0 was the latest release when reproduced.
• Deployment: Kubernetes/Helm, using a chart based on Atlantis Helm chart 6.15.0; two Atlantis replicas share one locking cluster.
• Locking backend: Redis-protocol-compatible Valkey Cluster with three primaries and replicas. This was not independently repeated on a Redis-branded cluster.
• Relevant Atlantis settings (addresses are placeholders; authentication comes from a Kubernetes Secret):
ATLANTIS_LOCKING_DB_TYPE=redis
ATLANTIS_REDIS_CLUSTER_ADDRESSES=node-0.example.invalid:6379,node-1.example.invalid:6379,node-2.example.invalid:6379
ATLANTIS_ENABLE_EXTERNAL_STORES=true
Neither ATLANTIS_REDIS_HOST nor ATLANTIS_REDIS_PORT was configured.
• Relevant server-side repo config (private identifiers omitted):
external_stores:
plan_store:
type: s3
repos:
- id: <private-repository>
repo_config_file: <private-repo-config-file>.yaml
repo_locks:
mode: on_plan
• Relevant repo config structure: version: 3, parallel_plan: true, and three explicit projects with distinct directories. The actual alternate repo-config filename, project names, paths, branch rules, and unrelated workflows are omitted.
Additional Context
• Atlantis documents the cluster-address setting.
• In v0.48.0, `List` and `UnlockByPull` each use one r.client.Scan(...).Iterator() to discover locks. The migration path uses the same pattern. This is a source-level inference: go-redis `Scan` does not explicitly traverse every cluster primary, while `ForEachMaster` provides that traversal. A scan routed to one node could explain both observations.
• Issue #6765 concerns delayed unlocking while PR cleanup is still running. Here the close handler had finished, yet an owned lock remained.
Could maintainers confirm the intended cluster-wide scan behavior and add a regression case with lock keys on different primaries? Listing, PR-close unlock, and migration appear to need explicit traversal of all primaries. Cleanup should also avoid deleting a lock if its ownership has changed since discovery.
runatlantis/atlantisGitHub
10/05/2026, 1:50 PMGitHub
10/05/2026, 8:40 PMGitHub
10/06/2026, 2:11 AMremoved blocks with destroy = false, I want Atlantis to display the "to forget" count in the plan summary, but currently this information is not captured by the plan parsing regex.
Describe the solution you'd like
Update the rePlanChanges regex in server/events/models/models.go to optionally capture the "to forget" count, similar to how "to import" was added in #3621.
Current regex:
rePlanChanges = regexp.MustCompile(Plan: (?:(\d+) to import, )?(\d+) to add, (\d+) to change, (\d+) to destroy.)
Proposed regex (example):
rePlanChanges = regexp.MustCompile(Plan: (?:(\d+) to import, )?(\d+) to add, (\d+) to change, (?:(\d+) to forget, )?(\d+) to destroy.)
This would allow Atlantis to properly parse and display plan output like:
Plan: 3 to add, 0 to change, 2 to forget, 0 to destroy.
Background
Terraform 1.7 and OpenTofu 1.7 introduced the removed block, which allows declaratively removing resources from state without destroying them:
removed {
from = aws_instance.example
lifecycle {
destroy = false
}
}
When destroy = false, the plan output includes "X to forget" to indicate resources being removed from state management. This is the declarative equivalent of terraform state rm.
References:
• Terraform removed block docs
• OpenTofu removed block PR
• PR #3621 - precedent for adding "to import"
Describe the drawbacks of your solution
• Minor increase in regex complexity
• Need to update PlanSuccessStats struct and related code to track the new count
• Should be backward compatible since "to forget" is optional in the regex
Describe alternatives you've considered
• Ignoring the "to forget" count: Not ideal because users lose visibility into state-only changes
• Manual state management: Using terraform state rm outside of Atlantis workflow, but this defeats the purpose of declarative IaC
runatlantis/atlantisGitHub
10/06/2026, 9:21 PMPOST /api/plan, POST /api/drift/detect, POST /api/drift/remediate), I want one request covering many projects to plan them in parallel, the same way atlantis plan does on a pull request when parallel_plan: true is set.
Right now the API plans one project at a time. A drift detection request across a few dozen projects takes the sum of every plan's duration. Long requests also run into HTTP timeouts (see #6795).
The cause is in server/controllers/api_controller.go. apiPlan loops over the plan commands and calls events.RunOneProjectCmd for each project in turn, and policy checks use the same plain loop. parallel_plan, parallel_policy_check and --parallel-pool-size are only honored by the PR command path (runProjectCmdsParallel / runProjectCmdsWithCancellationTracker in server/events/project_command_pool_executor.go).
The project contexts the API builds already carry ParallelPlanEnabled and ParallelPolicyCheckEnabled, set the same way as for PR commands. The only missing pieces are the pool size and a parallel code path.
The only current workaround is to split the projects across several concurrent API requests on the client. That works because each request gets its own synthetic pull number and working dir. But it pushes the scheduling onto every client, and each request repeats the clone and hook overhead.
Describe the solution you'd like
Have API plans and policy checks honor the existing settings. No new configuration:
• Use a bounded pool when --parallel-pool-size > 1, the request has more than one project, and the projects have parallel_plan enabled (parallel_policy_check for policy checks). Otherwise keep the current sequential loop unchanged.
• Run at most --parallel-pool-size projects of one request at once.
• Respect execution order groups: a group starts only after the previous group has finished.
• Return results in request order rather than completion order, so API responses and drift results stay deterministic.
• Never stop early: every project gets a result, matching today's API behavior.
• In parallel mode, run all pre-workflow hooks first, then the batch of plans, then all post-workflow hooks. Hooks share the working dir with the plans, so interleaving them with concurrent plans is unsafe. Sequential mode keeps the current per-project pre → plan → post order.
• Covers /api/plan, /api/drift/detect, and the plan step of /api/drift/remediate.
• Keep applies sequential. The non-PR apply path runs ValidateNonPRAPIRefUnchanged, which does a git fetch in the shared clone. Running those fetches concurrently would race on git ref locks. Parallel API apply could be a follow-up that does the ref check once per request before the batch.
Concurrent plans in one clone rely on the same mechanisms PR parallel plans already use: the per-project lock, GitReadLock held during runSteps, and the read-lock fast path in Clone() from #6376. The API resolves the requested ref to a commit SHA before building commands, so each project's Clone() takes that fast path.
I have a working implementation, with tests run under -race. The main parts:
• A new exported helper in project_command_pool_executor.go, RunProjectCmdsParallelInOrder(cmds, runnerFunc, poolSize), built on sizedwaitgroup. It waits for the pool to drain when the execution order group changes and writes results by index.
• A ParallelPoolSize field on APIController, wired from userConfig.ParallelPoolSize in server.go.
• A parallel branch in apiPlan for plans and for policy checks. apiApply is unchanged.
• One-line doc notes for parallel_plan, --parallel-pool-size, and the drift detection endpoint.
Describe the drawbacks of your solution
• Behavior change for existing users: anyone with parallel_plan: true gets parallel API plans after upgrading. That means more concurrent Terraform processes from one request, so more CPU and memory use and more pressure on provider rate limits. This matches what they already opted into for PRs, but it's new on the API path. If maintainers prefer, it could sit behind an opt-in flag, but I'd rather reuse the existing setting.
• Hook ordering differs between modes: in parallel mode, a post-workflow hook for project A runs after every plan in the batch, not right after A's plan. Hooks that assume strict interleaving would see a different order. It's the same trade-off the PR path makes.
• Shared caches: the known plugin and provider cache races under parallel plans (#2412, #5984) now apply to API requests too. This adds no new mechanism, just more exposure.
• Conflicts with in-flight work: this touches the same apiPlan code as #5994 (hook order) and the open plan-generation PRs (#6657, #6855). Whichever lands later needs a rebase.
Describe alternatives you've considered
• Client-side concurrency (several smaller API requests in flight): works today, but every client has to reimplement scheduling, server-side --parallel-pool-size can't bound it, and clone and hook overhead repeat per request.
• Async drift detection (#6795): solves the HTTP-timeout side of long runs but not the total runtime. The two are complementary.
• Parallelizing applies too: left out of scope because of the git fetch race described above.
runatlantis/atlantisGitHub
10/06/2026, 11:49 PM/api/plan, /api/apply, /api/drift/detect, /api/drift/remediate) run under a synthetic negative pull number from `nextNonPRPullNum()`. Project locks taken during the plan belong to that number, and only a defer a.Locker.UnlockByPull(...) in the handler releases them.
If that defer never runs, the locks stay forever:
• The process is killed or restarted mid-request (see #6934: API operations are not drained on shutdown).
• The defer is registered only after apiPlan returns without error (`DetectDrift`, `Plan`, `Apply`). apiPlan can return an error after some projects were already planned and locked, for example a failing pre-workflow hook with --fail-on-pre-workflow-hook-error on a later project.
Unlike a PR lock, nothing ever releases a synthetic one. There is no PR to close or merge, and the number is never reused (it is time-based). Every later PR plan and API call on those projects fails with This project is currently locked by an unapplied plan from pull #-993524529, until someone deletes each lock in the UI.
We currently have 19 such locks from three synthetic pulls. Two came from a pod eviction during a nightly drift run; one is from 2026-08-24 and its cause is unknown.
Reproduction Steps
1. Start POST /api/drift/detect (or /api/plan) for several projects.
2. Restart the Atlantis process while the request is in flight.
3. `GET /api/locks`: the projects from the in-flight request are still locked with a negative PullID, and stay locked after restart.
Logs
Not attached. GET /api/locks shows entries like {"PullID": -993524529, "PullURL": "", "ProjectRepoPath": "live/...", "Time": "2026-10-02T11:53:29Z"}.
Environment details
• Atlantis version: v0.48.0 (code above checked against main @ 4149b21)
• Deployment method: Kubernetes StatefulSet, single replica, BoltDB on a persistent volume
• Atlantis flags: drift detection and remediation enabled, --parallel-plan
Additional Context
Possible fixes (any of these would help):
1. Register the UnlockByPull defer right after apiSetup, before apiPlan, in every API handler.
2. On server startup, delete locks whose PullID is negative. No live request can own them after a restart.
runatlantis/atlantisGitHub
10/07/2026, 2:36 PMdb.Database interface. --locking-db-type picks BoltDB or Redis for all of it at once, and the Redis connection is configured through dedicated --redis-* flags.
• Plan artifacts go through the planstore.PlanStore interface. --enable-external-stores plus the external_stores.plan_store block in the server-side repo config picks local filesystem or S3.
• Job output lives in process memory with no backend at all.
Two problems fall out of this. Connection configuration is tied to a single use case: the Redis flags exist only to serve locking, so pointing a second function (say, plans) at the same Redis requires new flags. And each new function invents its own selection mechanism: locking uses a flag, plans use a yaml block plus an enable flag, and a future jobs store would add a third pattern.
Decision
Split the two concepts and give each a home:
• A backend declares how to reach a storage service: connection parameters, credentials, endpoint. Backends are configured once per type (boltdb, filesystem, redis, `s3`; memory is a future candidate) and know nothing about what data they hold. boltdb and filesystem live under the server's --data-dir and work without configuration. One instance per type keeps the config flat; if separate clusters of one type are ever needed, a named-instance form can be added without breaking this schema.
• A store names a function that persists data: an interface scoped to one use case, plus one driver per backend type it supports. Each store binds to a backend type in config through its own schema, since stores don't share the same options; both take only backend today. On s3, each store appends its own key segment (plans/) under the backend's prefix, so stores sharing the backend cannot collide. Stores are coordination and plans today; jobs, logs, and remote work executors are future candidates.
Naming
coordination covers everything behind today's `db.Database`: project locks, the global apply/command lock, and pull status. These share transactional key-value semantics and always move between backends together. Pull status is state rather than a lock, so it may split into its own store later; nothing in this design blocks that.
plans covers plan artifacts — the .tfplan files a store must survive a server restart with. It replaces the external_stores.plan_store block from #6312, which is deleted rather than deprecated because it never reached a release (latest tag v0.46.0 predates it).
Backend types always use the concrete technology name — boltdb, filesystem, redis, s3, later mysql or memory — so the yaml states what actually holds the data; an earlier draft had a local umbrella type whose meaning varied per store, dropped for exactly that ambiguity.
Configuration
Backends and bindings live in the server-side repo config:
backends:
redis:
host: redis.internal
port: 6379
tls_enabled: true
s3:
bucket: atlantis-data
region: us-east-1
prefix: some/team
stores:
coordination:
backend: redis
plans:
backend: s3
| store | local | redis | s3 |
| ------------ | ------------------------------- | ------- | --- |
| coordination | yes (BoltDB file) | yes | no |
| plans | yes (workdir files, no restore) | planned | yes |
| jobs | yes (in memory) | planned | no |
Package layout
server/core/backends/ # connection layer, function-agnostic
backends.go # Kind, Backend handles, lazy Registry
boltdb.go # bolt file open
redis.go # Redis dial (TLS, cluster selection, ping)
s3.go # S3 client construction
resolve.go # legacy-flag synthesis (removed with the flags)
server/core/coordination/ # store: locks, apply lock, pull status
coordination.go # LockStore/PullStatusStore/CommandLockStore + Store + NewStore(backend)
locking.go # Locker client (was core/locking)
apply_locking.go # ApplyLocker client (was core/locking)
pull_status_updater.go # pull-status write policy (was events.DBUpdater)
boltdb/ # driver (was core/boltdb)
redis/ # driver (was core/redis)
server/core/planstore/ # store: plan artifacts
plan_store.go # PlanStore interface
factory.go # New(backend) factory
s3_plan_store.go # s3 driver
This change absorbs the old server/core/db (interface, now coordination.Store), server/core/boltdb, server/core/redis, and server/core/locking packages into server/core/coordination. The locking package merged in rather than staying a consumer because its entire content (Locker client, NoOpLocker, ApplyLocker) wraps the coordination store; the lock-key parsing the drivers needed from it moved to models.ParseLockKey, which also removes the driver→service import.
The convention for a new store is a package under server/core/<store>/ owning its interface, its service API, a New*(backend) factory that switches on backend kind, and one driver file or subpackage per supported kind. The convention for a new backend type is a Kind constant, a config struct in `raw`/`valid`, and a client constructor in backends — no store code changes.
Constructed backends are typed handles behind the small backends.Backend interface — BoltDBBackend{DB}, FilesystemBackend{}, RedisBackend{Client}, S3Backend{Client, Bucket, Prefix} — mirroring the per-type config structs, so a handle cannot carry another type's fields. Store factories type-switch on the concrete handle and take the client, not the config: opening the bolt file, dialing redis (TLS, cluster selection, ping), and building the S3 client happen once in the registry, and store drivers only add their data layout on top (bucket creation and key migration for boltdb, key schema for redis). The registry constructs backends lazily on first Get, so a configured-but-unbound backend is never dialed.
Related consolidation
Landing with this change: coordination.Store splits into LockStore, PullStatusStore, and CommandLockStore so consumers depend on the slice they touch; events.DBUpdater becomes coordination.PullStatusUpdater (pull-identity freshness helpers move to models, result-classification types `DirNotExistErr`/`ErrStaleCommandHead` to command); and NoOpLocker is deleted — per-project repo_locking: false and --disable-repo-locking early-return inside DefaultProjectLocker.TryLock instead of swapping in a fake locker. That last change means the locks UI and unlock paths under --disable-repo-locking now query the real store: pre-existing locks become visible and deletable rather than silently ignored, and an unreachable redis surfaces as an error where the no-op never could fail.
Legacy flags
`--locking…
runatlantis/atlantisGitHub
10/14/2026, 4:53 PM--gh-allow-mergeable-bypass-apply and the mergeable apply requirement, Atlantis applies PRs that GitHub will not merge because of required conversation resolution. The apply succeeds, then automerge fails:
Automerging failed:
merging pull request: PUT <https://api.github.com/repos/<owner>/<repo>/pulls/<n>/merge>: 405 Repository rule violations found
A conversation must be resolved before this pull request can be merged.
The infrastructure is changed, but the PR stays open, so the default branch no longer matches what was applied.
When atlantis/apply is a required check, GitHub reports the PR as blocked before apply. PullIsMergeable then calls IsMergeableMinusApply (server/events/vcs/github/client.go). That function checks:
• reviewDecision
• the requiredApprovingReviewCount from active ruleset pull_request rules
• required status checks (branch protection and rulesets), excluding atlantis/apply
• required workflows
It never reads requiredReviewThreadResolution (ruleset pull_request rule) or requiresConversationResolution (branch protection), and it doesn't look at the PR's review threads. A PR that is blocked only by atlantis/apply and an unresolved conversation therefore passes the bypass as mergeable.
Reproduction Steps
1. Use a repo whose default branch has an active ruleset with a pull_request rule that requires conversation resolution (or classic branch protection with "Require conversation resolution before merging"), plus atlantis/apply as a required status check.
2. Run Atlantis with --gh-allow-mergeable-bypass-apply and --automerge, and set apply_requirements: [mergeable].
3. Open a PR, get the required approval, and leave one review comment thread unresolved.
4. Comment atlantis apply.
Expected: the apply is rejected because the PR isn't mergeable (the same as an unmet approval or a failing required check).
Actual: the apply runs and automerge fails with the 405 above.
Logs
Logs
Automerging failed:
merging pull request: PUT <https://api.github.com/repos/<owner>/<repo>/pulls/<n>/merge>: 405 Repository rule violations found
A conversation must be resolved before this pull request can be merged.
[]
Environment details
• Atlantis version: v0.48.0. The relevant code in server/events/vcs/github/client.go is unchanged on main.
• Deployment method: Kubernetes StatefulSet
• Atlantis flags: --gh-allow-mergeable-bypass-apply, --automerge, GitHub App auth
Atlantis server-side config file:
repos:
- id: /.*/
apply_requirements: [mergeable]
Additional Context
Proposed fix (I have it implemented with tests and am happy to open a PR once this is accepted):
• Add requiredReviewThreadResolution to the PullRequestParameters fragment and requiresConversationResolution to branchProtectionRule in the GetPullRequestMergeabilityInfo query.
• Add reviewThreads(first: 100, after: $threadCursor) { pageInfo { endCursor hasNextPage } nodes { isResolved } } to the pull request and page through it in the existing rules/contexts pagination loop. If it stopped at the first page, an unresolved thread after the first 100 would still let the apply through.
• In IsMergeableMinusApply, return not mergeable when resolution is required and any thread is unresolved.
References:
• GitHub docs: Require conversation resolution before merging
• GitHub GraphQL: `PullRequestParameters.requiredReviewThreadResolution`
runatlantis/atlantis