Maintaining a private package namespace as a git repo

I’ve seen private package management come up more frequently, so I thought I’d make the process I use more public with this post. A few disclaimers up front:

  • you can’t use this with the web app
  • the people using the private package namespace will need to know a bit of git
  • the script I wrote to help with this is only tested on Linux

(The good thing about these limitations is that I don’t fear that this will impact Typst’s ability to monetize this more advanced use case for more general audiences.)


The goal is to have a number of packages available to import under some namespace, for example:

#import "@pria/invoice:0.1.1"

(I’m using PRIA as the example because it’s the real association I’m doing it for, and because why not)

For this, we need the package to ultimately land at the path {data-dir}/typst/packages/pria/invoice/0.1.1, like described in the typst/packages repo.

My approach has the following parts:

  • each package is a git repo, created from a fork of GitHub - typst-community/typst-package-template
  • releases of these packages are not released to typst/packages, but our private “package registry” repo
  • the registry repo contains a script that makes it easier to clone it to the right location and update it once it has been cloned

Since all related repos are private, I’ll just outline the structure below.

The package template

Private packages don’t go to the official registry, and don’t go into the @preview namespace. This means I adapted the community’s typst-package-template repo.

Be sure to also check the template’s README and/or Typst packages - Why and How? | James’ Programming Blog since you need to create some Github tokens to use the release workflows.

Many of the changes are just trivial replacements:

Replace 'preview' with 'pria' in a few places

Justfile:

- # install the library with the "@preview" prefix (for pre-release testing)
- install-preview: (package "@preview")
+ # install the library with the "@pria" prefix (for pre-release testing)
+ install-pria: (package "@pria")

...

- # uninstalls the library from the "@preview" prefix (for pre-release testing)
- uninstall-preview: (remove "@preview")
+ # uninstalls the library from the "@pria" prefix (for pre-release testing)
+ uninstall-pria: (remove "@pria")

scripts/package:

if (( $# < 1 )) || [[ "${1:-}" == "help" ]]; then
  echo "package TARGET"
  echo ""
  echo "Packages all relevant files into a directory named '<name>/<version>'"
-   echo "at TARGET. If TARGET is set to @local or @preview, the local Typst package"
+   echo "at TARGET. If TARGET is set to @local or @pria, the local Typst package"
  echo "directory will be used so that the package gets installed for local use."
  echo "The name and version are read from 'typst.toml' in the project root."
  echo ""
  echo "Local package prefix: $DATA_DIR/typst/package/local"
-   echo "Local preview package prefix: $DATA_DIR/typst/package/preview"
+   echo "Local pria package prefix: $DATA_DIR/typst/package/pria"
  exit 1
fi

- TARGET="$(resolve-target "${1:?Missing target path, @local or @preview}")"
+ TARGET="$(resolve-target "${1:?Missing target path, @local or @pria}")"

scripts/uninstall:

if (( $# < 1 )) || [[ "${1:-}" == "help" ]]; then
  echo "uninstall TARGET"
  echo ""
  echo "Removes the package installed into a directory named '<name>/<version>'"
-   echo "at TARGET. If TARGET is set to @local or @preview, the local Typst package"
+   echo "at TARGET. If TARGET is set to @local or @pria, the local Typst package"
  echo "directory will be used so that the package gets installed for local use."
  echo "The name and version are read from 'typst.toml' in the project root."
  echo ""
  echo "Local package prefix: $DATA_DIR/typst/package/local"
-   echo "Local preview package prefix: $DATA_DIR/typst/package/preview"
+   echo "Local pria package prefix: $DATA_DIR/typst/package/pria"
  exit 1
fi

- TARGET="$(resolve-target "${1:?Missing target path, @local or @preview}")"
+ TARGET="$(resolve-target "${1:?Missing target path, @local or @pria}")"

scripts/setup:

	if [[ "$target" == "@local" ]]; then
		echo "${DATA_DIR}/typst/packages/local"
- 	elif [[ "$target" == "@preview" ]]; then
- 		echo "${DATA_DIR}/typst/packages/preview"
+ 	elif [[ "$target" == "@pria" ]]; then
+ 		echo "${DATA_DIR}/typst/packages/pria"
	else
		echo "$target"
	fi

The more interesting changes are around configuring the actual CI deployment:

private registry action

This action makes sure that, if a private package depends on other private packages, it has access to them when the package is tested in CI.

.github/setup-private-registry/action.yml
name: Setup private registry
description: Install private Typst package registry

inputs:
  repository:
    description: The registry repository to check out
  token:
    description: >
      Personal access token (PAT) used to fetch the repository.
    default: ${{ github.token }}
  namespace:
    description: The namespace under which to install the repository, e.g. `foo` for `@foo`

runs:
  using: "composite"
  steps:
    - name: Checkout package registry
      uses: actions/checkout@v4
      with:
        repository: ${{ inputs.repository }}
        token: ${{ inputs.token }}
        path: ${{ inputs.namespace }}-typst-packages

    - name: Move package registry to local package location
      shell: bash
      run: |
        if [[ "$OSTYPE" == "linux"* ]]; then
          DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}"
        elif [[ "$OSTYPE" == "darwin"* ]]; then
          DATA_DIR="$HOME/Library/Application Support"
        else
          DATA_DIR="${APPDATA}"
        fi
        TARGET="${DATA_DIR}/typst/packages/${{ inputs.namespace }}"
        mkdir -p "$(dirname "$TARGET")"
        mv "${{ inputs.namespace }}-typst-packages" "${TARGET}"

modified CI workflows

The public registry is not directly backed by its github repo, it’s only populated from there. As such, it has a different directory structure and packages go into packages/prefix. Not so for the private registry: the repo itself is the namespace and directly in there are the packages.

release and test workflows

.github/workflows/release.yml:

env:
  # the repository to which to push the release version
  # usually a fork of typst/packages (https://github.com/typst/packages/)
  # that you have push privileges to
-   REGISTRY_REPO: author/typst-packages
+   REGISTRY_REPO: PRIArobotics/typst-packages
  # the path within that repo where the "<name>/<version>" directory should be put
  # for the Typst package registry, keep this as is
-   PATH_PREFIX: packages/preview
+   PATH_PREFIX: .

...

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      ...

      - name: Setup typst
        uses: typst-community/setup-typst@v3
        with:
          typst-version: latest

+       - name: Setup PRIA package registry
+         uses: ./.github/setup-private-registry/
+         with:
+           repository: PRIArobotics/typst-packages
+           token: ${{ secrets.REGISTRY_TOKEN }}
+           namespace: pria

.github/workflows/tests.yml:

jobs:
  tests:
    ...
    steps:
      ...

      - name: Setup typst
        id: setup-typst
        uses: typst-community/setup-typst@v3
        with:
          typst-version: ${{ matrix.typst-version }}

+       - name: Setup PRIA package registry
+         uses: ./.github/setup-private-registry/
+         with:
+           repository: PRIArobotics/typst-packages
+           token: ${{ secrets.REGISTRY_TOKEN }}
+           namespace: pria

The package registry

The hard part is done; the package registry repo is basically just a regular repo with a convenience script in it, and to which your Github Actions workflows get access. I will just paste the README and the script here:

README

PRIA-private Typst package repository

This repository contains Typst packages for PRIA. As described in the typst/packages repo, by placing packages in a specific folder they can be made available on a system. Against recommendation, we don’t limit ourselves to the local namespace: this repository should be cloned into a directory named pria so that packages can then be imported via

#import "@pria/<name>:<version>"

Maintaining this clone in the right location is done by the install.sh script - see below.

install.sh

This will (on a Linux system) either clone or pull from this repo, at the appropriate location on your system.

--force updates the repository even if there are changes to it.
This would usually only happen when developing Typst packages.

--remove Removes the repository. Currently, this does not require --force even if there are changes.

Of course, this script can in principle be used in the curl ... | bash way;
however, since this is a private repository, there is no stable link to the raw script that could be put in this README.
You can either just download the script, or do the following:

  • initially, copy the raw link once and curl ... | bash it
  • you will see the output cloned PRIA package repository into .../typst/packages/pria
  • from now on, call the script as .../typst/packages/pria/install.sh

If you should want to use arguments without downloading the script,
you can use /dev/stdin as the script passed to bash, e.g.:

curl ... | bash /dev/stdin --force
install.sh
#!/usr/bin/env bash
set -eu

# Local package directories per platform
if [[ "$OSTYPE" == "linux"* ]]; then
  DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}"
elif [[ "$OSTYPE" == "darwin"* ]]; then
  DATA_DIR="$HOME/Library/Application Support"
else
  DATA_DIR="${APPDATA}"
fi

PRIA_PACKAGES="$DATA_DIR/typst/packages/pria"

if [[ "${1:-""}" == "--remove" ]]; then
	# uninstall package registry
	if [[ -e "$PRIA_PACKAGES" ]]; then
		rm -rf "$PRIA_PACKAGES"
		echo "Removed PRIA package repository from $PRIA_PACKAGES"
		exit 0
	else
		echo "PRIA package repository did not exist at $PRIA_PACKAGES"
		exit 1
	fi
elif [[ ! -e "$PRIA_PACKAGES" ]]; then
	# the package repo does not exist; clone
	git clone --depth=1 https://github.com/PRIArobotics/typst-packages "$PRIA_PACKAGES"
	cd "$PRIA_PACKAGES"
	echo "cloned PRIA package repository into $PRIA_PACKAGES"
else
	cd "$PRIA_PACKAGES"
	if git diff-index --quiet HEAD -- || [[ "${1:-""}" == "--force" ]]; then
		if [[ "${1:-""}" == "--force" ]]; then
			# force overwrite the package repo
			git fetch origin main
			git reset --hard origin/main
			git clean -fd
		else
			# pull the package repo
			git pull
		fi
		echo "Pulled updated PRIA package repository into $PRIA_PACKAGES"
	else
		# the package repo is dirty
		git status
		echo "the PRIA package repository at $PRIA_PACKAGES is not clean; see above"
		echo "use --force to overwrite local changes"
		exit 1
	fi
fi
git log -1 --pretty=format:'PRIA packages are now at %h:%n%B'

Conclusion

This was a lot of code with little explanation, but I only have so much time today. If it helps you let me know; if you need a few more pointers to understand what’s going on or how to get this setup to work just ask.

Happy new year!

6 Likes

I was thinking about changing two things for our organization.

  1. Making it a monorepo for all our packages so all the scripts etc. can just live in the repository root and used for all packages.
  2. Not using an install script and instead instructing to clone it directly in the .local/share/typst/packages/

Both of those things just to make it simpler. Do you have an opinion on these things?

Re 1., I just like that I can use the same CI workflows for both public and private packages. I also don’t like that separate repos cause some duplication, but not all of it would be prevented by a monorepo (e.g. there’d still be separate manual boilerplate) and the duplication which would be prevented could at some point become a standalone tool instead of a script living in the repo. There’s e.g. utpm but I just haven’t evaluated it for my use cases yet.

Re 2., this is a classic tradeoff between complexity in the system and complexity living within user’s heads, and in this case having the script feels right to me. Since updating private Typst packages or registries is not something I do every day, I think it’s worth offloading the brain overhead into a script that you can just run, or worst case read when it doesn’t work.

Keep in mind, just cloning the registry is not all there is to it. You clone once, then pull the rest of the time. And when you’re developing a package and use just install-pria, you may be trying to pull with a dirty working tree. All these are edge cases that you can either remember, or put into a script so that you can forget them.

1 Like

Recently I’m working on this: see typship And it’s available on crates.io.

For this case you might use typship download to install a certain package to the local namespace.

Still WIP so I haven’t made a post in Showcase, but any advice is appreciated.

2 Likes