Why does publish_{custom-post-type} fire on update?

First of all you have to understand that when we update a post, wp_update_post function is called. But due to a bit not optimal design of WP core, the actual saving is processed by wp_insert_post function. See it in trac on line 3006.

Ok, next lets see what is inside of wp_insert_post function. As you can see, on line 2950, save_post action is called each time the function is called, whenever it has been called directly or by wp_update_post function. It means that it doesn’t suit to determine if post has been inserted/published or updated.

To find better action, lets take a look at wp_transition_post_status function which is called almost just before save_post action, see it on line 2942. This function does three actions transition_post_status, {$old_status}_to_{$new_status} and {$new_status}_{$post->post_type}, see it on line 3273. Here we have nice action transition_post_status, which passes old and new post statuses. This is what we need. So if new status is publish and old status is not publish, then the post is published. And if new status is publish and old status is new, then the post has been inserted. And finally if new status equals to old status, then the post has been just updated.

Here is your snippet:

add_action( 'transition_post_status', 'wpse_transition_post_status', 10, 3 );  

function wpse_transition_post_status( $new_status, $old_status, $post ) {
    if ( $new_status == 'publish' && $old_status == 'new' ) {
        // the post is inserted
    } else if ( $new_status == 'publish' && $old_status != 'publish' ) {
        // the post is published
    } else {
        // the post is updated
    }
}

P.S.: read the code of the WordPress core, each time when you in doubts, it will help you a lot!

tech