wp_enqueue_script belongs in a hook, not in the template

Calling wp_enqueue_script() from inside a template survives review because it appears to work. By the time a template file runs, wp_head has already fired, so the handle is either printed in the footer by accident or dropped without a word — and the dependency array, which is the entire reason the function exists, is never consulted.

function catalogue_assets() {
    wp_enqueue_style(
        'catalogue',
        get_stylesheet_directory_uri() . '/css/catalogue.css',
        array(),
        '1.2.0'
    );

    if ( is_singular( 'product' ) ) {
        wp_enqueue_script(
            'catalogue-gallery',
            get_stylesheet_directory_uri() . '/js/gallery.js',
            array( 'jquery' ),   // load after the bundled jQuery, in noConflict mode
            '1.2.0',
            true                 // before </body>
        );
    }
}

add_action( 'wp_enqueue_scripts', 'catalogue_assets' );

wp_enqueue_scripts is the hook for the front end; admin_enqueue_scripts and login_enqueue_scripts are the other two, and using the front-end one for an admin screen is why a script sometimes loads on the site and not in the dashboard. Registering here means WordPress resolves the dependency graph and emits handles in a valid order, so nothing has to know what anything else needs, and the version argument becomes a real cache-buster — it should change with the file rather than sit at the theme version forever. The conditional is the part that pays: the hook fires on every request, so is_singular() inside it sends the gallery only where there is a gallery, which a <script> tag in header.php can never do. One thing not to do while you are in there: wp_deregister_script( 'jquery' ) in favour of a CDN copy breaks every plugin expecting the bundled 1.7.2 in noConflict mode, and the breakage does not appear on the pages you tested.