slipcase-open 0.1.8

Open the payload of a Slipcase container in its own application, and write edits back into the container
Documentation
# Run the gate, the cross-target checks, and the package on Linux.
#
# `check.sh` is what a person runs and this is the same three things on every
# push, plus the two nobody runs by hand: whether the arms for the platforms
# this runner is not still compile, and whether the association actually
# resolves on a machine that was not the one it was written on.
#
# What it reaches and what it does not, stated because a green tick invites more
# faith than it has earned.
#
# **The suite runs three times rather than once.** `CLAUDE.md` says why, and a
# runner is the case it was written about: the tests watch real directories
# through the platform's notifier, and event timing moves with load. Three
# rather than five only because the runner is slower than a desk, and the
# deadlines the suite waits on are seconds where the events are milliseconds.
#
# **Nothing here reaches a notification service.** The two tests that talk to
# `org.freedesktop.Notifications` are `#[ignore]`d and stay that way: there is
# no session bus on a runner, and a test that quietly passed by finding none
# would be worse than no test. What they check is measured by hand on a desktop
# and recorded in the module that holds them.
#
# **Nothing here opens a document.** `platform::testing::Recording` is what the
# suite launches through, so `xdg-open` is never called and no application is
# ever handed a payload. The whole of concept 5 step 7 is unexercised here and
# is checked by hand.
#
# Author: David M. Anderson
# Built with AI assistance (Claude, Anthropic)

name: Linux

on:
  push:
  pull_request:
  # What attaches the package. A person's token creates the release, so the
  # event fires; a release created by a workflow using the default
  # GITHUB_TOKEN fires nothing, which is what the dispatch below is for. A
  # release event resolves this file at the tag rather than on the default
  # branch.
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      # Optional. With a tag the package is attached to that release; without
      # one it is built, checked and kept as an artefact, which is how
      # everything above the attach is exercised without a release to spend.
      tag:
        description: 'Tag to package for (e.g. v0.1.8)'
        required: false
        type: string

env:
  CARGO_TERM_COLOR: always

permissions:
  contents: read

concurrency:
  # Keyed by the tag when this is releasing and by the ref when it is checking,
  # so a push cannot cancel a run that is building a release's package.
  group: linux-${{ github.event.release.tag_name || inputs.tag || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}

jobs:
  linux:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}

      - name: What is installed
        run: rustc -V && cargo -V && dpkg-deb --version | head -1

      - name: Formatting
        run: cargo fmt --check

      - name: Clippy
        run: cargo clippy --all-targets -- -D warnings

      - name: Build
        run: cargo build --all-targets

      # Three runs, for the reason in the header. Not `check.sh`, because that
      # would run fmt and clippy again inside a step that says it is testing.
      - name: Test, three times
        run: for i in 1 2 3; do echo "run $i"; cargo test --quiet; done

      # The arms for the platforms this runner is not. Written against
      # Windows' requirements while only Linux existed, which is worth nothing if
      # nobody compiles them — and when this step was first written, they did
      # not compile: `identity.rs` used `windows_by_handle`, unstable since
      # 2019, and the endpoint's tests imported a Unix-only function.
      #
      # Both are everything now. Windows was the library alone for as long as
      # the binary could not be built for it at all — concept 8's named pipe was
      # Phase 4 and the front door and the resident loop were Unix-only — and
      # that landed, so the gate that said so comes off with it. `windows.yml`
      # is what runs the suite there; this stays as the cheaper half, because a
      # Linux runner says whether a change still compiles for Windows without
      # anybody waiting for a Windows one.
      #
      # macOS is a Unix and compiles whole. It has no workflow of its own, and
      # a compile is all this claims about it.
      - name: The arms for the other platforms still compile
        run: |
          rustup target add x86_64-pc-windows-msvc x86_64-apple-darwin
          cargo check --all-targets --target x86_64-pc-windows-msvc
          cargo check --all-targets --target x86_64-apple-darwin

      - name: Build the package
        id: package
        run: |
          cargo install cargo-deb --locked
          cargo build --release
          # cargo-deb prints the path it wrote, which is the only reliable way
          # to find it: `[build] target-dir` moves the target directory and no
          # environment variable then says so.
          cargo deb --no-build | tee /tmp/deb-path
          # The same path as an output, because `upload-artifact` takes a glob
          # rather than a command and cannot read the file above.
          echo "path=$(cat /tmp/deb-path)" >> "$GITHUB_OUTPUT"

      # Two things worth reading every time. `/etc/slipcase/open.toml` must be a
      # conffile or dpkg overwrites an administrator's policy on the next
      # upgrade, which is the whole reason it is under `/etc`. And the binary
      # must be in there at all: the asset list names it as
      # `target/release/slipcase-open`, a path cargo-deb rewrites to wherever
      # the target directory really is.
      - name: The package holds up
        run: |
          deb=$(cat /tmp/deb-path)
          echo "package: $deb"
          dpkg-deb -c "$deb"
          dpkg-deb -I "$deb" conffiles | grep -qx '/etc/slipcase/open.toml' ||
              { echo "the policy file is not a conffile" >&2; exit 1; }
          dpkg-deb -c "$deb" | grep -q 'usr/bin/slipcase-open' ||
              { echo "no binary in the package" >&2; exit 1; }
          dpkg-deb -f "$deb" Depends | grep -q slipcase-common ||
              { echo "the package does not depend on slipcase-common" >&2; exit 1; }

      # The one layer no test can reach. `/etc/slipcase/open.toml` is
      # deliberately not redirectable — a machine policy an environment
      # variable could move is not one — so the suite has no way to put a file
      # there, and the machine arm of `policy` is unexercised by everything
      # above. Here there is a machine to write to.
      #
      # What it checks is the unit test `the_policy_file_the_package_ships_says
      # _nothing` makes of the shipped file, asked through the real resolution
      # instead of a `Files` pointed at the repository copy: an untouched
      # install must not read as administered. A stray uncommented line in that
      # file is a policy nobody wrote, enforced on every machine that installs
      # the package, and announced to each of them as their settings being
      # administered.
      - name: The shipped policy file reads as documentation
        run: |
          sudo install -D -m 0644 packaging/linux/open.toml /etc/slipcase/open.toml
          said=$(cargo run --release --quiet -- policy)
          printf '%s\n' "$said"
          printf '%s\n' "$said" | grep -q '/etc/slipcase/open.toml (there, and sets nothing)' ||
              { echo "the shipped file is not being read, or is setting something" >&2; exit 1; }
          # `if` rather than `grep ... && { exit 1; }`: under `set -e` a whole
          # AND-list that fails is a failed command, so the good case — no
          # match — would end the step with the inverted verdict.
          if printf '%s\n' "$said" | grep -qi 'administered'; then
              echo "an untouched install reads as administered" >&2
              exit 1
          fi
          sudo rm -rf /etc/slipcase

      - name: What lintian says
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq lintian
          lintian --info --tag-display-limit 0 --fail-on error,warning "$(cat /tmp/deb-path)"

      # The only step that asks the platform anything. Concept 4 wants this
      # tool's entry in the Open With list, and that is two questions with no
      # eyes in them.
      #
      # `gio` rather than `xdg-mime`: it asks GLib for the content type, which
      # is the path a GTK file manager takes, and it needs no session. The
      # sibling repository's workflow found that `xdg-mime` without a session
      # falls back to `file`, which reads magic bytes — and SPEC §4 reserves
      # none, so it would answer `application/zip` however well the glob was
      # installed.
      #
      # `slipcase-common` declares the type and this package does not, so it is
      # installed first or there is nothing for the entry to resolve against.
      # Pinned, because this clones another repository and runs a shell script
      # out of it: whoever can land a commit there executes code here. Bump it
      # deliberately, and take the hash from `git rev-parse` rather than typing
      # it — the first version of this line was a real short hash with the rest
      # invented, which clones fine and fails the checkout with exit 128.
      #
      # COMMON-PIN: bfde6bec075e3811932907bdfec62a0cc2e1822f
      - name: The type and the entry are ours
        run: |
          sudo apt-get install -y -qq shared-mime-info desktop-file-utils libglib2.0-bin

          git clone --filter=blob:none --no-checkout \
              https://github.com/excelano/slipcase-common.git "${RUNNER_TEMP}/common"
          git -C "${RUNNER_TEMP}/common" checkout --detach \
              bfde6bec075e3811932907bdfec62a0cc2e1822f
          "${RUNNER_TEMP}/common/install.sh"
          ./packaging/linux/install.sh

          # A real container, because an empty file answers
          # `application/x-zerosize` whatever the glob says. Built here rather
          # than committed: a fixture in the tree would be one more thing that
          # can drift from what SPEC §2 requires.
          python3 -c 'import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],"w"); z.writestr("slipcase.metadata.toml","slipcase_version = \"1.0\"\n\n[payload]\nfile = \"report.txt\"\n"); z.writestr("report.txt","a report\n"); z.close()' "${RUNNER_TEMP}/sample.slpc"

          type=$(gio info -a standard::content-type "${RUNNER_TEMP}/sample.slpc" |
              sed -n 's/.*standard::content-type: //p')
          echo "content type: ${type}"
          [ "$type" = "application/vnd.excelano.slipcase+zip" ] ||
              { echo "the type is not ours" >&2; exit 1; }

          gio mime application/vnd.excelano.slipcase+zip | grep -q 'slipcase-open.desktop' ||
              { echo "this tool is not registered against the type" >&2; exit 1; }
          gio mime application/vnd.excelano.slipcase+zip

      # The package leaves this job, which until now it did not: it was built
      # on every push, checked by everything above, and thrown away at the end
      # of the job. A release that carries no `.deb` is a release the apt step
      # refuses.
      - uses: actions/upload-artifact@v7
        with:
          name: deb
          path: ${{ steps.package.outputs.path }}
          if-no-files-found: error

  # Only where there is a release to attach to. A push builds the package to
  # check it and keeps it as an artefact, which is the point of building it on
  # a push; a dispatch with no tag does the same, so everything above can be
  # exercised without spending a release.
  attach:
    needs: linux
    if: github.event.release.tag_name || inputs.tag
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: deb
          path: packages

      - name: Attach the package to the release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
          TAG: ${{ github.event.release.tag_name || inputs.tag }}
        run: |
          set -eu
          ls -l packages
          # The tag goes through the environment rather than into the command's
          # text: GitHub substitutes an expression before the shell parses the
          # line, and a dispatch input is free text.
          gh release upload "$TAG" packages/*.deb