Skip to content

Commit 61c6114

Browse files
committed
first commit
Signed-off-by: Polina Simonenko <rabarbrablad@gmail.com>
0 parents  commit 61c6114

9 files changed

Lines changed: 455 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Publishes every shellcell package repository to dl.shellcell.dev.
2+
#
3+
# main holds desired state: snailmail.toml, the locks, the publication ledgers
4+
# and deployment receipts. gh-pages holds the built site, force-pushed as a
5+
# single orphan commit so the published bytes never accumulate in history.
6+
#
7+
# Artifact bytes are in neither branch. Each lock pins an origin on the
8+
# producing repository's GitHub Release, and snailmail refetches from it and
9+
# checks it against the committed digest, so a fresh runner can rebuild every
10+
# repository without any object storage.
11+
name: publish
12+
13+
on:
14+
# A tool's release workflow dispatches this after publishing its assets.
15+
repository_dispatch:
16+
types: [release]
17+
workflow_dispatch:
18+
inputs:
19+
repository:
20+
description: Tool repository to adopt from, as owner/name
21+
required: false
22+
type: string
23+
tag:
24+
description: Release tag to adopt, such as v0.1.2
25+
required: false
26+
type: string
27+
# Republish after a lock is changed by hand: promote, yank or prune.
28+
push:
29+
branches: [main]
30+
31+
permissions:
32+
contents: read
33+
34+
# Publishing rebuilds shared state, so a second run waits rather than racing the
35+
# lock, the ledger and the branch.
36+
concurrency:
37+
group: publish
38+
cancel-in-progress: false
39+
40+
jobs:
41+
publish:
42+
runs-on: ubuntu-latest
43+
environment: production
44+
permissions:
45+
contents: write
46+
steps:
47+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
48+
with:
49+
ref: main
50+
# Publication ledgers are validated against reachable history.
51+
fetch-depth: 0
52+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
53+
with:
54+
go-version: '1.25'
55+
56+
# Pinned rather than @latest: the tool that decides what gets published
57+
# should change when someone decides to change it. This must be a tag that
58+
# exists and includes origin-backed blob restore, or plan cannot rebuild a
59+
# repository on a runner that holds no artifacts.
60+
- name: Install snailmail
61+
env:
62+
SNAILMAIL_VERSION: v0.0.2
63+
run: |
64+
set -euo pipefail
65+
go install "github.com/shellcell/snailmail/cmd/snailmail@${SNAILMAIL_VERSION}"
66+
snailmail version
67+
68+
- name: Adopt the release
69+
env:
70+
TOOL_REPOSITORY: ${{ github.event.client_payload.repository || inputs.repository }}
71+
TOOL_TAG: ${{ github.event.client_payload.tag || inputs.tag }}
72+
run: |
73+
set -euo pipefail
74+
if [ -z "${TOOL_REPOSITORY}" ] || [ -z "${TOOL_TAG}" ]; then
75+
echo "no release to adopt; republishing current desired state"
76+
exit 0
77+
fi
78+
bin/adopt-release.sh "${TOOL_REPOSITORY}" "${TOOL_TAG}"
79+
80+
- name: Commit desired state
81+
run: |
82+
set -euo pipefail
83+
git config user.name "github-actions[bot]"
84+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
85+
git add snailmail.toml repos
86+
if git diff --cached --quiet; then
87+
echo "desired state unchanged"
88+
else
89+
git commit -m "adopt ${TOOL_REPOSITORY:-release} ${TOOL_TAG:-}"
90+
fi
91+
env:
92+
TOOL_REPOSITORY: ${{ github.event.client_payload.repository || inputs.repository }}
93+
TOOL_TAG: ${{ github.event.client_payload.tag || inputs.tag }}
94+
95+
# plan refetches any artifact this runner does not have from the origin
96+
# its lock records, verifying each against the committed digest.
97+
- name: Plan
98+
run: snailmail plan --out snailmail.snailmail-plan.json
99+
100+
# --runner docker so the Debian repository is checked by a real apt
101+
# installing from the built tree, not only parsed.
102+
- name: Apply
103+
run: snailmail apply --plan snailmail.snailmail-plan.json --runner docker
104+
105+
- name: Assemble the site
106+
run: |
107+
set -euo pipefail
108+
rm -rf site && mkdir -p site
109+
# Each published repository is a symlink to its current managed
110+
# release, so the copy dereferences: Git stores a symlink as a symlink
111+
# and Pages would serve it as a text file rather than following it.
112+
# The glob also skips snailmail's control and staging directories,
113+
# which are dot-prefixed and must not reach the site.
114+
for repository in docs/*/; do
115+
name=$(basename "$repository")
116+
cp -RL "$repository" "site/$name"
117+
done
118+
# Owning the apex of the subdomain, so the CNAME lives at the root.
119+
echo "dl.shellcell.dev" > site/CNAME
120+
# Pages runs Jekyll by default, which would drop files beginning with
121+
# an underscore and rewrite others.
122+
touch site/.nojekyll
123+
cp public/index.html site/index.html
124+
find site -maxdepth 2 | sort | head -40
125+
126+
- name: Deploy the site
127+
run: |
128+
set -euo pipefail
129+
# An orphan commit each time: the site is a snapshot, and keeping its
130+
# history would mean keeping every artifact ever published.
131+
cd site
132+
git init -q -b gh-pages
133+
git config user.name "github-actions[bot]"
134+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
135+
git add -A
136+
git commit -qm "publish ${GITHUB_SHA}"
137+
git push --force --quiet \
138+
"https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" gh-pages
139+
env:
140+
GITHUB_TOKEN: ${{ github.token }}
141+
142+
# Ledgers and receipts are written by apply, so desired state and the
143+
# record of what happened are pushed together after it.
144+
- name: Push the publication record
145+
run: |
146+
set -euo pipefail
147+
# Neither directory exists until the first apply has written one.
148+
git add --all -- publications deployments 2>/dev/null || true
149+
if ! git diff --cached --quiet; then
150+
git commit -m "record publication ${GITHUB_SHA}"
151+
fi
152+
git push origin HEAD:main

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# snailmail local state
2+
.snailmail/
3+
*.snailmail-plan.json
4+
5+
# Built by `snailmail apply`; the site is published to gh-pages, not committed here.
6+
docs/
7+
site/

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# postoffice
2+
3+
Every shellcell package repository, published to <https://dl.shellcell.dev> with
4+
[snailmail](https://github.com/shellcell/snailmail).
5+
6+
| repository | format | serves | URL |
7+
|---|---|---|---|
8+
| `releases` | raw | release binaries for every tool and platform | `dl.shellcell.dev/releases/` |
9+
| `apt` | deb | `apt-get install` for Debian and Ubuntu | `dl.shellcell.dev/apt/` |
10+
| `charts` | helm | Helm charts | `dl.shellcell.dev/charts/` |
11+
12+
One repository per *format*, not per tool: raw publishes to
13+
`<name>/<version>/<file>` and deb pools by package name, so every shellcell tool
14+
lives in the same three.
15+
16+
## How a release gets here
17+
18+
Tools do not publish to this repository. Each one builds its own artifacts,
19+
attaches them to a GitHub Release with a `SHA256SUMS`, and dispatches here:
20+
21+
```
22+
shellcell/snailmail tag v0.1.2 ──► GitHub Release + SHA256SUMS
23+
│ repository_dispatch
24+
25+
postoffice: adopt (pins each digest) ──► plan ──► apply ──► gh-pages
26+
```
27+
28+
`bin/adopt-release.sh` reads the release's `SHA256SUMS` and adopts each asset by
29+
digest, routing it by what it is: `.deb` to `apt`, `.tgz` to `charts`, archives
30+
to `releases`. An asset type with no rule fails rather than landing somewhere
31+
arbitrary.
32+
33+
Adding a tool needs no change here. Give it a release workflow that produces
34+
conventionally named assets and dispatches `release` with its repository and tag,
35+
using a token with `Contents: write` on this repository (`TAP_GITHUB_TOKEN`).
36+
37+
## Adopting by hand
38+
39+
```sh
40+
bin/adopt-release.sh shellcell/snailmail v0.1.2
41+
git diff repos/ # review what is about to be published
42+
git commit -am 'adopt snailmail 0.1.2'
43+
git push # the push publishes
44+
```
45+
46+
## Where things live
47+
48+
`main` holds desired state — `snailmail.toml`, the locks, the publication
49+
ledgers and deployment receipts. `gh-pages` holds the built site as a single
50+
orphan commit, force-pushed each time so published bytes never accumulate in
51+
history.
52+
53+
**Artifact bytes are in neither.** Each lock pins the origin on the producing
54+
repository's GitHub Release, and snailmail refetches from it and checks it
55+
against the committed digest. That is why this needs no object storage: your
56+
GitHub Releases already are the durable copy. It also means a release whose
57+
assets are deleted can no longer be rebuilt — keep them.
58+
59+
`docs/` is generated by `snailmail apply` and is not committed.
60+
61+
## Operating it
62+
63+
```sh
64+
snailmail status # what is desired, and what is published
65+
snailmail check --origins # re-fetch pins and verify them
66+
snailmail promote releases snailmail 0.1.2
67+
snailmail yank --all releases snailmail 0.1.1
68+
snailmail prune releases --keep 5 # bound how many versions stay visible
69+
```
70+
71+
Every one of these edits a lock. Publishing is always `plan` then `apply`, run
72+
by the workflow on push.
73+
74+
## Before announcing the apt repository
75+
76+
It is currently **unsigned**, so clients need `[trusted=yes]` and nothing
77+
verifies what they install. To fix that:
78+
79+
```sh
80+
export SNAILMAIL_KEY_PASSPHRASE='use-a-secret-manager-value'
81+
snailmail keys new archive-signing --expires-in 17520h
82+
snailmail setup deb --name apt --output docs/apt --suite stable \
83+
--architectures amd64,arm64 --signing-key archive-signing
84+
snailmail keys publish archive-signing
85+
```
86+
87+
Then put the encrypted private key in `SNAILMAIL_SIGNING_PRIVATE_KEY`, the
88+
passphrase in `SNAILMAIL_KEY_PASSPHRASE`, and the key reference in
89+
`SNAILMAIL_SIGNING_KEY_REF`, and drop the `[trusted=yes]` from the landing page.
90+
91+
## Requirements
92+
93+
Publishing runs on a fresh runner every time, which needs
94+
95+
- Pages serving the `gh-pages` branch, with `dl.shellcell.dev` as the custom
96+
domain and a `CNAME` DNS record pointing at `shellcell.github.io`
97+
- Docker on the runner, so a real `apt` verifies the Debian repository by
98+
installing from it
99+
- full history on checkout, since ledgers are validated against it

bin/adopt-release.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Adopt the assets of a published GitHub Release into this workspace.
4+
#
5+
# bin/adopt-release.sh shellcell/snailmail v0.1.2
6+
#
7+
# Every artifact is pinned to the digest listed in the release's own SHA256SUMS.
8+
# That file is the one thing this script trusts: if it is wrong, the wrong bytes
9+
# are pinned. Sign it in the producing repository if that matters to you, and
10+
# verify the signature here before adopting.
11+
#
12+
# Nothing is published. This only records desired state, which is then reviewed
13+
# as a diff and applied by the publish workflow.
14+
set -euo pipefail
15+
16+
if [ "$#" -ne 2 ]; then
17+
echo "usage: $0 <owner/repo> <tag>" >&2
18+
exit 2
19+
fi
20+
21+
repository=$1
22+
tag=$2
23+
snailmail=${SNAILMAIL:-snailmail}
24+
workspace=$(cd "$(dirname "$0")/.." && pwd)
25+
cd "$workspace"
26+
27+
case "$repository" in
28+
*/*) ;;
29+
*) echo "$0: repository must be owner/name, got '$repository'" >&2; exit 2 ;;
30+
esac
31+
case "$tag" in
32+
v[0-9]*) ;;
33+
*) echo "$0: tag must look like v1.2.3, got '$tag'" >&2; exit 2 ;;
34+
esac
35+
36+
download="https://github.com/${repository}/releases/download/${tag}"
37+
scratch=$(mktemp -d)
38+
trap 'rm -rf "$scratch"' EXIT
39+
40+
echo "==> reading ${download}/SHA256SUMS"
41+
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
42+
--max-time 120 --output "$scratch/SHA256SUMS" "${download}/SHA256SUMS"
43+
44+
adopted=0
45+
while read -r digest name; do
46+
# sha256sum writes "<digest> ./<name>"; drop the leading marker.
47+
name=${name#\*}
48+
name=${name#./}
49+
case "$name" in
50+
''|SHA256SUMS) continue ;;
51+
esac
52+
case "$digest" in
53+
[0-9a-f]*) ;;
54+
*) echo "$0: unusable digest for '$name'" >&2; exit 1 ;;
55+
esac
56+
if [ "${#digest}" -ne 64 ]; then
57+
echo "$0: digest for '$name' is not 64 hex characters" >&2
58+
exit 1
59+
fi
60+
61+
# Which repository serves an artifact is decided by what it is, so a new
62+
# asset type fails here rather than landing somewhere arbitrary.
63+
case "$name" in
64+
*.deb) target=apt ;;
65+
*.tgz) target=charts ;;
66+
*.tar.gz|*.tar.xz|*.tar.zst|*.tar.bz2|*.zip) target=releases ;;
67+
*)
68+
echo "$0: no repository serves '$name'; add a rule to $0" >&2
69+
exit 1 ;;
70+
esac
71+
72+
echo "==> adopt ${target}: ${name}"
73+
"$snailmail" adopt --sha256 "$digest" --public-origin "$target" "${download}/${name}"
74+
adopted=$((adopted + 1))
75+
done < "$scratch/SHA256SUMS"
76+
77+
if [ "$adopted" -eq 0 ]; then
78+
echo "$0: SHA256SUMS listed no adoptable assets" >&2
79+
exit 1
80+
fi
81+
echo "==> adopted ${adopted} artifacts from ${repository} ${tag}"

0 commit comments

Comments
 (0)