Introduction

By the end of CS374 you have built a real programming language: a lexer, a parser, an evaluator or compiler, and probably a REPL. That implementation lives in a folder on your laptop. This tutorial shows you how to share it with the world so that anyone — without cloning your repository or installing your dependencies by hand — can run programs written in your language.

There are three scenarios, and you only need the one that fits your project:

Pick the section that matches your project. The three parts are completely independent.


Semantic Versioning Quick Reference

Before diving into the tooling, agree on version numbers. All three ecosystems (PyPI, npm, Docker) use the same semantic versioning convention: MAJOR.MINOR.PATCH.

Change Version bump Example
New feature, backward compatible Minor 0.1.0 → 0.2.0
Bug fix Patch 0.1.0 → 0.1.1
Breaking change Major 0.1.0 → 1.0.0

Start at 0.1.0. You are allowed to break things in the 0.x range. When you feel the language is stable, bump to 1.0.0.


Part A: Publishing to PyPI with pip

Use this section if your interpreter is written in Python.

A1: Project Layout

Before you can publish, your project needs a standard layout. The key file is pyproject.toml, which replaces the older setup.py. The special __main__.py module makes python3 -m mylang work as an alternative to the mylang command.

mylang/
  __init__.py
  lexer.py
  parser.py
  evaluator.py
  __main__.py      # enables: python3 -m mylang program.ml
pyproject.toml
README.md
tests/
  test_arithmetic.py
  test_closures.py

__init__.py can be empty — it just tells Python that mylang/ is a package. Your existing source files (lexer.py, parser.py, evaluator.py) stay exactly where they are.

A2: pyproject.toml

pyproject.toml is the single file that describes your package to PyPI, to pip, and to the build tool. Create it in the root of your project (next to the mylang/ directory, not inside it):

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mylang-cs374"
version = "0.1.0"
description = "A small programming language built in CS374"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}

[project.scripts]
mylang = "mylang.__main__:main"

Key points:

A3: __main__.py

This file is the CLI entry point. It reads a source file from the command line, passes its contents to your evaluator, and handles errors gracefully.

import sys
from .evaluator import run, LangError

def main():
    if len(sys.argv) != 2:
        print("Usage: mylang <program.ml>", file=sys.stderr)
        sys.exit(1)
    try:
        with open(sys.argv[1]) as f:
            source = f.read()
        result = run(source)
        if result is not None:
            print(result)
    except FileNotFoundError:
        print(f"Error: file not found: {sys.argv[1]}", file=sys.stderr)
        sys.exit(1)
    except LangError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()

The relative import (.evaluator) works because mylang is a proper package. LangError should be the base exception class your evaluator raises for runtime and parse errors; define it in evaluator.py if you have not already.

A4: Build and Upload

Install the build and upload tools once:

pip install build twine

Then build your package. This creates two distribution files in dist/:

python3 -m build
# dist/mylang_cs374-0.1.0.tar.gz      (source distribution)
# dist/mylang_cs374-0.1.0-py3-none-any.whl  (wheel)

Validate the distributions before uploading — this catches missing files and metadata errors:

twine check dist/*

Testing on Test PyPI first (recommended)

Test PyPI is a sandbox that behaves exactly like the real PyPI. Create a free account at test.pypi.org and upload there first:

twine upload --repository testpypi dist/*

Then install from Test PyPI to verify everything works:

pip install --index-url https://test.pypi.org/simple/ mylang-cs374
mylang tests/hello.ml

Uploading to the real PyPI

Create an account at pypi.org, then:

twine upload dist/*

After upload, anyone can install your language with:

pip install mylang-cs374
mylang program.ml

A5: Updating Your Package

When you fix a bug or add a feature, bump version in pyproject.toml, rebuild, and upload:

# Edit pyproject.toml: version = "0.1.1"
python3 -m build
twine check dist/*
twine upload dist/*

Users get the update by running pip install --upgrade mylang-cs374.


Part B: Publishing to npm

Use this section if your transpiler emits JavaScript. Your users will run programs with npx @yourname/mylang program.ml — no install step needed for one-off use.

B1: When to Use npm

The npm ecosystem is the right fit when:

If your interpreter is written in Python and produces Python-like semantics, use Part A instead.

B2: package.json

package.json is the npm equivalent of pyproject.toml. Create it in the root of your project:

{
  "name": "@yourname/mylang",
  "version": "0.1.0",
  "description": "Transpiler for the MyLang language (CS374)",
  "bin": {
    "mylang": "./bin/mylang.js"
  },
  "main": "src/index.js",
  "scripts": {
    "test": "node tests/run_tests.js"
  },
  "keywords": ["language", "transpiler", "cs374"],
  "license": "MIT"
}

Key points:

B3: bin/mylang.js

Create the directory bin/ and add mylang.js. The shebang line (#!/usr/bin/env node) tells the OS to run this file with Node.js:

#!/usr/bin/env node
'use strict';

const fs = require('fs');
const path = require('path');
const { transpile } = require('../src/index.js');

const file = process.argv[2];

if (!file) {
  console.error('Usage: mylang <program.ml>');
  process.exit(1);
}

const fullPath = path.resolve(file);

if (!fs.existsSync(fullPath)) {
  console.error(`Error: file not found: ${file}`);
  process.exit(1);
}

const source = fs.readFileSync(fullPath, 'utf8');

try {
  const output = transpile(source);
  console.log(output);
} catch (e) {
  console.error(`Error: ${e.message}`);
  process.exit(1);
}

Make the file executable so npm can run it:

chmod +x bin/mylang.js

Your src/index.js should export a transpile function:

// src/index.js
function transpile(source) {
  // your lexer → parser → code generator pipeline
  // returns a string of JavaScript (or other target)
}

module.exports = { transpile };

B4: Publishing

Create a free account at npmjs.com, then log in from your terminal:

npm login

Testing locally before publishing

Pack the package into a tarball without uploading it. This lets you inspect exactly what will be published and install it locally for testing:

npm pack
# creates yourname-mylang-0.1.0.tgz

npm install -g ./yourname-mylang-0.1.0.tgz
mylang tests/hello.ml

Publishing to npm

Scoped packages (@yourname/...) must be published with --access public unless you are on a paid npm plan:

npm publish --access public

After publishing, anyone can run your transpiler with:

npx @yourname/mylang program.ml

B5: Updating Your Package

Bump the version in package.json and republish:

# Edit package.json: "version": "0.1.1"
npm publish --access public

npm rejects uploads of a version that already exists, so always bump before uploading.


Part C: Docker and GitHub Container Registry (ghcr.io)

Use this section if your language has native dependencies — Flex/Bison, LLVM, a custom C library, or any system package that is painful to install by hand.

C1: When to Use Docker

Docker is the right choice when:

Docker bundles your runtime, system libraries, and everything else into a single image. A user with Docker installed can run your language with one command, regardless of their OS.

C2: Dockerfile

Create a file named Dockerfile in the root of your project. This example starts from a minimal Python image; adapt the base image (FROM line) and the system package install step for your project:

FROM python:3.12-slim

# Set the working directory inside the container
WORKDIR /app

# Install system dependencies if your language needs them.
# Uncomment and modify as needed:
# RUN apt-get update && apt-get install -y --no-install-recommends \
#     flex bison llvm-14 \
#  && rm -rf /var/lib/apt/lists/*

# Copy only the files needed for installation first.
# Copying pyproject.toml before source code lets Docker cache
# the pip install layer and skip it on rebuilds when only source changes.
COPY pyproject.toml README.md ./
COPY mylang/ ./mylang/

# Install the package in the image
RUN pip install --no-cache-dir .

# The ENTRYPOINT is what runs when someone does:
#   docker run --rm mylang:latest program.ml
# The user passes the file path as the argument after the image name.
ENTRYPOINT ["mylang"]

If your language is compiled (C/C++ binary), replace the Python-based install with whatever build steps produce your binary, then copy the binary and set ENTRYPOINT to it.

C3: Build and Test Locally

Build the image with a tag:

docker build -t mylang:latest .

Test it by mounting your local tests/ directory into the container and running a program:

docker run --rm -v $(pwd)/tests:/tests mylang:latest /tests/fibonacci.ml

The -v $(pwd)/tests:/tests flag mounts your host’s tests/ directory at /tests inside the container. Your ENTRYPOINT ["mylang"] receives /tests/fibonacci.ml as its argument.

Run your full test suite to verify the image works the same as your local install:

for f in tests/*.ml; do
  echo "=== $f ==="
  docker run --rm -v "$(pwd)/tests:/tests" mylang:latest "/tests/$(basename $f)"
done

C4: Push to GitHub Container Registry (ghcr.io)

GitHub Container Registry (ghcr.io) is free for public images and integrates directly with your GitHub account — no separate Docker Hub account needed.

Step 1: Create a Personal Access Token (PAT)

Go to github.com/settings/tokens and create a new token (classic) with the write:packages scope. Copy the token value; you will only see it once.

Step 2: Log in to ghcr.io

echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

Replace YOUR_GITHUB_USERNAME with your GitHub username and $GITHUB_TOKEN with your PAT (set it as an environment variable or paste it directly).

Step 3: Tag and push the image

Tag your image with the registry path, then push both a version tag and latest:

# Tag the image
docker tag mylang:latest ghcr.io/YOUR_GITHUB_USERNAME/mylang:latest
docker tag mylang:latest ghcr.io/YOUR_GITHUB_USERNAME/mylang:0.1.0

# Push both tags
docker push ghcr.io/YOUR_GITHUB_USERNAME/mylang:latest
docker push ghcr.io/YOUR_GITHUB_USERNAME/mylang:0.1.0

Step 4: Make the image public

By default, new packages on ghcr.io are private. Go to your GitHub profile → Packages → select your image → Package settings → Change visibility → Public.

After making it public, anyone can pull and run your language with:

docker run --rm -v $(pwd):/work ghcr.io/YOUR_GITHUB_USERNAME/mylang:latest /work/program.ml

C5: Automate with GitHub Actions

Manually building and pushing the image every time you tag a release is tedious. GitHub Actions can do it automatically whenever you push a version tag.

Create the file .github/workflows/docker-publish.yml in your repository:


name: Publish Docker Image

on:
  push:
    tags:
      - 'v*'   # triggers on: git tag v0.1.0 && git push --tags

jobs:
  push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Log in to ghcr.io
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract version tag
        id: meta
        run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }}

To publish a new release, bump your version in pyproject.toml, commit, tag, and push:

git add pyproject.toml
git commit -m "Bump version to 0.2.0"
git tag v0.2.0
git push origin main --tags

GitHub Actions will detect the v0.2.0 tag, build the Docker image, and push ghcr.io/yourname/mylang:v0.2.0 and ghcr.io/yourname/mylang:latest automatically. No manual docker build or docker push needed.

The ${{ secrets.GITHUB_TOKEN }} is automatically available in every Actions workflow — you do not need to create it manually. It has write:packages permission because the workflow declares packages: write.


Summary: Which Path Should I Use?

My situation Use
Interpreter written in Python Part A (pip / PyPI)
Transpiler that emits JavaScript, tooling in JS/TS Part B (npm)
Native dependencies (Flex/Bison, LLVM, C compiler) Part C (Docker / ghcr.io)
I want both pip install and docker run to work Parts A + C (they are independent)

The most important thing is that someone who has never seen your language can install it in one command and run a program in under two minutes. Pick the packaging path that makes that true for your project.