I solved this using the query tag:
{exp:query sql="select distinct t1.cat_url_title, t1.cat_name from exp_categories t1, exp_category_posts t2, exp_weblog_titles t3 where t1.cat_id = t2.cat_id and t2.entry_id = t3.entry_id and t3.weblog_id = '3'"}
<a href="{path=template_group}tags/{cat_url_title}">{cat_name}</a><br />
{/exp:query}
Make sure you change weblog_id to be the one you actually want. Also, that doesn’t take dates into account at all—if you only want to show tags for posts that haven’t happened yet, it needs to be:
{exp:query sql="select distinct t1.cat_url_title, t1.cat_name from exp_categories t1, exp_category_posts t2, exp_weblog_titles t3 where t1.cat_id = t2.cat_id and t2.entry_id = t3.entry_id and t3.weblog_id = '3' and convert_tz(from_unixtime(entry_date), '+00:00', '-5:00') < now()"}
<a href="{path=template_group}tags/{cat_url_title}">{cat_name}</a><br />
{/exp:query}
I make sure it’s exact by converting the entry_date timestamp (which is stored in GMT) to my time zone (in this case, -5 hours). If you use that make sure to put your own time zone time. If you don’t care about time zones, only the date, it can just be “and from_unixtime(entry_date) < now()”.
Hopefully that helps somebody.
-Matt