Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Validate and publish

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Validate Python
run: |
python -m py_compile digest.py serve.py
python -m unittest discover -s tests -v
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
load: true
push: false
tags: myfeed:ci
cache-from: type=gha,scope=myfeed
cache-to: type=gha,mode=max,scope=myfeed
- name: Smoke test
run: |
docker run -d --name myfeed-ci -p 127.0.0.1:8484:8484 myfeed:ci
trap 'docker rm -f myfeed-ci >/dev/null 2>&1 || true' EXIT
for attempt in {1..45}; do
curl --fail --silent http://127.0.0.1:8484/health/ready && exit 0
sleep 1
done
docker logs myfeed-ci
exit 1

publish:
if: github.event_name != 'pull_request'
needs: validate
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
cache-from: type=gha,scope=myfeed
cache-to: type=gha,mode=max,scope=myfeed
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ COPY digest.py serve.py feeds.toml tech_pool.json econ_2026.json entrypoint.sh .
ENV MYFEED_DATA=/data MYFEED_BIND=0.0.0.0 TZ=America/New_York
VOLUME /data
EXPOSE 8484
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8484/health/ready', timeout=3).read()" || exit 1
CMD ["./entrypoint.sh"]
10 changes: 8 additions & 2 deletions serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
HERE = Path(__file__).parent
DATA = Path(os.environ.get("MYFEED_DATA", HERE))
CLICKS_LOG = DATA / "clicks.jsonl"
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8484
PORT = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 8484
BIND = os.environ.get("MYFEED_BIND", "127.0.0.1")


Expand All @@ -36,7 +36,13 @@ def _send(self, code, body, ctype="text/html; charset=utf-8", extra=None):

def do_GET(self):
url = urlparse(self.path)
if url.path == "/":
if url.path == "/health/live":
self._send(200, '{"status":"ok"}', "application/json")
elif url.path == "/health/ready":
digest = DATA / "digest.html"
code = 200 if digest.is_file() else 503
self._send(code, json.dumps({"status": "ready" if code == 200 else "waiting"}), "application/json")
elif url.path == "/":
f = DATA / "digest.html"
if f.exists():
self._send(200, f.read_bytes(), extra={"Cache-Control": "no-cache"})
Expand Down
52 changes: 52 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import json
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch

import serve


class ServeHealthTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.data = Path(self.temp_dir.name)
self.data_patch = patch.object(serve, "DATA", self.data)
self.data_patch.start()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), serve.Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.base = f"http://127.0.0.1:{self.server.server_port}"

def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.data_patch.stop()
self.temp_dir.cleanup()

def get(self, path):
with urllib.request.urlopen(self.base + path) as response:
return response.status, response.read()

def test_live_health_does_not_require_digest(self):
status, body = self.get("/health/live")
self.assertEqual(status, 200)
self.assertEqual(json.loads(body), {"status": "ok"})

def test_ready_health_requires_digest(self):
with self.assertRaises(urllib.error.HTTPError) as error:
self.get("/health/ready")
self.assertEqual(error.exception.code, 503)

(self.data / "digest.html").write_text("<!doctype html><title>test</title>")
status, body = self.get("/health/ready")
self.assertEqual(status, 200)
self.assertEqual(json.loads(body), {"status": "ready"})


if __name__ == "__main__":
unittest.main()