diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3fe7397 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +dev +runtime +target diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 528b94d..6211feb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,13 +2,15 @@ name: Deploy permissions: contents: read + packages: read on: workflow_dispatch: inputs: - release_tag: - description: 'GitHub release tag to be deployed' - required: true + image_tag: + description: 'Docker image tag (e.g., latest, 1.2.3)' + required: false + default: 'latest' type: string jobs: @@ -22,15 +24,15 @@ jobs: uses: webfactory/ssh-agent@v0.8.0 with: ssh-private-key: ${{ secrets.SSH_DEPLOYMENT_KEY }} - + - name: Add SSH key run: | mkdir -p ~/.ssh ssh-keyscan -H ${{ secrets.SSH_DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts - - name: Run the deployment script + - name: Deploy env: HOST: ${{ secrets.SSH_DEPLOYMENT_HOST }} USERNAME: ${{ secrets.SSH_DEPLOYMENT_USER }} - - run: ssh ${USERNAME}@${HOST} "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} GITHUB_TAG=${{ inputs.release_tag }} GITHUB_REPO=${{ github.repository }} $( cat ./deploy.sh )" + run: | + ssh ${USERNAME}@${HOST} "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} GITHUB_ACTOR=${{ github.actor }} REPO=${{ github.repository }} IMAGE_TAG=${{ inputs.image_tag }} $( cat ./dev/deploy-docker.sh )" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fe1c93..7993f28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,7 @@ name: Release permissions: contents: write + packages: write on: push: @@ -25,3 +26,39 @@ jobs: with: bin: observatory token: ${{ secrets.RELEASE_GH_TOKEN }} + + build-and-push-docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 8d995b1..044f9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ tarpaulin-report.html config.yaml tunnel.sh -private-key.pem + +*.pem + +runtime/* +!runtime/.config.yaml + +docker-compose.override.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26f231e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM docker.io/rust:alpine3.23 as builder + +RUN apk add --no-cache pkgconfig openssl-dev openssl-libs-static musl-dev libcrypto3 + +WORKDIR /build + +COPY src ./src +COPY Cargo.lock Cargo.toml ./ + +RUN cargo build --release + +FROM alpine:3.23.2 + +WORKDIR /app +COPY --from=builder /build/target/release/observatory . + +ENTRYPOINT [ "/app/observatory" ] diff --git a/README.md b/README.md index 98c3614..1d9eed6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,28 @@ # observatory +GitHub app for detecting overlapping translation changes in [osu! wiki](https://github.com/ppy/osu-wiki) + ## features - - detect overlapping changes (same `.md` files edited) - - detect original change and a translation existing at the same time +- detect overlapping changes (same `.md` files edited) +- detect original change and a translation existing at the same time + +## quick start + +```shell +# local development +cp docker-compose.override.yaml.example docker-compose.override.yaml +docker compose up + +# or run directly +cargo run -- -c runtime/config.yaml +``` ## testing -see [`TESTING.md`](TESTING.md) +see [`TESTING.md`](TESTING.md) for details + +## deploying + +- **release**: `git tag v1.2.3 && git push origin v1.2.3` builds and publishes to ghcr.io +- **deploy**: trigger manually from GitHub Actions with desired image tag diff --git a/TESTING.md b/TESTING.md index 90b8600..4aa74a0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,44 +1,40 @@ # testing -0. make sure the app can accept requests. - - if you have access to a remote host, save the script below to `dev/tunnel.sh` and use it to forward traffic to the app run locally. - - otherwise, use something like https://ngrok.com/ which would do that for you. -1. [register](https://github.com/settings/apps/new) a new GitHub app, then add read/write access to pull requests and issues. -2. open its GitHub Store page[^1] and install it on a selected repository. -3. list of events sent to the app is available on `https://github.com/settings/apps/{app name}/advanced`. +## github app setup -## local tests +1. [register](https://github.com/settings/apps/new) a new GitHub app with read/write access to pull requests and issues +2. set webhook URL to your server endpoint (see deployment section below) +3. install the app on a selected repository via `https://github.com/apps/{app name}` +4. webhook events are available at `https://github.com/settings/apps/{app name}/advanced` -```shell -cargo test -# coverage reporting via https://github.com/xd009642/tarpaulin -cargo tarpaulin --out html -``` +## local development -## nginx setup +### with docker -see `dev/example.nginx` to avoid being a web framework canary +```shell +cp docker-compose.override.yaml.example docker-compose.override.yaml +docker compose up +``` -## port forwarding +app runs at `http://localhost:3000` -(this can probably be boiled down to just a single `ssh` command, but I'm not very good at juggling `-L`s and `-R`s) +### without docker -```sh -#!/usr/bin/env bash +```shell +cargo run -- -c runtime/config.yaml +``` -# -R used locally: on jump_host, forward traffic from ITS localhost:jump_local_port to YOUR localhost:local_port -# -L used remotely: accept traffic from any ip (0.0.0.0) on external_port and forward it to localhost:local_jump_port +## unit tests -# to sum it up: anyone → jump_host:external_port → jump_host:local_jump_port → localhost:local_port → local web server +```shell +cargo test +cargo tarpaulin --out html # coverage report +``` -external_port="8000" -jump_local_port="12345" -local_port="3000" -jump_user="user" -jump_host="domain-or-ip.com" +## deployment -ssh -R "${jump_local_port}":localhost:"${local_port}" "${jump_user}@${jump_host}" -A \ - "ssh -L 0.0.0.0:${external_port}:localhost:${jump_local_port} ${jump_user}@localhost >/dev/null" -``` +automated via GitHub Actions: +- **release**: push a tag (e.g., `v1.2.3`) to build and publish docker image to ghcr.io +- **deploy**: manually trigger from GitHub Actions UI to deploy specific image tag to server -[^1]: `https://github.com/apps/{app name}` +see nginx config example in `dev/example.nginx` diff --git a/dev/deploy-docker.sh b/dev/deploy-docker.sh new file mode 100755 index 0000000..03669c5 --- /dev/null +++ b/dev/deploy-docker.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -e + +GITHUB_TOKEN="${GITHUB_TOKEN?}" +GITHUB_ACTOR="${GITHUB_ACTOR?}" +REPO="${REPO?}" +IMAGE_TAG="${IMAGE_TAG:-latest}" + +IMAGE="ghcr.io/${REPO}:${IMAGE_TAG}" +PROJECT_DIR="${PROJECT_DIR:-$HOME/observatory}" + +cd "${PROJECT_DIR}" +git fetch --all +git reset --hard origin/master + +echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + +docker compose down || true +docker pull "${IMAGE}" + +cat > docker-compose.override.yaml << EOF +services: + observatory: + image: ${IMAGE} +EOF + +docker compose up -d +docker compose ps +docker compose logs --tail=30 \ No newline at end of file diff --git a/dev/fetch-latest.sh b/dev/fetch-latest.sh deleted file mode 100755 index b8b473f..0000000 --- a/dev/fetch-latest.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -full_repo_name="TicClick/observatory" -releases_url="https://api.github.com/repos/${full_repo_name}/releases/latest" -package_url=$( curl "${releases_url}" | jq '.assets | map(select(.name | endswith("linux-gnu.tar.gz"))) | .[0].browser_download_url' --raw-output ) -if [[ "${package_url}" = "null" ]]; then - echo "no release available -- see https://github.com/${full_repo_name}/releases/latest" && exit 1 -fi - -# extract everything into current directory, assuming there's only an executable inside (otherwise there'll be A LOT of litter) -curl -L "$package_url" | tar -zxf - diff --git a/docker-compose.override.yaml.example b/docker-compose.override.yaml.example new file mode 100644 index 0000000..a0de150 --- /dev/null +++ b/docker-compose.override.yaml.example @@ -0,0 +1,8 @@ +# Local development overrides +# Copy to docker-compose.override.yaml and customize as needed + +services: + observatory: + image: observatory:latest + build: + context: . diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..db4b471 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + observatory: + image: observatory:latest + build: + context: . + ports: + - "127.0.0.1:3000:3000" + volumes: + - ./runtime:/app/runtime + command: ["-c", "/app/runtime/config.yaml"] + restart: on-failure diff --git a/.config.yaml b/runtime/.config.yaml similarity index 100% rename from .config.yaml rename to runtime/.config.yaml diff --git a/src/config.rs b/src/config.rs index 335955a..57459ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,7 +77,7 @@ mod tests { #[test] fn template_correctness() { - let settings = Config::from_path(".config.yaml").unwrap(); + let settings = Config::from_path("runtime/.config.yaml").unwrap(); let template = Config { server: Server { bind_ip: Ipv4Addr::new(127, 0, 0, 1), diff --git a/src/memory.rs b/src/memory.rs index 32f9ea7..b6ede95 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -19,7 +19,7 @@ impl Memory { pub fn contains(&self, full_repo_name: &str, pr: &PullRequest) -> bool { let g = self.pulls.lock().unwrap(); g.get(full_repo_name) - .map_or(false, |pulls| pulls.contains_key(&pr.number)) + .is_some_and(|pulls| pulls.contains_key(&pr.number)) } pub fn insert_pull(&self, full_repo_name: &str, new_pull: PullRequest) {