Never deploy an untagged dependency again

Apr 26, 2024·
Gert de Pagter
Gert de Pagter
· 3 min read

Today we ran into an issue with a build in one of our projects. Composer could no longer install, as a dependency was gone.

When we opened our pipeline logs we saw the following error:

failed to execute git checkout '<ref>' -  
- && git reset --hard '<ref>' --          

fatal: reference is not a tree: <ref>

It looks like the commit hash is not available in the repository, maybe the  
commit was removed from the branch? Run "composer update acme/dependency" 
to resolve this.

And in our composer.json we found a dependency like this:

{
  "require": {
    "acme/dependency": "dev-feat-new-things as 4.3.1"
  }
}

Now this worked fine when it got merged, except that after the merge, in the acme/dependency package, the feat-new-things branch got merged into main, so it no longer existed. Therefore, composer stopped being able to find it.

Now while working on a project it’s perfectly fine to put a dependency on a dev branch while you are working on that branch for a new feature, but before releasing it, you want to make sure to actually tag this. Normally we put a comment on a merge request to remind ourselves to tag the release of the package before merging. But this time that was forgotten, and the merge request was merged without a tag of the dependency.

The fix

In order to fix this we wrote the following PHPUnit test.

final class NoDevBranchDependenciesTest extends TestCase
{
    public function testComposerHasNoDevBranchDependencies(): void
    {
        $composer = json_decode(
            file_get_contents(__DIR__ . '/../../composer.json') ?: '',
            true,
            flags: JSON_THROW_ON_ERROR
        );
        $this->assertIsArray($composer);
        $requireDev = $composer['require-dev'] ?? [];
        $this->assertIsArray($requireDev);

        $require = $composer['require'] ?? [];
        $this->assertIsArray($require);

        $branchDependencies = array_filter(
            array_merge($requireDev, $require),
            fn ($version) => str_starts_with($version, 'dev-'),
        );

        $this->assertEmpty(
            $branchDependencies,
            sprintf(
                'Found the following dependencies with a dev key: "%s"',
                implode(', ', array_keys($branchDependencies))
            )
        );
    }
}

Lets go over it step by step and see what it does:

$composer = json_decode(
    file_get_contents(__DIR__ . '/../../composer.json') ?: '',
    true,
    flags: JSON_THROW_ON_ERROR
);
$this->assertIsArray($composer);
$requireDev = $composer['require-dev'] ?? [];
$this->assertIsArray($requireDev);

$require = $composer['require'] ?? [];
$this->assertIsArray($require);

This first bit grabs the contents of the composer.json, and decodes it. The lines after that are grabbing the production and dev dependencies, and does some type checking on this.

$branchDependencies = array_filter(
    array_merge($requireDev, $require),
    fn ($version) => str_starts_with($version, 'dev-'),
);

In the next bit we are looping over all the dependencies, and we only keep the ones that start with dev-, which is used by composer to point to branches.

$this->assertEmpty(
    $branchDependencies,
    sprintf(
        'Found the following dependencies with a dev key: "%s"',
        implode(', ', array_keys($branchDependencies))
    )
);

After we have filtered out the dependencies, all that is left to do is report if we found any. And of course just reporting that an array is not empty to a developer isn’t going to make much sense to them. So we provide a nice message to the other developers to tell them what they did wrong.

By adding this test we make sure that the pipeline stays red until the dependency points to a proper tag. This way it can’t be merged, and we’ll never run into an untagged dependency being merged into main again.

If you want to get notified of the next blog post, join the newsletter.