diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d32683..03eef91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,19 @@ name: Release -# Builds and publishes a release when a version tag is pushed. Running it by hand for an -# existing tag rebuilds that tag's code with this workflow and updates the release's files. +# Builds and publishes a release when a version tag is pushed: the command line for every +# platform, the desktop installers, and the container image on ghcr.io. Running it by hand +# with an existing tag rebuilds that tag's code and updates the release's files. Running it +# by hand without a tag is a trial: it builds every file from the selected branch and keeps +# them as downloads of the run, without publishing anything. on: push: tags: ["v*"] workflow_dispatch: inputs: tag: - description: Existing tag to build and publish, for example v1.0.0 - required: true + description: Existing tag to build and publish, such as v1.0.0. Leave it empty for a trial build of the selected branch. + required: false + default: "" permissions: contents: read @@ -24,28 +28,45 @@ jobs: outputs: tag: ${{ steps.version.outputs.tag }} version: ${{ steps.version.outputs.version }} + # What every job builds: the full tag ref (never a branch of the same name) or, for a + # trial, the exact commit. + ref: ${{ steps.version.outputs.ref }} + publish: ${{ steps.version.outputs.publish }} steps: - # Full tag refs everywhere: a branch with the same name as the tag must not be built. - uses: actions/checkout@v7 with: ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} - id: version env: - TAG: ${{ inputs.tag || github.ref_name }} + # Empty for a trial started by hand without a tag. + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} run: | set -euo pipefail - version="${TAG#v}" workspace=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "repodna-cli") | .version') - if [ "$version" != "$workspace" ]; then - echo "The tag $TAG does not match the workspace version $workspace." - exit 1 + if [ -n "$TAG" ]; then + version="${TAG#v}" + if [ "$version" != "$workspace" ]; then + echo "The tag $TAG does not match the workspace version $workspace." + exit 1 + fi + ref="refs/tags/$TAG" + publish=true + else + version="$workspace" + ref=$(git rev-parse HEAD) + publish=false + echo "Trial build of $version from $GITHUB_REF_NAME at $ref; nothing is published." fi if ! grep -q "^## \[$version\]" CHANGELOG.md; then echo "CHANGELOG.md has no section for $version." exit 1 fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" + { + echo "tag=$TAG" + echo "version=$version" + echo "ref=$ref" + echo "publish=$publish" + } >> "$GITHUB_OUTPUT" web: name: Web interface @@ -54,7 +75,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: refs/tags/${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: actions/setup-node@v7 with: node-version: 22 @@ -88,7 +109,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: refs/tags/${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} # The binary embeds the web interface served by `repodna serve`. - uses: actions/download-artifact@v8 with: @@ -161,7 +182,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: refs/tags/${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - name: Install WebKitGTK if: runner.os == 'Linux' run: | @@ -178,6 +199,14 @@ jobs: # Builds the web interface first (Tauri's beforeBuildCommand) and embeds it. - name: Build the installers run: npm run bundle -w @repodna/desktop -- ${{ matrix.bundle-args }} + - name: Check the app signature + # The ad-hoc signature must cover the whole bundle: macOS reports a downloaded app + # without one as damaged, instead of offering to open it from Privacy & Security. + if: runner.os == 'macOS' + run: | + app=apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle/macos/RepoDNA.app + codesign --verify --deep --strict --verbose=2 "$app" + codesign --display --verbose=2 "$app" - name: Collect the installers shell: bash run: | @@ -194,16 +223,89 @@ jobs: path: dist/* if-no-files-found: error + container: + name: Container image + needs: [prepare, cli] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.ref }} + - uses: actions/download-artifact@v8 + with: + pattern: cli-*-linux-musl + path: archives + merge-multiple: true + - name: Prepare the build context + # The static Linux binaries of this release, and the license files. + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + context="$RUNNER_TEMP/image" + for pair in amd64:x86_64 arm64:aarch64; do + arch="${pair%%:*}" + name="repodna-$VERSION-${pair#*:}-unknown-linux-musl" + tar -xzf "archives/$name.tar.gz" -C "$RUNNER_TEMP" + mkdir -p "$context/$arch" + cp "$RUNNER_TEMP/$name/repodna" "$context/$arch/repodna" + done + cp LICENSE NOTICE THIRD-PARTY-NOTICES.txt "$context/" + echo "CONTEXT=$context" >> "$GITHUB_ENV" + - name: Set up emulation and Buildx + # Installing Git into the arm64 image runs under emulation on this x86_64 runner. + run: | + docker run --privileged --rm tonistiigi/binfmt --install arm64 + docker buildx create --use --name repodna + - name: Test the image + run: | + set -euo pipefail + docker buildx build --platform linux/amd64 --load --tag repodna:test \ + --file packaging/container/Dockerfile "$CONTEXT" + docker run --rm repodna:test --version + docker run --rm --volume "$PWD:/work" repodna:test analyze . --profile quick --no-store --quiet > /dev/null + - name: Log in to the GitHub Container Registry + if: needs.prepare.outputs.publish == 'true' + env: + TOKEN: ${{ github.token }} + run: echo "$TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + - name: Build for amd64 and arm64, and push a release + env: + VERSION: ${{ needs.prepare.outputs.version }} + PUBLISH: ${{ needs.prepare.outputs.publish }} + run: | + set -euo pipefail + image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/repodna" + tags=(--tag "$image:$VERSION") + if [[ "$VERSION" != *-* ]]; then + major="${VERSION%%.*}" + minor="${VERSION#*.}" + minor="${minor%%.*}" + tags+=(--tag "$image:$major.$minor" --tag "$image:$major" --tag "$image:latest") + fi + push=() + if [ "$PUBLISH" = true ]; then + push=(--push) + fi + docker buildx build --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="$VERSION" --build-arg REVISION="$(git rev-parse HEAD)" \ + --label org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "${tags[@]}" "${push[@]}" --file packaging/container/Dockerfile "$CONTEXT" + publish: name: Publish the release - needs: [prepare, cli, desktop] + needs: [prepare, cli, desktop, container] + if: needs.prepare.outputs.publish == 'true' runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v7 with: - ref: refs/tags/${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.ref }} - uses: actions/download-artifact@v8 with: pattern: cli-* @@ -225,9 +327,11 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail + # The version's section of the changelog, without the link definitions at its end. awk -v version="$VERSION" ' index($0, "## [" version "]") == 1 { found = 1; next } found && /^## \[/ { exit } + found && /^\[[^]]+\]: / { next } found { print } ' CHANGELOG.md > notes.md cat >> notes.md <<'EOF' @@ -239,10 +343,13 @@ jobs: - `RepoDNA__amd64.deb`, `RepoDNA--1.x86_64.rpm`, `RepoDNA__universal.dmg`, `RepoDNA__x64_en-US.msi`, and `RepoDNA__x64-setup.exe`: the desktop app. + - `ghcr.io/sanskarin/repodna:`: a container image with the command line and + Git, for CI jobs: `docker run --rm -v "$PWD:/work" ghcr.io/sanskarin/repodna analyze .` - `SHA256SUMS.txt`: checksums of every file. EOF echo "The binaries are not code-signed, so macOS and Windows ask for confirmation the first time they start; see the [installation guide](https://github.com/sanskarIN/RepoDNA/blob/$TAG/docs/installation.md)." >> notes.md + sed -i "s//$VERSION/g" notes.md cat notes.md - name: Create or update the release env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 286510e..b47d389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to RepoDNA are documented in this file. The format is based ## [Unreleased] -## [1.0.0] - 2026-09-26 +## [1.0.0] - 2026-09-27 The first stable release: local-first repository intelligence and code archaeology, with every conclusion backed by evidence. @@ -71,6 +71,8 @@ every conclusion backed by evidence. part of the analysis, with search, a command palette, keyboard shortcuts, light and dark themes, a table view for every chart, and a bundled demo that works offline. - A desktop app for Linux, macOS, and Windows, built with Tauri on the same Rust core. +- A container image with the command line and Git, `ghcr.io/sanskarin/repodna`, for + `linux/amd64` and `linux/arm64`. - About & support, Privacy Policy, Terms of Use, and Licenses pages in the web interface and the desktop app; every download includes the licenses of the third-party software it contains (`THIRD-PARTY-NOTICES.txt`). diff --git a/README.md b/README.md index d5fbf8f..9eab58d 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,10 @@ repodna --version **The desktop app.** Installers for Linux (`.deb`, `.rpm`), macOS (`.dmg`), and Windows (`.msi`, `.exe`) are attached to each release. See [the desktop app](docs/desktop.md). +**Container image.** `docker run --rm -v "$PWD:/work" ghcr.io/sanskarin/repodna analyze .` +runs the command line with Git, on `linux/amd64` and `linux/arm64`. See +[the container image](docs/installation.md#container-image). + **The web version.** needs no installation: it opens analyses (`repodna.json` or `.repodna` files) in your browser, without uploading them, and includes the demo. Analyzing a repository needs the command line or the desktop app. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 52a0fa3..52102e1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -35,6 +35,10 @@ This builds the web interface, embeds it, and writes installers for your platfor `.dmg` on macOS, and `.msi` and `.exe` installers on Windows. Choose formats with `npm run bundle -w @repodna/desktop -- --bundles deb`. +On macOS the app is signed ad hoc (`bundle.macOS.signingIdentity` is `-` in +[tauri.conf.json](src-tauri/tauri.conf.json)): Macs with Apple silicon report a downloaded +app whose signature does not cover the whole bundle as damaged, and refuse to open it. + Without the Tauri command line, `cargo build --release --features custom-protocol` in `src-tauri/` builds the app binary with the interface embedded (build the web interface first with `npm run build -w @repodna/web` from the repository root). diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index e00e0a5..4945f1a 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -40,6 +40,9 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "macOS": { + "signingIdentity": "-" + } } } diff --git a/crates/repodna-app/tests/fixtures.rs b/crates/repodna-app/tests/fixtures.rs index af36008..6a25497 100644 --- a/crates/repodna-app/tests/fixtures.rs +++ b/crates/repodna-app/tests/fixtures.rs @@ -229,9 +229,8 @@ fn suspicious_secrets() { secrets .iter() .find(|s| s.rule == rule && s.path == path) - .unwrap_or_else(|| { - panic!("missing expected secret finding") - }) + // Names the expected rule and file, never the findings themselves. + .unwrap_or_else(|| panic!("no {rule} finding in {path}")) }; assert!(!found("aws-access-key-id", "deploy/config.py").in_test_or_example); assert!(found("aws-access-key-id", "tests/fixtures/credentials.json").in_test_or_example); diff --git a/docs/desktop.md b/docs/desktop.md index 4a5bb92..fea684e 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -20,9 +20,10 @@ Installers are attached to each [release](https://github.com/sanskarIN/RepoDNA/r On Linux the app needs WebKitGTK 4.1 (`libwebkit2gtk-4.1-0`), which the packages declare as a dependency. The Linux packages are built on Ubuntu 24.04. -The installers are not code-signed. macOS asks for confirmation the first time: open the -app from Finder with Control-click, **Open**. Windows SmartScreen may show "Windows -protected your PC": choose **More info**, then **Run anyway**. See +The installers are not signed with a developer certificate, so the system asks for +confirmation the first time. On macOS, open the app once, then choose **Open Anyway** in +**System Settings > Privacy & Security**. Windows SmartScreen may show "Windows protected +your PC": choose **More info**, then **Run anyway**. See [installation](installation.md#unsigned-binaries). To build it yourself, see [apps/desktop/README.md](../apps/desktop/README.md). diff --git a/docs/development.md b/docs/development.md index d7f6480..4066e96 100644 --- a/docs/development.md +++ b/docs/development.md @@ -230,4 +230,10 @@ cargo xtask bench --runs 5 # the table in benchmarks/README.md The [release workflow](../.github/workflows/release.yml) checks that the tag matches the workspace version and the changelog, builds the command line for Linux, macOS, and Windows -and the desktop installers, and publishes them with checksums. +and the desktop installers, publishes them with checksums, and pushes the container image +to `ghcr.io/sanskarin/repodna`. After the first release, make the image public once in the +package's settings on GitHub (**Package settings > Change visibility**). + +To try a release before tagging it, run the Release workflow by hand from the Actions tab +with an empty tag: it builds every file from the selected branch and keeps them as +downloads of the run, without publishing anything. diff --git a/docs/installation.md b/docs/installation.md index c38b692..0ceda17 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,6 +6,7 @@ installing anything, use the [web version](web.md#the-web-version). - [Prebuilt binaries](#prebuilt-binaries) - [The desktop app](#the-desktop-app) +- [Container image](#container-image) - [Build from source](#build-from-source) - [Check the installation](#check-the-installation) - [Uninstall](#uninstall) @@ -57,11 +58,14 @@ Get-FileHash .\repodna-1.0.0-x86_64-pc-windows-msvc.zip -Algorithm SHA256 # co ### Unsigned binaries -The binaries and installers are not code-signed. +The binaries and installers are not signed with a developer certificate. -- **macOS** blocks unsigned programs downloaded from the internet. For the command line, - remove the quarantine attribute: `xattr -d com.apple.quarantine /usr/local/bin/repodna`. - For the desktop app, Control-click it in Finder, choose **Open**, and confirm. +- **macOS** blocks programs downloaded from the internet that Apple has not checked. For the + command line, remove the quarantine attribute: + `xattr -d com.apple.quarantine /usr/local/bin/repodna`. For the desktop app, open it once + and close the warning, then open **System Settings > Privacy & Security**, choose + **Open Anyway** next to the message about RepoDNA, and confirm. On macOS 14 and earlier, + you can instead Control-click the app in Finder and choose **Open**. - **Windows** SmartScreen may say "Windows protected your PC". Choose **More info**, then **Run anyway**. @@ -70,6 +74,27 @@ The binaries and installers are not code-signed. Installers for Linux (`.deb`, `.rpm`), macOS (`.dmg`), and Windows (`.msi`, `.exe`) are attached to each release. See [the desktop app](desktop.md). +## Container image + +Each release is also published as a container image with the command line and Git, for CI +jobs and machines where you would rather not install anything. It runs on `linux/amd64` and +`linux/arm64`: + +```sh +docker run --rm -v "$PWD:/work" ghcr.io/sanskarin/repodna analyze . +docker run --rm -v "$PWD:/work" --user "$(id -u):$(id -g)" \ + ghcr.io/sanskarin/repodna report . --output repodna-report +``` + +The current directory is mounted as `/work`, and `--user` makes the files RepoDNA writes +yours. Tags follow the releases: `1.0.0`, `1.0`, `1`, and `latest`. Stored analyses live in +`/tmp/repodna` inside the container and disappear with it; mount a volume there +(`-v repodna-data:/tmp/repodna`) to keep them. `repodna serve` in a container listens on the +container's own loopback address, so use an installed binary or the desktop app for the web +interface. The image is based on Alpine Linux; the Alpine packages in it, such as Git, keep +their own licenses, and their sources are available from +[Alpine Linux](https://gitlab.alpinelinux.org/alpine/aports). + ## Build from source Prerequisites: diff --git a/packaging/container/Dockerfile b/packaging/container/Dockerfile new file mode 100644 index 0000000..210d233 --- /dev/null +++ b/packaging/container/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 +# +# The repodna command line with Git, for CI jobs and machines without an installation. +# The release workflow builds it for linux/amd64 and linux/arm64 from the static Linux +# binaries it publishes, and pushes it to ghcr.io/sanskarin/repodna. The build context holds: +# +# amd64/repodna, arm64/repodna the binaries +# LICENSE, NOTICE, THIRD-PARTY-NOTICES.txt +# +# Usage: docker run --rm -v "$PWD:/work" ghcr.io/sanskarin/repodna analyze . + +FROM alpine:3.24 + +ARG TARGETARCH +ARG VERSION=dev +ARG REVISION=unknown + +LABEL org.opencontainers.image.title="RepoDNA" \ + org.opencontainers.image.description="Local-first repository intelligence and code archaeology: the repodna command line with Git." \ + org.opencontainers.image.source="https://github.com/sanskarIN/RepoDNA" \ + org.opencontainers.image.url="https://github.com/sanskarIN/RepoDNA" \ + org.opencontainers.image.documentation="https://github.com/sanskarIN/RepoDNA/blob/main/docs/installation.md#container-image" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.vendor="Sanskar" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" + +# Git for history analysis. Mounted repositories usually belong to another user, which Git +# refuses unless it is told to trust them. +RUN apk add --no-cache git ca-certificates \ + && git config --system --add safe.directory '*' + +COPY --chmod=0755 ${TARGETARCH}/repodna /usr/local/bin/repodna +COPY LICENSE NOTICE THIRD-PARTY-NOTICES.txt /usr/share/doc/repodna/ + +# Storage in a directory any user can write, so the image also runs with --user. +ENV REPODNA_HOME=/tmp/repodna +WORKDIR /work +ENTRYPOINT ["repodna"] +CMD ["--help"]