Custom Go import paths

by about Go, Software Engineering in Technology

Most Go import paths start with github.com. Wouldn’t it be cool if you could use your own domain name there?

The go module system relies heavily on the version control system as a backend. As a direct result, most packages have an import path of github.com/yourname/yourpackage. Wouldn’t it be cool if you could use your own domain name there? For example:

import "go.debugged.it/validation"

It’s much nicer to read, and also gives you independence from GitHub if that is something you value.

You may think that you have to host your own Git server to do this, but no, that’s not actually the case! The secret is buried deep in the Go documentation. You can do this very easily by creating an HTML file located at https://go.debugged.it/validation:

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- Meta tag for go get -->
    <meta name="go-import"
          content="go.debugged.it/validation
                   git https://github.com/haveyoudebuggedit/go-validation" />
    <!-- Meta tag for godoc -->
    <meta name="go-source"
          content="go.debugged.it/validation
                   https://github.com/haveyoudebuggedit/go-validation
                   https://github.com/haveyoudebuggedit/go-validation/tree/main{/dir}
                   https://github.com/haveyoudebuggedit/go-validation/blob/main{/dir}/{file}#L{line}" />
    <!-- Redirect human visitors -->
    <meta http-equiv="refresh" content="0; url=https://github.com/haveyoudebuggedit/go-validation">
</head></html>

That’s it! The best part is, you can use GitHub Pages to host this subdomain.

The easy way ▲ Back to top

If you want this done the easy way, say no more. We built a template repository for you that you can simply use to create your own redirector.

The hard way ▲ Back to top

Since you asked for it, let me show you how you can automate the entire process. First, you will need to create a little generator script that takes a config file in this format:

{
  "packagename": "https://github.com/yourname/packagename"
}

Let’s start our program by defining a template for the HTML file:

package main

var templateText = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta name="go-import"
          content="go.debugged.it/{{ .Name }}
                   git {{ .URL }}" />
    <meta name="go-source"
          content="{{ .URL }}
                   {{ .URL }}/tree/main{/dir}
                   {{ .URL }}/blob/main{/dir}/{file}#L{line}" />
    <meta http-equiv="refresh" content="0; url={{ .URL }}">
</head></html>
`

Simple enough, but don’t forget to replace the domain name with your own here. Next, we create our structure for the config file:

type config map[string]string

And as a last piece of preparation, let’s create a data structure for rendering the template:

type entry struct {
    Name string
    URL  string
}

Next, comes the main() function. This will create a directory and an index.html file for each of our packages from the config file. First, we read and then parse the packages.json file. We also compile our template. Then we create the gh-pages directory and loop over all the packages. In each loop we create the package directory, create the index.html file, and render the template.

Finally, we’ll create the CNAME file for GitHub Pages. Again, don’t forget to add your own domain name.

func main() {
    data, err := ioutil.ReadFile("packages.json")
    if err != nil {
        panic(fmt.Errorf("failed open %s (%w)", "packages.json", err))
    }
    cfg := &config{}
    if err := json.Unmarshal(data, cfg); err != nil {
        panic(fmt.Errorf("failed load %s (%w)", "packages.json", err))
    }
    tpl := template.Must(template.New("html").Parse(templateText))
    
    ghPagesDir := "gh-pages"
    if err := os.MkdirAll(ghPagesDir, 0755); err != nil {
        panic(fmt.Errorf("failed to create dir %s (%w)", ghPagesDir, err))
    }
    
    for name, url := range *cfg {
        e := entry{
            name, url,
        }
        dir := filepath.Join(ghPagesDir, name)
        if err := os.MkdirAll(dir, 0755); err != nil {
            panic(fmt.Errorf("failed to create dir %s (%w)", dir, err))
        }
        file := filepath.Join(ghPagesDir, name, "index.html")
        fh, err := os.Create(file)
        if err != nil {
            panic(fmt.Errorf("failed to open %s (%w)", file, err))
        }
        if err := tpl.Execute(fh, e); err != nil {
            panic(fmt.Errorf("failed to render template (%w)", err))
        }
    }

    cnameFile := filepath.Join(ghPagesDir, "CNAME")
    if err := ioutil.WriteFile(cnameFile, []byte("go.debugged.it"), 0644); err != nil {
        panic(fmt.Errorf("failed to write CNAME file %s (%w)", cnameFile, err))
    }
}

That’s it! Now the only thing that’s left is to automate the process via GitHub Actions. To do that, let’s add our gh-pages directory to the .gitgnore file and create a file named .github/workflows/build.yml with the following content:

name: Generate
on:
  push:
  pull_request:
jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Set up Go
        uses: actions/setup-go@v2
        with:
          go-version: 1.18
      - name: Build
        run: go run generate.go
      - name: Upload artifacts
        uses: actions/upload-artifact@v2
        with:
          name: gh-pages
          path: gh-pages/*
          if-no-files-found: error
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    concurrency:
      group: "gh-pages-deploy"
      cancel-in-progress: false
    needs:
      - build
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: gh-pages
          path: dist
      - name: Download artifacts
        uses: actions/download-artifact@v2
        with:
          name: gh-pages
          path: artifacts
      - name: Deploy
        run: |
          set -euo pipefail
          rsync -az --exclude=.git --delete ./artifacts/ ./dist/
          cd dist
          git config user.name "GitHub Actions"
          git config user.email noreply@github.com
          git add .
          if ! git diff-index --quiet HEAD --; then
            git commit -m "Publish"
            git push --set-upstream --force origin gh-pages
          fi          
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

That’s a pretty hefty chunk of code, but it makes sure that you can run the publishing without any third party dependencies. It consists of two jobs: the build job runs on every commit, even on pull requests, to verify that the code generation works. The deploy job, on the other hand, runs only on the main branch. (Don’t forget to change this if you are using master.) We also make sure it runs only one job at a time, as indicated by the concurrency setting.

Now that all that’s done, please commit and push these changes to GitHub before proceeding.

As a final step, we will need to create the gh-pages branch, otherwise the integration won’t run properly. To do that, let’s create an empty folder and run the following commands, adding your own repository name:

git init
git checkout -b gh-pages
git commit --allow-empty -m "Initial commit"
git remote add origin https://github.com/yourname/yourrepo.git
git push -u origin gh-pages

That’s it! If you’ve done everything right, you should now be the proud owner of a Go package redirector site.