I'm writing this as an answer because of space restrictions but this is more of a reply to @TobySpeight.
Thanks to your help I've improved my Makefile to create debug and release builds separately but I had to make additional changes beyonf what you wrote. I am recording them here for future posterity.
I had to change the install target so instead of depending on the release target which no longer exists, it cds to the release directory and builds there first. In my top level Makefile:
install:
@cd release && $(MAKE) install-$(PROGRAM)
...and in the release Makefile:
install-$(PROGRAM): $(PROGRAM)
$(INSTALL) -m755 -D -d $(DESTDIR)$(PREFIX)/$(BINDIR)
$(INSTALL) -m755 $< $(DESTDIR)$(PREFIX)/$(BINDIR/$(PROGRAM)
With the new setup, what happens if a user runs make in the top-level directory? They are going to get errors. Or what if they run make distclean in a subdirectory? First I defined a function in the top-level Makefile to tell where we are. This was unexpectedly complicated but this works:
get_builddir = '$(findstring '$(notdir $(CURDIR))', 'debug' 'release')'
I defined two new targets:
checkinbuilddir:
ifeq ($(call get_builddir), '')
$(error 'Change to the debug or release directories and run make from there.')
endif
checkintopdir:
ifneq ($(call get_builddir), '')
$(error 'Make this target from the top-level directory.')
endif
Then I had my $(PROGRAM) and $(DISTCLEAN) targets depend on them:
$(PROGRAM): checkinbuilddir $(OBJECTS) | checkinbuilddir
$(LINK.cc) $(OUTPUT_OPTION) $^
$(STRIP)
distclean: | checkintopdir
cd debug && $(MAKE) clean
cd release && $(MAKE) clean
So this will be my stock Makefile going forward. I still don't deal with e.g. building libraries or multi-binary programs but I'm satisfied with it for now. Thanks once again.