iPhone → blog, with GitHub Actions
Photos → Share → Publish to blog → optional caption and highlight name → upload.
The authenticated upload service converts photos to JPEG (maximum 2560 px), applies
orientation, converts embedded color profiles to sRGB, and strips public metadata.
Originals stay in a private volume. A worker commits optimized images and an _art
entry to github.com/mdibaiee/mahdi.blog, on master.
Every push to master triggers .github/workflows/publish.yml. GitHub Actions builds
Jekyll and uploads the static output over SSH. nginx switches to the new release
only after the transfer completes. Other blog edits use the same workflow.
No Jekyll build runs on the server, and Nextcloud is not involved.
GitHub repository configuration
Actions is already enabled; there were no publishing workflows or deployment secrets when inspected. Add these at Settings → Secrets and variables → Actions:
| Repository secret | Value |
|---|---|
DEPLOY_SSH_KEY |
Private SSH key whose public key is authorized for deployment on theread.me |
DEPLOY_KNOWN_HOSTS |
Verified SSH known_hosts entry for theread.me (include [host]:port if nonstandard) |
The workflow has these defaults; override with repository variables if needed:
| Variable | Default |
|---|---|
DEPLOY_HOST |
theread.me |
DEPLOY_USER |
mahdi |
DEPLOY_PORT |
22 |
DEPLOY_PATH |
/home/mahdi/blog-published |
The server login needs write access to DEPLOY_PATH, plus bash, rsync, and
flock. Install its public key in that account's ~/.ssh/authorized_keys; use a
separate key for this workflow. Verify the SSH host fingerprint independently,
not by blindly trusting an unauthenticated scan. The server must accept SSH from
GitHub-hosted runners. Deployment itself needs no sudo or nginx reload.
Separately, add the photo publisher's public SSH key at Settings → Deploy keys,
with Allow write access enabled for this repository. This key stays on your
server and allows photo pushes to trigger Actions; it is not a workflow secret or
GITHUB_TOKEN. GitHub is the publishing source of truth. Push ordinary edits to
the github remote too; pushing only to git.theread.me does not trigger this workflow.
The workflow uses read-only repository permissions and pinned action commits. It
runs only for master, and serialized deployments plus a server-side run-number
check prevent an older workflow from replacing a newer deployment. Ruby matches
this blog's existing 3.1.2 requirement. The lockfile removes the obsolete
jekyll-latex dependency that was already absent from Gemfile, allowing frozen CI
installs. Runtime upgrades are separate from this change.
Server setup
The server is Ubuntu 24.04. Before this setup, nginx served
/home/mahdi/theread.me/_site. /home/mahdi/update-server/update-server.js existed,
was not running, and used a different port from nginx's old /_update route.
The new workflow replaces that rebuild hook.
- Install Podman and a Compose provider (
sudo apt-get install podman podman-compose). - Keep this service in its own checkout, e.g.
/home/mahdi/blog-photo-service. - Prepare private credentials and the public output directory:
install -d -m 700 "$HOME/.config/blog-photos"
install -d -m 755 /home/mahdi/blog-published
umask 077
openssl rand -hex 32 > "$HOME/.config/blog-photos/token"
ssh-keygen -t ed25519 -N '' -f "$HOME/.config/blog-photos/git_key" -C blog-photo-publisher
Register git_key.pub as the repository's write-enabled deploy key. Put GitHub's
verified SSH host key in ~/.config/blog-photos/known_hosts. Keep the private key
readable only by its owner. This key is separate from Actions' server-login key.
cd /home/mahdi/blog-photo-service/photo-upload
cp .env.example .env
# Adjust absolute paths if needed.
podman compose config
systemctl --user enable --now podman.socket
podman compose up -d --build
podman compose logs -f publisher
On SELinux hosts add shared :z labels to bind mounts as appropriate.
Commit and push this change to GitHub, and wait for the first Actions deployment.
Then back up nginx's site config and use host-nginx.conf.example: change the
HTTPS server's root to /home/mahdi/blog-published/current and add /api/photos.
Preserve the existing /, /raw/, TLS, /_xpanel, and unrelated routes. Ensure
nginx can traverse parent directories and read the output.
sudo nginx -t
sudo systemctl reload nginx
The API listens on loopback port 8088; HTTPS remains on the existing nginx. Rollback by restoring the original nginx root and reloading. The old checkout and site are untouched. Once verified, retire the old rebuild route.
For reboot persistence, create ~/.config/systemd/user/blog-photos.service:
[Unit]
Description=Blog photo uploads
After=network-online.target podman.socket
Wants=network-online.target
Requires=podman.socket
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/mahdi/blog-photo-service/photo-upload
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose stop
TimeoutStartSec=180
[Install]
WantedBy=default.target
Then run systemctl --user daemon-reload, systemctl --user enable --now blog-photos.service,
and sudo loginctl enable-linger mahdi.
iPhone shortcut
Create Publish to blog:
- Enable Show in Share Sheet, accepting Images.
- Ask for Input (Text): “Caption (optional)”.
- Add another Ask for Input (Text): “Highlight name (optional)”.
- Get Contents of URL:
https://theread.me/api/photos.- Method: POST.
- Header:
Authorization=Bearer YOUR_TOKEN. - Request Body: Form.
photos: type File, value Shortcut Input (the selected images).caption: type Text, value the caption response.highlight: type Text, value the highlight-name response. Select each prompt's specific output variable so the caption and highlight stay separate.- Do not set Content-Type; Shortcuts supplies the multipart boundary.
- Show “Uploaded; publishing” on success. Optionally poll the returned
status_urlwith the same header.pushedmeans “GitHub Actions is building/deploying”; onlypublishedmeans the deployed site's receipt contains this album.
Multi-photo albums require repeated photos parts in one request. Verify that
file-list behavior on the target iPhone, starting with one photo and then two.
Select still photos; videos/Live Photo video components aren't supported. Dates
are upload time in UTC; captions are plain text. A caption is optional.
An optional highlight name (up to 200 characters) adds one link to Highlights,
pointing to the first photo in the batch. Leave it blank for an ordinary upload.
The token is visible in the shortcut editor; remove it before sharing the shortcut.
Rotate it by replacing the token file and recreating the upload container with
podman compose up -d --force-recreate upload.
Snippets shortcut
The same service and token also publish to https://theread.me/snippets/.
Text stays literal (including line breaks and Persian text); standalone HTTP/HTTPS
URL lines become clickable links. Images use the same optimization and metadata
removal as photo uploads. Each submission is prepended below the introduction,
with a separator from the previous snippet. Existing snippets are preserved.
Create Publish snippet:
- Enable Show in Share Sheet, accepting Text, URLs, and Images.
- Repeat with Each item in Shortcut Input. Use Get Type on the repeat item. If it is Image, add the item to a variable called Snippet Images. Otherwise use Get Text from Input on the item and add its result to Snippet Text. These variables accumulate the shared items during this run.
- Combine Text from Snippet Text, separated by New Lines. For an image-only share, leave the text empty. Don't fetch the contents of shared URLs: send the link itself.
- Ask for Input (Text): “Note (optional)”. Keep its output separate from the shared text. You can also run the shortcut without shared input and use a text prompt as Snippet Text to write a new snippet.
- Get Contents of URL:
https://theread.me/api/snippets, method POST, request body Form:- Header
Authorization:Bearer YOUR_TOKEN(same token as photos). text: Text, the combined shared text.caption: Text, the optional note.photos: File, Snippet Images, when sharing images. For text-only requests, omit this field. Use an If on whether there are images to choose between the form withphotosand the text-only form.- Delete any blank header rows. Do not manually set
Content-Type.
- Header
- Show Result using the actual response. A response with
errormeans the upload failed; don't show a fixed success message.queuedmeans accepted; publishing happens afterwards. The returnedstatus_urlcan be polled with the same authorization header;publishedconfirms deployment.
For the simplest initial shortcut, accept just Text and URLs and omit the image
branch and photos field; add the image branch when that works on your phone.
POST /api/snippets accepts up to 20,000 characters of text, an optional
4,000-character caption, and up to 20 images in repeated photos parts, within
the existing 100 MiB request limit. At least text or an image is required.
It returns id, count (image count), url, status_url, and status.
GET /api/snippets/<id> checks publication, and
POST /api/snippets/<id>/retry retries failed Git publication. Identical text,
note, and original images in the same order are deduplicated.
Keep <!-- uploaded-snippets --> in snippets.md: it marks the insertion point.
The publisher fails safely if that marker is removed. GitHub Actions includes
rendered snippet IDs in photo-publication.json, alongside photo IDs.
Markdown blog-post shortcut
Create Publish blog post and enable Show in Share Sheet, accepting Files.
Share one UTF-8 .md or .markdown file, up to 1 MiB. In Get Contents of URL:
- URL:
https://theread.me/api/posts; method POST; request body Form. - Header:
Authorization=Bearer YOUR_TOKEN(no empty header rows). - Form field
file: type File, value Shortcut Input. - Let Shortcuts set
Content-Typeautomatically.
Show the actual response, and poll https://theread.me plus its status_url
with the same header. Matching happens in the publisher: 202 only means the
file was accepted, not that a new post or update was published. The status
response supplies the final url, post_id, and action (created or updated).
failed includes an error; published confirms the deployed revision. An older
upload can become superseded when a newer revision replaces it.
No front matter is required. The first # Heading becomes the title, or the
filename is used if there is no heading. Optional YAML front matter supports
title, subtitle, lang, description, categories, tags, toc, math,
date, and permalink (a path such as /my-essay/). Markdown formatting is
preserved; Liquid template evaluation is disabled for imported post bodies.
The original file is retained privately in the service volume.
Automatic updates by content
The publisher compares the body against existing Markdown posts, ignoring front matter and whitespace changes. It updates a unique match with at least 92% word sequence similarity, at least 200 characters in both texts, and a lead of at least 8 percentage points over the next plausible match. An exact body match of at least 40 characters also qualifies. These scores are a conservative heuristic, not a probability. Renaming the file does not prevent matching; reusing a filename for unrelated writing does not overwrite its previous post.
If several posts match closely, the upload fails without changing Git. If no
post meets the threshold, it creates a new post. Large rewrites and very short
posts may therefore need an explicit ID. For a known imported post, add the
returned post_id to its front matter to target it directly:
---
title: My revised essay
post_id: THE_ID_FROM_THE_UPLOAD_STATUS
---
For an older Git-authored post, first add a unique post_id to that existing
post's front matter and use the same ID in the uploaded file. Updates preserve
the original publication date and public URL, and remain reversible through Git.
Identical file retries are deduplicated and do not roll back a newer revision.
The upload takes the Markdown file only. Relative links to images or attachments in an export folder will not upload those files; use existing public image URLs. Export Markdown from the source app before sharing; proprietary note files, PDFs, ZIP bundles, and rich-text files are not Markdown uploads.
API, failures, and backups
All API routes require the bearer token:
POST /api/photos: 1–20 multipartphotos, optionalcaption(4000 characters). Optionalhighlight(200 characters) names a Highlights link to the batch's first photo. Maximum request 100 MiB; each photo at most 60 megapixels.GET /api/photos/<id>:queued,publishing,pushed,published, orfailed.POST /api/photos/<id>/retry: retry a failed Git publication.
Identical original bytes, ordering, caption, and highlight name produce the same ID. Retrying does not duplicate entries or commits. Re-encoding or changing the caption creates a new submission; edit an existing entry through Git. Keep the upload response/status URL.
For Git failures, check podman compose logs publisher, fix access/conflicts, and
POST to the status URL plus /retry. No force push is used. If Actions fails,
status stays pushed: inspect and rerun the failed workflow on GitHub, rather than
uploading again. Deployment only switches nginx after a complete transfer; the old
site stays available during builds and failures. The receipt is built from rendered
photo anchors so the service does not mistake a successful push for publication.
Back up the private photo-data volume (originals and job state) and GitHub's
repository. photo-work is disposable. Never expose private data through nginx or
run compose down -v. Originals and deployment releases are retained, so monitor
disk usage and periodically remove old releases (never current or an in-progress
release). Interrupted uploads may leave files in photo-data/temporary; clean that
folder only while the API is stopped. Public Git images are optimized copies.
Tests
python3 -m venv /tmp/blog-photo-tests
/tmp/blog-photo-tests/bin/pip install -r requirements.txt pytest
/tmp/blog-photo-tests/bin/pytest -q tests
Tests use a temporary local bare Git repository; no test pushes go to GitHub or the live server. The iPhone shortcut and production deployment need an end-to-end check after credentials are configured.