+14
.gitignore
+14
.gitignore
+15
LICENSE
+15
LICENSE
···
1
+
ISC License
2
+
3
+
Copyright (c) 2026 Gabriel Díaz López de la Llave
4
+
5
+
Permission to use, copy, modify, and/or distribute this software for any
6
+
purpose with or without fee is hereby granted, provided that the above
7
+
copyright notice and this permission notice appear in all copies.
8
+
9
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+
PERFORMANCE OF THIS SOFTWARE.
+236
README.md
+236
README.md
···
1
+
# Private OPAM Repository
2
+
3
+
A private OPAM repository with tooling for publishing OCaml packages.
4
+
5
+
## Repository Structure
6
+
7
+
```
8
+
├── bin/opam-publish # CLI tool for publishing packages
9
+
├── packages/ # Published package metadata
10
+
│ └── <pkg>/
11
+
│ └── <pkg>.<version>/
12
+
│ └── opam
13
+
└── repo # OPAM repository marker
14
+
```
15
+
16
+
## Architecture
17
+
18
+
```
19
+
┌──────────────┐ opam-publish ┌─────────────┐
20
+
│ Your Package │ ────────────────▶│ S3 Storage │◀── tarballs
21
+
└──────────────┘ └─────────────┘
22
+
│
23
+
▼
24
+
┌─────────────┐
25
+
│ Cloudflare │◀── public reads
26
+
└─────────────┘
27
+
28
+
┌──────────────┐ opam-publish ┌─────────────┐
29
+
│ Your Package │ ────────────────▶│ This Repo │◀── metadata only
30
+
└──────────────┘ └─────────────┘
31
+
```
32
+
33
+
## Setup
34
+
35
+
### Requirements
36
+
37
+
- `mc` (MinIO client, for S3-compatible uploads)
38
+
- `git` (for pushing to this repo)
39
+
- `tar`, `sha256sum` (standard Unix tools)
40
+
- `opam` (optional, for linting validation)
41
+
- SSH access to this repository
42
+
43
+
### Configuration
44
+
45
+
**1. Configure mc alias** (stores credentials securely in `~/.mc/`):
46
+
47
+
```bash
48
+
mc alias set mys3 https://your-s3-endpoint ACCESS_KEY SECRET_KEY
49
+
```
50
+
51
+
**2. Create opam-publish config** (`~/.config/opam-publish/config`):
52
+
53
+
```bash
54
+
mkdir -p ~/.config/opam-publish
55
+
chmod 700 ~/.config/opam-publish
56
+
57
+
cat > ~/.config/opam-publish/config << 'EOF'
58
+
MC_ALIAS=mys3
59
+
S3_BUCKET=your-bucket
60
+
PUBLIC_URL_BASE=https://packages.yourdomain.com
61
+
OPAM_REPO_URL=git@your-git-host:path/to/this/repo
62
+
EOF
63
+
64
+
chmod 600 ~/.config/opam-publish/config
65
+
```
66
+
67
+
### Add to PATH
68
+
69
+
```bash
70
+
export PATH="$PATH:/path/to/this/repo/bin"
71
+
```
72
+
73
+
Or symlink:
74
+
75
+
```bash
76
+
ln -s /path/to/this/repo/bin/opam-publish ~/.local/bin/
77
+
```
78
+
79
+
## Publishing a Package
80
+
81
+
### Prerequisites
82
+
83
+
Your OCaml project must have:
84
+
85
+
1. A `<package>.opam` file (or generate via `dune build`)
86
+
2. A `version` field in the `.opam` file or `dune-project`
87
+
88
+
### Publish
89
+
90
+
From your project root:
91
+
92
+
```bash
93
+
opam-publish
94
+
```
95
+
96
+
If multiple `.opam` files exist, specify which one:
97
+
98
+
```bash
99
+
opam-publish mypackage
100
+
```
101
+
102
+
### What Happens
103
+
104
+
1. Validates `.opam` file (required fields, runs `opam lint` if available)
105
+
2. Builds a source tarball (excludes `.git`, `_build`, `_opam`)
106
+
3. Computes SHA256 checksum
107
+
4. Uploads tarball to S3
108
+
5. Clones this repo, adds package metadata with URL + checksum
109
+
6. Pushes commit to this repo
110
+
111
+
### Example Output
112
+
113
+
```
114
+
==> Validating mylib.opam...
115
+
ok
116
+
==> Building mylib.1.0.0.tbz...
117
+
42K
118
+
==> Uploading to S3...
119
+
https://packages.example.com/mylib/mylib.1.0.0.tbz
120
+
==> Cloning opam repository...
121
+
==> Pushing to opam repository...
122
+
==> Published mylib 1.0.0
123
+
124
+
Done. Users can install with:
125
+
opam install mylib
126
+
```
127
+
128
+
## Using This Repository
129
+
130
+
### Add to OPAM
131
+
132
+
```bash
133
+
opam repository add private git+ssh://git@your-git-host/path/to/repo.git
134
+
```
135
+
136
+
Or via HTTPS:
137
+
138
+
```bash
139
+
opam repository add private git+https://your-git-host/path/to/repo.git
140
+
```
141
+
142
+
### Install Packages
143
+
144
+
```bash
145
+
opam update
146
+
opam install <package>
147
+
```
148
+
149
+
### Remove Repository
150
+
151
+
```bash
152
+
opam repository remove private
153
+
```
154
+
155
+
## Releasing a New Version
156
+
157
+
1. Update version in `dune-project`:
158
+
```lisp
159
+
(version 1.2.0)
160
+
```
161
+
162
+
2. Regenerate opam file:
163
+
```bash
164
+
dune build
165
+
```
166
+
167
+
3. Commit changes:
168
+
```bash
169
+
git add -A
170
+
git commit -m "Release 1.2.0"
171
+
git tag v1.2.0
172
+
git push origin main --tags
173
+
```
174
+
175
+
4. Publish:
176
+
```bash
177
+
opam-publish
178
+
```
179
+
180
+
## Troubleshooting
181
+
182
+
### "version not found"
183
+
184
+
Ensure your `.opam` file has a version field:
185
+
186
+
```
187
+
version: "1.0.0"
188
+
```
189
+
190
+
Or in `dune-project`:
191
+
192
+
```lisp
193
+
(version 1.0.0)
194
+
```
195
+
196
+
### "version already published"
197
+
198
+
Each version can only be published once. Bump the version number.
199
+
200
+
### S3 upload fails
201
+
202
+
Check mc alias and bucket access:
203
+
204
+
```bash
205
+
mc alias list
206
+
mc ls mys3/your-bucket
207
+
```
208
+
209
+
### Git push fails
210
+
211
+
Ensure you have SSH access to this repository:
212
+
213
+
```bash
214
+
ssh -T git@your-git-host
215
+
```
216
+
217
+
### "opam lint failed"
218
+
219
+
Fix the issues reported by `opam lint`. Common problems:
220
+
221
+
```bash
222
+
opam lint mylib.opam
223
+
```
224
+
225
+
Missing fields can be added to `dune-project`:
226
+
227
+
```lisp
228
+
(generate_opam_files true)
229
+
(name mylib)
230
+
(synopsis "Short description")
231
+
(authors "Your Name")
232
+
(maintainers "your@email.com")
233
+
(license MIT)
234
+
```
235
+
236
+
Then regenerate with `dune build`.
+139
bin/opam-publish
+139
bin/opam-publish
···
1
+
#!/usr/bin/env bash
2
+
set -euo pipefail
3
+
4
+
CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/opam-publish/config"
5
+
6
+
die() { echo "error: $*" >&2; exit 1; }
7
+
8
+
load_config() {
9
+
[[ -f "$CONFIG_FILE" ]] || die "config not found: $CONFIG_FILE
10
+
Create it with:
11
+
mkdir -p ~/.config/opam-publish
12
+
cat > ~/.config/opam-publish/config << 'EOF'
13
+
MC_ALIAS=mys3
14
+
S3_BUCKET=your-bucket
15
+
PUBLIC_URL_BASE=https://packages.yourdomain.com
16
+
OPAM_REPO_URL=git@your-git-host:your-repo
17
+
EOF
18
+
19
+
Then configure mc:
20
+
mc alias set mys3 https://your-s3-endpoint ACCESS_KEY SECRET_KEY"
21
+
source "$CONFIG_FILE"
22
+
}
23
+
24
+
find_package() {
25
+
local opam_files=(*.opam)
26
+
[[ ${#opam_files[@]} -eq 0 ]] && die "no .opam file found"
27
+
[[ ${#opam_files[@]} -gt 1 ]] && die "multiple .opam files found, specify one: $*"
28
+
echo "${opam_files[0]%.opam}"
29
+
}
30
+
31
+
get_version() {
32
+
local pkg="$1"
33
+
grep -E '^version:' "${pkg}.opam" 2>/dev/null | head -1 | sed 's/version:[[:space:]]*"\(.*\)"/\1/' || \
34
+
grep -E '^\(version' dune-project 2>/dev/null | head -1 | sed 's/(version[[:space:]]*\(.*\))/\1/' || \
35
+
die "version not found in ${pkg}.opam or dune-project"
36
+
}
37
+
38
+
validate_package() {
39
+
local pkg="$1" version="$2"
40
+
echo "==> Validating ${pkg}.opam..."
41
+
42
+
local opam_file="${pkg}.opam"
43
+
44
+
grep -qE '^opam-version:' "$opam_file" || die "missing opam-version field"
45
+
grep -qE '^synopsis:' "$opam_file" || die "missing synopsis field"
46
+
grep -qE '^maintainer:|^authors:' "$opam_file" || die "missing maintainer or authors field"
47
+
48
+
if command -v opam &>/dev/null; then
49
+
local lint_output
50
+
if ! lint_output=$(opam lint "$opam_file" 2>&1); then
51
+
echo "$lint_output" >&2
52
+
die "opam lint failed"
53
+
fi
54
+
local warnings
55
+
warnings=$(echo "$lint_output" | grep -c "warning" || true)
56
+
[[ "$warnings" -gt 0 ]] && echo " ${warnings} warning(s) from opam lint"
57
+
fi
58
+
59
+
echo " ok"
60
+
}
61
+
62
+
build_tarball() {
63
+
local pkg="$1" version="$2" archive="$3"
64
+
echo "==> Building ${archive}..."
65
+
tar --transform "s,^\.,${pkg}.${version}," \
66
+
--exclude='.git' \
67
+
--exclude='_build' \
68
+
--exclude='_opam' \
69
+
--exclude='.tangled' \
70
+
-cjf "${archive}" .
71
+
echo " $(du -h "${archive}" | cut -f1)"
72
+
}
73
+
74
+
upload_s3() {
75
+
local pkg="$1" archive="$2"
76
+
local filename
77
+
filename=$(basename "${archive}")
78
+
echo "==> Uploading to S3..."
79
+
mc cp --quiet "${archive}" "${MC_ALIAS}/${S3_BUCKET}/${pkg}/${filename}"
80
+
echo " ${PUBLIC_URL_BASE}/${pkg}/${filename}"
81
+
}
82
+
83
+
publish_opam() {
84
+
local pkg="$1" version="$2" archive="$3" checksum="$4"
85
+
local tmpdir
86
+
tmpdir=$(mktemp -d)
87
+
trap "rm -rf ${tmpdir}" EXIT
88
+
89
+
echo "==> Cloning opam repository..."
90
+
git clone --depth 1 "${OPAM_REPO_URL}" "${tmpdir}/repo" 2>/dev/null
91
+
92
+
local pkg_dir="${tmpdir}/repo/packages/${pkg}/${pkg}.${version}"
93
+
[[ -d "$pkg_dir" ]] && die "version ${version} already published"
94
+
95
+
mkdir -p "${pkg_dir}"
96
+
cp "${pkg}.opam" "${pkg_dir}/opam"
97
+
98
+
printf '\nurl {\n src: "%s/%s/%s"\n checksum: "sha256=%s"\n}\n' \
99
+
"${PUBLIC_URL_BASE}" "${pkg}" "${archive}" "${checksum}" \
100
+
>> "${pkg_dir}/opam"
101
+
102
+
echo "==> Pushing to opam repository..."
103
+
cd "${tmpdir}/repo"
104
+
git add packages/
105
+
git commit -m "Publish ${pkg} ${version}" --quiet
106
+
git push origin main --quiet
107
+
108
+
echo "==> Published ${pkg} ${version}"
109
+
}
110
+
111
+
main() {
112
+
load_config
113
+
114
+
local pkg="${1:-$(find_package)}"
115
+
[[ -f "${pkg}.opam" ]] || die "${pkg}.opam not found"
116
+
117
+
local version
118
+
version=$(get_version "$pkg")
119
+
[[ -n "$version" ]] && [[ "$version" != *'"'* ]] || die "invalid version: $version"
120
+
121
+
local archive="${pkg}.${version}.tbz"
122
+
123
+
validate_package "$pkg" "$version"
124
+
build_tarball "$pkg" "$version" "/tmp/${archive}"
125
+
126
+
local checksum
127
+
checksum=$(sha256sum "/tmp/${archive}" | cut -d' ' -f1)
128
+
129
+
upload_s3 "$pkg" "/tmp/${archive}"
130
+
publish_opam "$pkg" "$version" "$archive" "$checksum"
131
+
132
+
rm -f "/tmp/${archive}"
133
+
134
+
echo ""
135
+
echo "Done. Users can install with:"
136
+
echo " opam install ${pkg}"
137
+
}
138
+
139
+
main "$@"
packages/.gitkeep
packages/.gitkeep
This is a binary file and will not be displayed.
+1
repo
+1
repo
···
1
+
opam-version: "2.0"