diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index b772daa..e38613d 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -16,13 +16,14 @@ inputs: runs: using: composite steps: - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: targets: ${{ inputs.targets }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: ${{ inputs.cache-key }} + save-if: false - name: Stamp the version shell: bash diff --git a/.github/actions/sidecar/action.yml b/.github/actions/sidecar/action.yml new file mode 100644 index 0000000..c3cfb1b --- /dev/null +++ b/.github/actions/sidecar/action.yml @@ -0,0 +1,21 @@ +name: The resident the picker carries +description: >- + `externalBin` in tauri.conf.json makes the binary a build-time requirement of + the picker, so anything that compiles the workspace — a test, a clippy run — + needs it there first, not only a bundle. + +inputs: + profile: + description: debug or release. + required: false + default: debug + target: + description: Rust target the resident is built for, empty for the host. + required: false + default: "" + +runs: + using: composite + steps: + - shell: bash + run: bash scripts/sidecar.sh "${{ inputs.profile }}" "${{ inputs.target }}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9df346a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,73 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directories: + - / + - /.github/actions/prepare + - /.github/actions/sidecar + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: "chore" + groups: + actions: + patterns: + - "*" + update-types: + - minor + - patch + actions-majors: + patterns: + - "*" + update-types: + - major + + - package-ecosystem: cargo + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: "chore" + groups: + rust: + patterns: + - "*" + update-types: + - minor + - patch + rust-majors: + patterns: + - "*" + update-types: + - major + + - package-ecosystem: npm + directory: /app + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: "chore" + groups: + picker: + patterns: + - "*" + update-types: + - minor + - patch + picker-majors: + patterns: + - "*" + update-types: + - major + + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 1 + commit-message: + prefix: "chore" diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index e097750..d911db0 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -29,6 +29,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always @@ -38,9 +42,11 @@ jobs: runs-on: windows-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/setup-node@v7 with: @@ -98,11 +104,13 @@ jobs: env: TARGET: aarch64-apple-darwin steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: targets: aarch64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/caches.yml b/.github/workflows/caches.yml new file mode 100644 index 0000000..b52c8fe --- /dev/null +++ b/.github/workflows/caches.yml @@ -0,0 +1,61 @@ +name: Caches + +on: + pull_request_target: + types: [closed] + workflow_run: + workflows: ["CI"] + types: [completed] + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "List what would go, delete nothing." + type: boolean + default: true + +permissions: + actions: write + +jobs: + sweep: + name: a closed pull request takes its caches with it + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Everything written under this pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + REF: refs/pull/${{ github.event.pull_request.number }}/merge + run: gh cache delete --all --ref "$REF" --succeed-on-no-caches + + generations: + name: one generation of each cache, and nothing a tag left behind + if: >- + github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + || (github.event_name == 'workflow_run' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_branch == 'main' + && github.event.workflow_run.head_repository.full_name == github.repository + && github.event.workflow_run.conclusion != 'cancelled') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + concurrency: + group: cache-generations + cancel-in-progress: false + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: What no build will ask for again + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + run: python3 scripts/generations.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9327905..54f1812 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings @@ -16,38 +20,22 @@ jobs: test: name: test / ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 45 strategy: fail-fast: false matrix: os: [windows-latest, macos-latest] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@nextest - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh - - run: cargo nextest run --workspace --no-tests=pass - - run: cargo test --doc --workspace - - lint: - name: fmt + clippy / ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest] - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --all --check - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh + components: clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: ./.github/actions/sidecar + - uses: taiki-e/install-action@nextest - run: cargo clippy --workspace --all-targets - name: Each crate builds on its own, without the features its neighbours bring along shell: bash @@ -55,15 +43,30 @@ jobs: for one in linkunbound-core linkunbound-win linkunbound-mac linkunbound-shell linkunbound-settings; do cargo check -p "$one" --all-targets done + - run: cargo nextest run --workspace --no-tests=pass + - if: runner.os == 'Windows' + run: cargo test --doc --workspace + + lint: + name: fmt + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + components: rustfmt + - run: cargo fmt --all --check picker: name: frontend runs-on: ubuntu-latest + timeout-minutes: 20 defaults: run: working-directory: app steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 @@ -77,31 +80,38 @@ jobs: prose: name: markdown runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 + cache: npm + cache-dependency-path: package-lock.json - run: npm ci - run: npm run lint:md audit: name: advisories + licences runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: actions/checkout@v7 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 coverage: name: coverage runs-on: ubuntu-latest + timeout-minutes: 40 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest @@ -143,12 +153,15 @@ jobs: coverage-mac: name: coverage / the Mac crate runs-on: macos-latest + timeout-minutes: 40 steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: The Mac crate, and the Mac branches of the core and the picker @@ -165,23 +178,27 @@ jobs: name: sonarcloud needs: [coverage, coverage-mac] runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 15 + if: >- + github.actor != 'dependabot[bot]' + && (github.event_name == 'push' + || github.event.pull_request.head.repo.full_name == github.repository) steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: coverage path: . - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: coverage-mac path: . - - uses: SonarSource/sonarqube-scan-action@v6 + - uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: @@ -194,6 +211,7 @@ jobs: -Dsonar.exclusions=crates/*/tests/**,app/src/tests/** -Dsonar.rust.lcov.reportPaths=lcov-rust.info,lcov-mac.info -Dsonar.javascript.lcov.reportPaths=app/coverage/lcov.info + -Dsonar.cpd.exclusions=app/src/i18n.ts -Dsonar.rust.clippy.enabled=false -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/** -Dsonar.qualitygate.wait=true diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 8c1eced..327f413 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -11,50 +11,44 @@ name: CLA # went unread. Reopening it without that trailer costs a minute. on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, synchronize, reopened] + issue_comment: + types: [created] + pull_request_target: + types: [opened, synchronize, reopened] permissions: - actions: write - contents: read - pull-requests: write - statuses: write + actions: write + contents: read + pull-requests: write + statuses: write jobs: - cla: - name: Check CLA signature - runs-on: ubuntu-latest - # Only react to the signature phrase or to PR events, never to ordinary - # comment traffic. - if: > - (github.event_name == 'issue_comment' && - github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || - github.event_name == 'pull_request_target' - steps: - - name: CLA Assistant - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Fine-grained PAT with contents:write on the signatures - # repository. Without it the action cannot record a signature. - PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }} - with: - # Scoped per project: one signatures repository serves every - # dual-licensed repo, but a signature given for one CLA must - # never count as consent for another project's. - path-to-signatures: "signatures/linkunbound/v1/cla.json" - path-to-document: "https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md" - branch: "main" - allowlist: rgdevment,dependabot[bot] - remote-organization-name: rgdevment - remote-repository-name: cla-signatures - custom-notsigned-prcomment: > - Thanks for the pull request. LinkUnbound is released under the GPL-3.0 - and offered under separate commercial terms, which requires a signature - from every contributor before code can be merged. You keep the copyright - on your work — see [CLA.md](https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md). - To sign, post a comment on this pull request with exactly: - custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" - custom-allsigned-prcomment: "All contributors have signed the CLA. Thanks!" + cla: + name: Check CLA signature + runs-on: ubuntu-latest + timeout-minutes: 10 + if: > + (github.event_name == 'issue_comment' && + github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || + github.event_name == 'pull_request_target' + steps: + - name: CLA Assistant + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }} + with: + path-to-signatures: "signatures/linkunbound/v1/cla.json" + path-to-document: "https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md" + branch: "main" + allowlist: rgdevment,dependabot[bot] + remote-organization-name: rgdevment + remote-repository-name: cla-signatures + custom-notsigned-prcomment: > + Thanks for the pull request. LinkUnbound is released under the GPL-3.0 + and offered under separate commercial terms, which requires a signature + from every contributor before code can be merged. You keep the copyright + on your work — see [CLA.md](https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md). + To sign, post a comment on this pull request with exactly: + custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" + custom-allsigned-prcomment: "All contributors have signed the CLA. Thanks!" diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 67f4110..b0cb990 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -7,12 +7,17 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: conventional: name: Conventional commit messages runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -29,13 +34,15 @@ jobs: subject=$(git log -1 --format=%s "$sha" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if ! printf '%s' "$subject" | grep -qE "$pattern"; then - echo "::error::${sha:0:8} does not follow conventional commits: $subject" + echo "::error::${sha:0:8} does not follow conventional commits" + printf ' %s\n' "$subject" failed=1 continue fi if [ "${#subject}" -gt 90 ]; then - echo "::error::${sha:0:8} subject is ${#subject} characters, keep it under 90: $subject" + echo "::error::${sha:0:8} subject is ${#subject} characters, keep it under 90" + printf ' %s\n' "$subject" failed=1 fi done < <(git rev-list --no-merges "$BASE".."$HEAD") diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index d5616fb..11eecd7 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -8,7 +8,7 @@ on: schedule: - cron: "23 7 * * *" release: - types: [published, unpublished, deleted, edited, released] + types: [unpublished, deleted] workflow_dispatch: permissions: @@ -20,14 +20,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Read it the way an installed copy does env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - sudo apt-get update -qq && sudo apt-get install -y -qq minisign + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest" # The key every installed copy carries. Verifying against anything else would be @@ -42,7 +47,8 @@ jobs: -q '.[] | select(.isPrerelease == false and .isDraft == false) | .tagName' \ | sed 's/^v//' | grep -E '^[2-9][0-9]*\.' | sort -V | tail -1 || true) - if ! curl -fsSL -H "Cache-Control: no-cache" -o manifest.json "$feed/release-manifest.json"; then + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" -o manifest.json "$feed/release-manifest.json"; then echo "::error::the feed did not answer. Every copy out there is looking at nothing, \ and will go on looking at nothing until the branch is put back." exit 1 @@ -91,7 +97,8 @@ jobs: # bytes, which answers 200 and fails on every machine in the world. takes() { local what="$1" expected="$2" - if ! curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed/$what"; then + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" -o channel.json "$feed/$what"; then echo "::error::$what did not answer, so what is announced is an update nobody \ can take" return 1 @@ -114,7 +121,7 @@ jobs: for os in $names; do url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig - if ! curl -fsSL -o one.bin "$url"; then + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 -o one.bin "$url"; then echo "::error::the installer for $os in $what does not answer: $url" return 1 fi @@ -139,7 +146,8 @@ jobs: if [ -n "$ahead" ]; then echo "the candidates' channel, announcing $ahead:" takes candidate.json "$ahead" - elif curl -fsSL -o /dev/null "$feed/candidate.json" 2>/dev/null; then + elif curl -fsSL -H "Cache-Control: no-cache" -o /dev/null \ + "$feed/candidate.json" 2>/dev/null; then echo "::error::candidate.json is still being served and the feed announces no \ candidate. A copy that remembers one would be sent to a version nobody is \ publishing any more." diff --git a/.github/workflows/mutants-sweep.yml b/.github/workflows/mutants-sweep.yml index 928942a..7a51449 100644 --- a/.github/workflows/mutants-sweep.yml +++ b/.github/workflows/mutants-sweep.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: mutants-sweep + cancel-in-progress: false + env: CARGO_TERM_COLOR: always DESKTOP_LIBS: libgtk-3-dev libayatana-appindicator3-dev libxdo-dev libfontconfig1-dev libfreetype6-dev libxkbcommon-dev libwayland-dev libxcb1-dev @@ -18,9 +22,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -54,9 +61,12 @@ jobs: matrix: shard: [1, 2, 3, 4] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -96,9 +106,12 @@ jobs: matrix: shard: [1, 2] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -141,9 +154,11 @@ jobs: matrix: shard: [1, 2] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -200,7 +215,7 @@ jobs: run: working-directory: app steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: @@ -249,7 +264,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v7 with: diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index efca9fe..dd7a6b7 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -12,6 +12,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always @@ -21,7 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -34,9 +38,12 @@ jobs: git diff "$from" -- crates/linkunbound-core crates/linkunbound-shell > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false if: steps.touched.outputs.any == 'yes' - uses: taiki-e/install-action@nextest if: steps.touched.outputs.any == 'yes' @@ -77,7 +84,7 @@ jobs: runs-on: macos-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -90,9 +97,12 @@ jobs: git diff "$from" -- crates > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false if: steps.touched.outputs.any == 'yes' - uses: taiki-e/install-action@nextest if: steps.touched.outputs.any == 'yes' @@ -127,7 +137,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -140,9 +150,12 @@ jobs: git diff "$from" -- app/src-tauri/src > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false if: steps.touched.outputs.any == 'yes' - uses: taiki-e/install-action@nextest if: steps.touched.outputs.any == 'yes' @@ -187,7 +200,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d67e8eb..987cc63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,10 @@ on: default: "0.0.0-dispatch" permissions: - contents: write - attestations: write - id-token: write + contents: read concurrency: - group: release + group: release-${{ github.ref }} cancel-in-progress: false env: @@ -27,12 +25,16 @@ jobs: gate: name: Style and prose runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json - run: npm ci - run: npm run lint:md - name: Frontend style @@ -41,6 +43,53 @@ jobs: npm ci npm run lint + tested: + name: Tested when it landed + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + actions: read + env: + GH_REPO: ${{ github.repository }} + steps: + - name: This commit was tested when it landed + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for attempt in $(seq 1 40); do + if ! said=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push \ + --limit 1 --json status,conclusion \ + --jq 'if length == 0 then "" else .[0] | "\(.status) \(.conclusion)" end' \ + 2>"$RUNNER_TEMP/gh.err"); then + echo "::error::the run history could not be read: $(cat "$RUNNER_TEMP/gh.err")" + exit 1 + fi + case "$said" in + "completed success") + echo "the commit this tag names passed CI on main" + exit 0 + ;; + "completed "*) + echo "::error::CI on ${GITHUB_SHA:0:8} ended ${said#completed }, so this tag is \ + not a release" + exit 1 + ;; + "") + echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/40)" + sleep 30 + ;; + *) + echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/40)" + sleep 30 + ;; + esac + done + echo "::error::twenty minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ + ran: a tag belongs on a commit that reached main and was tested there" + exit 1 + rust: name: fmt + clippy runs-on: windows-latest @@ -48,15 +97,16 @@ jobs: env: RUSTFLAGS: -D warnings steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false + - uses: ./.github/actions/sidecar - run: cargo fmt --all --check - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh - run: cargo clippy --workspace --all-targets version: @@ -69,67 +119,74 @@ jobs: prerelease: ${{ steps.resolve.outputs.prerelease }} macos: ${{ steps.resolve.outputs.macos }} env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_P12_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD != '' }} + HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }} + HAS_APPLE_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + HAS_UPDATER_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD != '' }} + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_PFX_PASSWORD: ${{ secrets.PFX_PASSWORD != '' }} steps: - name: Signing secrets come in pairs run: | fail() { echo "::error::$1"; exit 1; } - if [ -n "$PFX_BASE64" ] && [ -z "$PFX_PASSWORD" ]; then + if [ "$HAS_PFX" = "true" ] && [ "$HAS_PFX_PASSWORD" != "true" ]; then fail "PFX_BASE64 is set but PFX_PASSWORD is empty" fi - if [ -n "$MACOS_CERTIFICATE_P12" ] && [ -z "$MACOS_CERTIFICATE_PASSWORD" ]; then + if [ "$HAS_P12" = "true" ] && [ "$HAS_P12_PASSWORD" != "true" ]; then fail "MACOS_CERTIFICATE_P12 is set but MACOS_CERTIFICATE_PASSWORD is empty" fi - if [ -n "$APPLE_ID" ] && { [ -z "$APPLE_APP_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; }; then + if [ "$HAS_APPLE_ID" = "true" ] && + { [ "$HAS_APPLE_APP_PASSWORD" != "true" ] || [ "$HAS_APPLE_TEAM_ID" != "true" ]; }; then fail "APPLE_ID is set but APPLE_APP_PASSWORD or APPLE_TEAM_ID is empty" fi - if [ -n "$MACOS_CERTIFICATE_P12" ] && [ -z "$APPLE_SIGNING_IDENTITY" ]; then + if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_IDENTITY" != "true" ]; then fail "MACOS_CERTIFICATE_P12 is set but APPLE_SIGNING_IDENTITY is empty: the app would be bundled unsigned" fi - if [ "${{ github.event_name }}" = "push" ] && [ "$BUILD_MACOS" = "true" ]; then - if [ -z "$MACOS_CERTIFICATE_P12" ] || [ -z "$APPLE_ID" ]; then - fail "no Apple signing or notarization secrets. A disk image that is not notarized \ - is one Gatekeeper refuses, and the README promises otherwise." - fi - fi - if [ "${{ github.event_name }}" = "push" ] && [ -z "$PFX_BASE64" ]; then - fail "no Windows signing certificate. Set PFX_BASE64 and PFX_PASSWORD, or the \ - installer goes out unsigned." - fi - if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] && [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then + if [ "$HAS_UPDATER_KEY" = "true" ] && [ "$HAS_UPDATER_PASSWORD" != "true" ]; then echo "::notice::the updater key carries no password; that is fine if it was made without one" fi - if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then - if [ "${{ github.event_name }}" = "push" ]; then - fail "no updater key. Every copy already out there would be told about this release \ - and none could take it, and a release is not something an installed copy learns was \ - taken back. Set TAURI_SIGNING_PRIVATE_KEY." + if [ "$HAS_UPDATER_KEY" != "true" ]; then + echo "::warning::no updater key; this release cannot be installed by an existing copy" + fi + + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + missing="" + want() { [ "$2" = "true" ] || missing="$missing $1"; } + want TAURI_SIGNING_PRIVATE_KEY "$HAS_UPDATER_KEY" + want PFX_BASE64 "$HAS_PFX" + want PFX_PASSWORD "$HAS_PFX_PASSWORD" + if [ "$BUILD_MACOS" = "true" ]; then + want MACOS_CERTIFICATE_P12 "$HAS_P12" + want MACOS_CERTIFICATE_PASSWORD "$HAS_P12_PASSWORD" + want APPLE_SIGNING_IDENTITY "$HAS_APPLE_IDENTITY" + want APPLE_ID "$HAS_APPLE_ID" + want APPLE_APP_PASSWORD "$HAS_APPLE_APP_PASSWORD" + want APPLE_TEAM_ID "$HAS_APPLE_TEAM_ID" fi - echo "::warning::no updater key; this build cannot be installed by an existing copy" + [ -z "$missing" ] || fail "this tag would be released without:$missing" fi + echo "signing secrets are coherent" - id: resolve + env: + ASKED_FOR: ${{ inputs.version }} run: | if [[ "$GITHUB_REF" =~ ^refs/tags/v(.+)$ ]]; then version="${BASH_REMATCH[1]}" else - version="${{ inputs.version }}" + version="$ASKED_FOR" fi - num='(0|[1-9][0-9]*)' - pre='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' - if [[ ! "$version" =~ ^$num\.$num\.$num(-$pre(\.$pre)*)?$ ]]; then + strict='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + strict+='(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + strict+='(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + if [[ ! "$version" =~ $strict ]]; then echo "::error::«$version» is not a version semver reads (1.2.3 or 1.2.3-rc.1)" exit 1 fi @@ -159,12 +216,10 @@ jobs: stage: ${{ steps.stage.outputs.sha }} env: TARGET: x86_64-pc-windows-msvc - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/prepare with: @@ -181,17 +236,53 @@ jobs: working-directory: app run: npm ci - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh release + - uses: ./.github/actions/sidecar + with: + profile: release + + - name: Teach the bundler to sign what it packs + if: env.HAS_PFX == 'true' + shell: pwsh + env: + PFX_BASE64: ${{ secrets.PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} + run: | + $pfx = Join-Path $env:RUNNER_TEMP "bundler.pfx" + Set-Content -Path $pfx -Value ([Convert]::FromBase64String($env:PFX_BASE64)) -AsByteStream + try { + $said = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force + $cert = @(Import-PfxCertificate -FilePath $pfx ` + -CertStoreLocation Cert:\CurrentUser\My -Password $said)[0] + } finally { + Remove-Item $pfx -ErrorAction SilentlyContinue + } + + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + @{ bundle = @{ windows = @{ signCommand = @{ + cmd = $signtool.FullName + args = @("sign", "/sha1", $cert.Thumbprint, "/fd", "sha256", + "/tr", "http://timestamp.digicert.com", "/td", "sha256", "%1") + } } } } | ConvertTo-Json -Depth 8 | Set-Content -Path app/sign.json -Encoding utf8 + "CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append + Write-Host "the bundler signs with $($cert.Thumbprint)" - name: Bundle working-directory: app shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | sign="" if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi - npm run tauri build -- --bundles nsis $sign + packs="" + if [ -f sign.json ]; then packs="--config sign.json"; fi + npm run tauri build -- --bundles nsis $sign $packs - name: The installer carries both binaries shell: bash @@ -203,18 +294,82 @@ jobs: fi script=$(ls target/release/nsis/*/installer.nsi | head -1) if [ -z "$script" ] || ! grep -q 'oname=linkunbound-shell.exe' "$script"; then - echo "::error::the generated installer script does not lay down the resident; the sidecar was not bundled" + echo "::error::the generated installer script does not lay down the resident; the sidecar was not bundled" exit 1 fi echo "the installer lays down the resident" - name: Set the binaries aside for the MSIX - id: stage shell: bash run: | mkdir -p stage cp target/release/linkunbound-settings.exe stage/ cp target/release/linkunbound-shell.exe stage/ + + - name: Sign what the MSIX will carry + if: env.HAS_PFX == 'true' + shell: pwsh + run: | + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -like "*x64*" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + & $signtool.FullName sign /sha1 $env:CERT_THUMBPRINT /fd sha256 ` + /tr http://timestamp.digicert.com /td sha256 ` + stage/linkunbound-settings.exe stage/linkunbound-shell.exe + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } + + - name: What the installer will put on disk is signed + if: env.HAS_PFX == 'true' + shell: pwsh + run: | + $setup = Get-ChildItem -Path "target/release/bundle/nsis" -Filter "*-setup.exe" | + Select-Object -First 1 + if (-not $setup) { Write-Error "no installer to open"; exit 1 } + + $seven = @( + "C:\Program Files\7-Zip\7z.exe", + "C:\Program Files (x86)\7-Zip\7z.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $seven) { + $seven = (Get-Command 7z, 7za -ErrorAction SilentlyContinue | + Select-Object -First 1).Source + } + if (-not $seven) { Write-Error "no 7-Zip to open the installer with"; exit 1 } + + $peek = Join-Path $env:RUNNER_TEMP "peek" + & $seven x $setup.FullName "-o$peek" -y | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "the installer could not be opened"; exit 1 } + + $mine = Get-ChildItem -Path $peek -Recurse -File | + Where-Object { $_.Extension -in ".exe", ".dll" -and $_.FullName -notmatch [regex]::Escape('$PLUGINSDIR') } + if (-not $mine) { Write-Error "the installer holds nothing to run"; exit 1 } + + $status = 0 + foreach ($one in $mine) { + $where = $one.FullName.Substring($peek.Length).TrimStart("\\") + $sig = Get-AuthenticodeSignature $one.FullName + if (-not $sig.SignerCertificate) { + Write-Error "$where goes out unsigned" + $status = 1 + } elseif ($sig.SignerCertificate.Thumbprint -ne $env:CERT_THUMBPRINT) { + Write-Error "$where is signed by $($sig.SignerCertificate.Subject), which is not this build's certificate" + $status = 1 + } elseif (-not $sig.TimeStamperCertificate) { + Write-Error "$where is signed without a timestamp, so it dies with the certificate" + $status = 1 + } else { + Write-Host "$where <- $($sig.SignerCertificate.Subject)" + } + } + exit $status + + - name: What the MSIX job must find unchanged + id: stage + shell: bash + run: | echo "sha=$(cat stage/linkunbound-settings.exe stage/linkunbound-shell.exe | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v7 @@ -225,12 +380,9 @@ jobs: if-no-files-found: error - name: Sign the installer - if: env.PFX_BASE64 != '' + if: env.HAS_PFX == 'true' shell: pwsh run: | - $bytes = [Convert]::FromBase64String($env:PFX_BASE64) - Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream - $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` -Recurse -Filter "signtool.exe" | Where-Object { $_.FullName -like "*x64*" } | @@ -241,15 +393,17 @@ jobs: Select-Object -First 1 if (-not $setup) { Write-Error "no installer to sign"; exit 1 } - & $signtool.FullName sign /f signingCert.pfx /p $env:PFX_PASSWORD ` - /tr http://timestamp.digicert.com /td sha256 /fd sha256 $setup.FullName + & $signtool.FullName sign /sha1 $env:CERT_THUMBPRINT /fd sha256 ` + /tr http://timestamp.digicert.com /td sha256 $setup.FullName if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } - Remove-Item signingCert.pfx $sig = Get-AuthenticodeSignature $setup.FullName if (-not $sig.TimeStamperCertificate) { Write-Error "no timestamp: the signature would die with the certificate"; exit 1 } + if ($sig.SignerCertificate.Thumbprint -ne $env:CERT_THUMBPRINT) { + Write-Error "the installer was signed by $($sig.SignerCertificate.Subject), not by this build's certificate"; exit 1 + } Write-Host "status: $($sig.Status)" - name: Name it after the release @@ -266,9 +420,12 @@ jobs: ls -l dist - name: Sign it for the updater - if: env.TAURI_SIGNING_PRIVATE_KEY != '' + if: env.HAS_UPDATER_KEY == 'true' working-directory: app shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | v="${{ needs.version.outputs.version }}" exe="../dist/linkunbound-installer-$v-windows-x86_64.exe" @@ -280,6 +437,7 @@ jobs: with: name: bundle-windows path: dist/* + retention-days: 7 if-no-files-found: error bundle-macos: @@ -299,14 +457,11 @@ jobs: env: TARGET: ${{ matrix.target }} ARCH: ${{ matrix.arch }} - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/prepare with: @@ -329,12 +484,15 @@ jobs: floor=$(jq -r '.bundle.macOS.minimumSystemVersion' app/src-tauri/tauri.conf.json) echo "MACOSX_DEPLOYMENT_TARGET=$floor" >> "$GITHUB_ENV" - - name: The resident rides along - run: bash scripts/sidecar.sh release "$TARGET" + - uses: ./.github/actions/sidecar + with: + profile: release + target: ${{ matrix.target }} - name: Import signing certificate - if: env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_P12 == 'true' env: + MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: | KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" @@ -356,7 +514,7 @@ jobs: run: npm run tauri build -- --target "$TARGET" --no-bundle - name: Sign the settings binary - if: env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_P12 == 'true' env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -368,6 +526,8 @@ jobs: working-directory: app env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | sign="" if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi @@ -403,7 +563,7 @@ jobs: exit $status - name: The app is really signed - if: env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_P12 == 'true' run: | app="target/$TARGET/release/bundle/macos/LinkUnbound.app" codesign --verify --deep --strict --verbose=2 "$app" @@ -413,7 +573,11 @@ jobs: fi - name: Notarize - if: env.APPLE_ID != '' && env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_APPLE_ID == 'true' && env.HAS_P12 == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | dmg=$(ls target/$TARGET/release/bundle/dmg/*.dmg | head -1) xcrun notarytool submit "$dmg" \ @@ -422,7 +586,11 @@ jobs: xcrun stapler staple "$dmg" - name: Staple the app itself, and tar it again - if: env.APPLE_ID != '' && env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_APPLE_ID == 'true' && env.HAS_P12 == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | out="target/$TARGET/release/bundle/macos" app="$out/LinkUnbound.app" @@ -443,8 +611,11 @@ jobs: xcrun stapler validate "$RUNNER_TEMP/rt/LinkUnbound.app" - name: Sign it for the updater - if: env.TAURI_SIGNING_PRIVATE_KEY != '' && env.APPLE_ID != '' + if: env.HAS_UPDATER_KEY == 'true' && env.HAS_APPLE_ID == 'true' working-directory: app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | out="../target/$TARGET/release/bundle/macos" npm run tauri -- signer sign "$out/LinkUnbound.app.tar.gz" @@ -476,6 +647,7 @@ jobs: with: name: bundle-macos-${{ matrix.arch }} path: dist/* + retention-days: 7 if-no-files-found: error - name: Cleanup keychain @@ -506,10 +678,10 @@ jobs: echo "go=yes" >> "$GITHUB_OUTPUT" fi - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 if: steps.gate.outputs.go == 'yes' - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 if: steps.gate.outputs.go == 'yes' with: name: stage-windows @@ -544,12 +716,20 @@ jobs: shell: pwsh run: | $sdk = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Directory | - Where-Object { Test-Path "$($_.FullName)\x64\makeappx.exe" } | + Where-Object { + (Test-Path "$($_.FullName)\x64\makeappx.exe") -and (Test-Path "$($_.FullName)\x64\makepri.exe") + } | Sort-Object Name -Descending | Select-Object -First 1 - if (-not $sdk) { Write-Host "::error::no Windows SDK with makeappx"; exit 1 } + if (-not $sdk) { Write-Host "::error::no Windows SDK with makeappx and makepri"; exit 1 } $v = "${{ needs.version.outputs.version }}" New-Item -ItemType Directory -Force dist | Out-Null + + & "$($sdk.FullName)\x64\makepri.exe" createconfig /cf priconfig.xml /dq en-US_es-ES /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makepri would not write a config"; exit 1 } + & "$($sdk.FullName)\x64\makepri.exe" new /pr msix /cf priconfig.xml /of msix/resources.pri /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makepri would not index the package"; exit 1 } + & "$($sdk.FullName)\x64\makeappx.exe" pack /d msix /p "dist/linkunbound-$v-windows-x64.msix" /o if ($LASTEXITCODE -ne 0) { Write-Host "::error::makeappx refused the package"; exit 1 } Get-ChildItem dist @@ -587,25 +767,34 @@ jobs: with: name: bundle-msix path: dist/* + retention-days: 7 if-no-files-found: error publish: name: GitHub Release - needs: [rust, version, bundle-windows, bundle-macos, bundle-msix] + needs: [rust, tested, version, bundle-windows, bundle-macos, bundle-msix] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - && needs.rust.result == 'success' && needs.version.result == 'success' + && needs.rust.result == 'success' && needs.tested.result == 'success' + && needs.version.result == 'success' && needs.bundle-windows.result == 'success' && (needs.bundle-macos.result == 'success' || needs.bundle-macos.result == 'skipped') && (needs.bundle-msix.result == 'success' || needs.bundle-msix.result == 'skipped') runs-on: ubuntu-latest timeout-minutes: 60 + concurrency: + group: release-publish-${{ github.ref }} + cancel-in-progress: false + permissions: + contents: write + id-token: write + attestations: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: pattern: bundle-* path: dist @@ -644,7 +833,12 @@ jobs: - name: The signature answers to the key this build ships run: | - sudo apt-get update -qq && sudo apt-get install -y -qq minisign + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign v="${{ needs.version.outputs.version }}" jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub @@ -664,18 +858,11 @@ jobs: echo "the signatures and the shipped key are halves of the same pair" - name: Checksums - run: | - cd dist - shopt -s nullglob - for one in linkunbound-*; do - case "$one" in *.sha256) continue ;; esac - sha256sum "$one" > "$one.sha256" - done - ls -l + run: cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS - - uses: actions/attest-build-provenance@v4 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: - subject-path: dist/linkunbound-* + subject-path: dist/linkunbound-*,dist/SHA256SUMS - name: Notes from the tag id: notes @@ -683,16 +870,21 @@ jobs: body=$(git tag -l --format='%(contents:subject)%0a%0a%(contents:body)' "${GITHUB_REF_NAME}" \ | sed '/-----BEGIN SSH SIGNATURE-----/,$d' \ | sed -e :a -e '/^\n*$/{$d;N;ba}') + edge="LU_EOF_$(openssl rand -hex 8)" { - echo "body<> "$GITHUB_OUTPUT" - name: The feed does not walk backwards run: | v="${{ needs.version.outputs.version }}" if ! git fetch origin manifest --depth 1 2>/dev/null; then + if git ls-remote --exit-code --heads origin manifest >/dev/null 2>&1; then + echo "::error::the feed exists and could not be read, so it cannot be checked" + exit 1 + fi echo "no feed yet" exit 0 fi @@ -758,11 +950,17 @@ jobs: have=$(gh release view "$tag" --json assets -q '.assets[].name' | sort) want=$(ls dist | sort) - if [ "$have" != "$want" ]; then - echo "::error::the release carries a different set of files than dist; it stays a draft" - diff <(echo "$want") <(echo "$have") || true + missing=$(comm -23 <(echo "$want") <(echo "$have")) + if [ -n "$missing" ]; then + echo "::error::the release is missing files this run built; it stays a draft" + echo "$missing" exit 1 fi + spare=$(comm -13 <(echo "$want") <(echo "$have")) + if [ -n "$spare" ]; then + echo "::warning::the release carries files this run did not build:" + echo "$spare" + fi latest=false; [ "$PRERELEASE" = "false" ] && latest=true gh release edit "$tag" --draft=false --latest="$latest" @@ -779,6 +977,11 @@ jobs: ws=$(cat "dist/$win.sig") as=$(cat "dist/$arm.sig" 2>/dev/null || true) is=$(cat "dist/$intel.sig" 2>/dev/null || true) + if [ "${{ needs.version.outputs.macos }}" = "true" ] \ + && { [ -z "$as" ] || [ -z "$is" ]; }; then + echo "::error::no updater signature for a Mac, so no Mac would be offered $v" + exit 1 + fi jq -n --arg v "$v" \ --arg n "https://github.com/${{ github.repository }}/releases/tag/${GITHUB_REF_NAME}" \ --arg wu "$base/$win" --arg ws "$ws" \ @@ -818,15 +1021,19 @@ jobs: fi latest=$(jq -r '.latest // ""' release-manifest.json 2>/dev/null || echo "") + ahead=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then jq -n --arg l "${latest:-0.0.0}" --arg c "$v" \ '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json cp "$RUNNER_TEMP/channel.json" candidate.json else - ahead=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") + keep="" if [ -n "$ahead" ] \ - && [ "$(printf '%s\n%s\n' "$ahead" "$v" | sort -V | tail -1)" = "$ahead" ]; then - jq -n --arg l "$v" --arg c "$ahead" \ + && [ "$(printf '%s\n%s\n' "${ahead%%-*}" "$v" | sort -V | tail -1)" != "$v" ]; then + keep="$ahead" + fi + if [ -n "$keep" ]; then + jq -n --arg l "$v" --arg c "$keep" \ '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json else jq -n --arg l "$v" '{schema: 1, latest: $l}' > release-manifest.json @@ -851,22 +1058,116 @@ jobs: exit 0 fi echo "the feed would not push (${attempt}/5)" - git fetch origin manifest && git rebase origin/manifest || true + git fetch origin manifest || true + git rebase FETCH_HEAD || git rebase --abort || true sleep $((attempt * 5)) done echo "::error::the feed could not be pushed; the release is out and nothing announces it" exit 1 + verify: + name: The update is installable + needs: [version, publish] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Take the update the way an installed copy would + run: | + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign + v="${{ needs.version.outputs.version }}" + file=latest.json + if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then file=candidate.json; fi + feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest/$file" + + said="" + for attempt in 1 2 3 4 5 6; do + if curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed"; then + said=$(jq -r .version channel.json) + [ "$said" = "$v" ] && break + fi + echo "the channel still says «${said:-nothing}» (${attempt}/6)" + sleep $((attempt * 20)) + done + if [ -s channel.json ]; then jq . channel.json; fi + + ahead=$(jq -r .version channel.json 2>/dev/null || echo "") + if [ ! -s channel.json ] && [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then + ahead=$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" \ + "https://raw.githubusercontent.com/${{ github.repository }}/manifest/latest.json" \ + | jq -r .version 2>/dev/null || echo "") + fi + + overtaken=no + if printf "%s" "$ahead" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$"; then + mine=${v%%-*} + theirs=${ahead%%-*} + if [ "$theirs" != "$mine" ]; then + if [ "$(printf "%s\n%s\n" "$mine" "$theirs" | sort -V | tail -1)" = "$theirs" ]; then + overtaken=yes + fi + elif [ "$v" != "$mine" ] && [ "$ahead" = "$theirs" ]; then + overtaken=yes + elif [ "$v" != "$mine" ] && [ "$ahead" != "$theirs" ] \ + && [ "$(printf "%s\n%s\n" "$v" "$ahead" | sort -V | tail -1)" = "$ahead" ]; then + overtaken=yes + fi + fi + + if [ "$said" != "$v" ] && [ "$overtaken" = "yes" ]; then + echo "::warning::the feed already carries $ahead, so $v was overtaken before it could be taken here. Its signatures were not checked by this run." + exit 0 + fi + + if [ "$said" != "$v" ]; then + echo "::error::$file says «${said:-nothing}», this release is $v" + exit 1 + fi + + jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub + + for os in $(jq -r '.platforms | keys[]' channel.json); do + url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) + jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig + + case "$url" in + "https://github.com/${{ github.repository }}/releases/download/v$v/"*) ;; + *) echo "::error::$os points at $url, which is not the v$v release"; exit 1 ;; + esac + + curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 -o one.bin "$url" + if minisign -Vm one.bin -p feed.pub -x one.sig; then + echo "$os verifies" + else + echo "::error::$os does not verify against the key this build ships" + exit 1 + fi + done + msstore: name: Microsoft Store - needs: [version, publish, bundle-msix] + needs: [version, publish, verify, bundle-msix] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.bundle-msix.result == 'success' && needs.version.outputs.prerelease == 'false' runs-on: windows-latest timeout-minutes: 20 + concurrency: + group: release-msstore-${{ github.ref }} + cancel-in-progress: false env: STORE_PUBLISH: ${{ vars.STORE_PUBLISH }} STORE_APP_ID: ${{ vars.STORE_APP_ID }} @@ -885,20 +1186,21 @@ jobs: elif [ "${{ needs.bundle-msix.outputs.made }}" != "yes" ]; then echo "::notice::no MSIX was built this run; nothing to submit" echo "go=no" >> "$GITHUB_OUTPUT" - elif [ -z "$STORE_APP_ID" ] || [ -z "$STORE_TENANT_ID" ] || [ -z "$STORE_SELLER_ID" ] || [ -z "$STORE_CLIENT_ID" ] || [ -z "$STORE_CLIENT_SECRET" ]; then + elif [ -z "$STORE_APP_ID" ] || [ -z "$STORE_TENANT_ID" ] || [ -z "$STORE_SELLER_ID" ] \ + || [ -z "$STORE_CLIENT_ID" ] || [ -z "$STORE_CLIENT_SECRET" ]; then echo "::notice::the Store credentials are not all here; nothing to submit" echo "go=no" >> "$GITHUB_OUTPUT" else echo "go=yes" >> "$GITHUB_OUTPUT" fi - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 if: steps.gate.outputs.go == 'yes' with: name: bundle-msix path: dist - - uses: microsoft/microsoft-store-apppublisher@v1.3 + - uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 if: steps.gate.outputs.go == 'yes' with: version: v0.4.2 @@ -942,69 +1244,19 @@ jobs: echo "::error::the package reached the Store but no commit was confirmed. Read the status \ above before tagging again: a submission already committed must not be sent twice." - verify: - name: The update is installable - needs: [version, publish] - if: >- - !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - && needs.version.result == 'success' && needs.publish.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - - - name: Take the update the way an installed copy would - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq minisign - v="${{ needs.version.outputs.version }}" - file=latest.json - if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then file=candidate.json; fi - feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest/$file" - - said="" - for attempt in 1 2 3 4 5 6; do - if curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed"; then - said=$(jq -r .version channel.json) - [ "$said" = "$v" ] && break - fi - echo "the channel still says «${said:-nothing}» (${attempt}/6)" - sleep $((attempt * 20)) - done - if [ "$said" != "$v" ]; then - echo "::error::$file says «${said:-nothing}», this release is $v" - exit 1 - fi - jq . channel.json - - jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub - - for os in $(jq -r '.platforms | keys[]' channel.json); do - url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) - jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig - - case "$url" in - "https://github.com/${{ github.repository }}/releases/download/v$v/"*) ;; - *) echo "::error::$os points at $url, which is not the v$v release"; exit 1 ;; - esac - - curl -fsSL -o one.bin "$url" - if minisign -Vm one.bin -p feed.pub -x one.sig; then - echo "$os verifies" - else - echo "::error::$os does not verify against the key this build ships" - exit 1 - fi - done - homebrew: name: Homebrew tap - needs: [version, publish] + needs: [version, publish, verify] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.version.outputs.macos == 'true' runs-on: ubuntu-latest timeout-minutes: 10 + concurrency: + group: release-homebrew-${{ github.ref }} + cancel-in-progress: false env: GIST_TOKEN: ${{ secrets.GIST_TOKEN }} steps: @@ -1036,17 +1288,30 @@ jobs: echo "::error::could not download $1" return 1 } - fetch "$DMG_ARM" - fetch "$DMG_INTEL" + if ! fetch "$DMG_ARM" || ! fetch "$DMG_INTEL"; then + echo "::warning::a disk image is missing for ${VERSION}; leaving the cask as it is" + exit 0 + fi ARM_SHA=$(sha256sum "/tmp/$DMG_ARM" | awk '{print $1}') INTEL_SHA=$(sha256sum "/tmp/$DMG_INTEL" | awk '{print $1}') - git clone "https://x-access-token:${GIST_TOKEN}@github.com/rgdevment/homebrew-tap.git" /tmp/tap + git clone https://github.com/rgdevment/homebrew-tap.git /tmp/tap + git -C /tmp/tap config --local http.extraheader \ + "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GIST_TOKEN" | base64 -w0)" cd /tmp/tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" mkdir -p Casks + if [ -f "Casks/${CASK}.rb" ]; then + had=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' "Casks/${CASK}.rb" | head -1) + if [ -n "$had" ] && [ "$had" != "$VERSION" ] \ + && [ "$(printf '%s\n%s\n' "$had" "$VERSION" | sort -V | tail -1)" != "$VERSION" ]; then + echo "::warning::the tap already offers ${had}; writing ${VERSION} would downgrade every Mac that runs brew upgrade" + exit 0 + fi + fi + cat > "Casks/${CASK}.rb" <<-CASK cask "${CASK}" do arch arm: "aarch64", intel: "x86_64" @@ -1084,6 +1349,17 @@ jobs: end CASK + if [ "${{ needs.version.outputs.prerelease }}" = "false" ] \ + && [ -f "Casks/${CASK_OTHER}.rb" ]; then + beta=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' "Casks/${CASK_OTHER}.rb" | head -1) + if [ -n "$beta" ] \ + && [ "$(printf '%s\n%s\n' "${beta%%-*}" "$VERSION" | sort -V | tail -1)" \ + = "$VERSION" ]; then + git rm -q "Casks/${CASK_OTHER}.rb" + echo "retired Casks/${CASK_OTHER}.rb: it still offered $beta, and $VERSION passes it" + fi + fi + git add Casks if git diff --cached --quiet; then echo "tap already at ${VERSION}" @@ -1097,7 +1373,54 @@ jobs: fi echo "push rejected (attempt ${attempt}/5); rebasing onto the concurrent release" git fetch origin main - git rebase origin/main + if ! git rebase origin/main; then + git rebase --abort || true + echo "::error::the tap moved under this commit and it will not rebase cleanly" + exit 1 + fi done echo "::error::could not push the tap after 5 attempts" exit 1 + + winget: + name: Windows Package Manager + needs: [version, publish, verify] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' + && needs.version.outputs.prerelease == 'false' + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: release-winget-${{ github.ref }} + cancel-in-progress: false + env: + WINGET_PUBLISH: ${{ vars.WINGET_PUBLISH }} + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + steps: + - name: Is there anything to add a version to + id: gate + shell: bash + run: | + if [ "$WINGET_PUBLISH" != "true" ]; then + echo "::notice::WINGET_PUBLISH is not true; winget-pkgs gets nothing until it is" + echo "go=no" >> "$GITHUB_OUTPUT" + elif [ -z "$WINGET_TOKEN" ]; then + echo "::notice::no winget token; skipping" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi + + - name: Open the pull request that adds this version + if: steps.gate.outputs.go == 'yes' + uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: rgdevment.LinkUnbound + version: ${{ needs.version.outputs.version }} + release-tag: ${{ github.ref_name }} + installers-regex: 'linkunbound-installer-.*-windows-x86_64\.exe$' + max-versions-to-keep: 5 + fork-user: rgdevment-bot + token: ${{ secrets.WINGET_TOKEN }} diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 718a216..516c56b 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -4,16 +4,31 @@ on: push: branches: [main] pull_request: + schedule: + - cron: "41 5 * * *" + workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: conventions: name: Project conventions runs-on: ubuntu-latest + timeout-minutes: 120 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci - name: The workflows parse run: | @@ -21,7 +36,7 @@ jobs: # diagnosis — and release only ever runs once the tag is already pushed. status=0 for one in .github/workflows/*.yml .github/actions/*/action.yml; do - if ! npx --yes js-yaml "$one" >/dev/null; then + if ! npx --no-install js-yaml "$one" >/dev/null; then echo "::error file=$one::this file is not valid YAML" status=1 fi @@ -109,11 +124,18 @@ jobs: exit 1 fi - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Tests are deterministic run: | - for i in $(seq 1 20); do + turns=1 + if [ "${GITHUB_EVENT_NAME}" = "schedule" ] || [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + turns=20 + fi + for i in $(seq 1 "$turns"); do cargo test -p linkunbound-core --quiet || exit 1 done diff --git a/.github/workflows/update-badge.yml b/.github/workflows/update-badge.yml index 3b4394b..a422c4e 100644 --- a/.github/workflows/update-badge.yml +++ b/.github/workflows/update-badge.yml @@ -1,29 +1,38 @@ name: Update Downloads Badge on: - schedule: - - cron: "0 */12 * * *" - workflow_dispatch: + schedule: + - cron: "0 */12 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: downloads-badge + cancel-in-progress: false jobs: - update-badge: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 + update-badge: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false - - name: Setup uv - uses: astral-sh/setup-uv@v9.0.0 - with: - enable-cache: false + - name: Setup uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: false - - name: Update downloads badge - env: - GITHUB_TOKEN: ${{ github.token }} - GIST_ID: ${{ vars.GIST_ID }} - GIST_TOKEN: ${{ secrets.GIST_TOKEN }} - STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }} - STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }} - STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }} - STORE_APP_ID: ${{ vars.STORE_APP_ID }} - run: uv run resources/scripts/update_badge.py + - name: Update downloads badge + env: + GITHUB_TOKEN: ${{ github.token }} + GIST_ID: ${{ vars.GIST_ID }} + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }} + STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }} + STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }} + STORE_APP_ID: ${{ vars.STORE_APP_ID }} + run: uv run resources/scripts/update_badge.py diff --git a/PRIVACY.md b/PRIVACY.md index 1cba21b..3f1640d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -98,6 +98,7 @@ All data is stored locally under your user profile. | :------- | :------------------------------------------ | | Browsers | `%LOCALAPPDATA%\LinkUnbound\browsers.json` | | Rules | `%LOCALAPPDATA%\LinkUnbound\rules.json` | +| Settings | `%LOCALAPPDATA%\LinkUnbound\preferences.json` | | Log | `%LOCALAPPDATA%\LinkUnbound\navigate.log` | | Crash log | `%LOCALAPPDATA%\LinkUnbound\startup_crash.log` | | Icons | `%LOCALAPPDATA%\LinkUnbound\icons\` | @@ -108,6 +109,7 @@ All data is stored locally under your user profile. | :------- | :--------------------------------------------------------------- | | Browsers | `~/Library/Application Support/LinkUnbound/browsers.json` | | Rules | `~/Library/Application Support/LinkUnbound/rules.json` | +| Settings | `~/Library/Application Support/LinkUnbound/preferences.json` | | Log | `~/Library/Application Support/LinkUnbound/navigate.log` | | Crash log | `~/Library/Application Support/LinkUnbound/startup_crash.log` | | Icons | `~/Library/Application Support/LinkUnbound/icons/` | @@ -115,6 +117,8 @@ All data is stored locally under your user profile. On macOS, setting LinkUnbound as the default browser records which application held that role before (its bundle identifier, nothing else), so that "stop being the default" can hand the links back to it. +`preferences.json` holds your settings — theme, language, shortcut — and two marks that exist only so the app can ask you for a GitHub star once, at a sensible moment rather than on day one: the date you first opened the settings window, and whether you have already answered. They are read on that screen and nowhere else, they never leave your machine, and deleting the file resets them. + These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. --- diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d8619e4..8b902d9 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -7,8 +7,8 @@ mod update; use std::sync::Mutex; use linkunbound_core::{ - Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, merge, normalise, - site_of, + Asking, Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, merge, + normalise, site_of, }; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; @@ -597,9 +597,17 @@ fn spoken(prefs: &Preferences) -> &'static str { #[tauri::command] fn prefs_set(app: AppHandle, prefs: Preferences) -> Result { - store().save_prefs(&prefs).map_err(|e| e.to_string())?; - shell::repaint(&app, prefs.theme); - Ok(claim(&app, &prefs)) + let mut settled = prefs; + store() + .edit_prefs(|kept| { + settled.here_since = kept.here_since; + settled.asked_for_a_star = kept.asked_for_a_star; + *kept = settled.clone(); + true + }) + .map_err(|e| e.to_string())?; + shell::repaint(&app, settled.theme); + Ok(claim(&app, &settled)) } #[tauri::command] @@ -718,6 +726,7 @@ struct Build { repository: &'static str, candidates: bool, candidates_apply: bool, + kept_by_the_store: bool, } #[tauri::command] @@ -728,15 +737,61 @@ fn settings_painted(window: tauri::WebviewWindow) { #[tauri::command] fn about() -> Build { let kept = update::looked(store().dir()); + let store_copy = update::route().route == update::Route::Store; Build { version: HERE, license: "GPL-3.0-only", repository: "https://github.com/rgdevment/LinkUnbound", candidates: update::tracking(HERE, kept.candidates), - candidates_apply: update::route().route != update::Route::Store, + candidates_apply: !store_copy, + kept_by_the_store: store_copy, } } +fn now_in_seconds() -> Option { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|since| since.as_secs()) +} + +#[tauri::command] +fn star_due() -> Result { + let Some(now) = now_in_seconds() else { + return Ok(false); + }; + let store = store(); + let mut due = false; + store + .edit_prefs(|prefs| { + let counted = || store.rules().map(|set| set.rules.len()).unwrap_or_default(); + match linkunbound_core::asking(prefs.asked_for_a_star, prefs.here_since, now, counted) { + Asking::Start => { + prefs.here_since = Some(now); + true + } + Asking::Wait => false, + Asking::Now => { + due = true; + false + } + } + }) + .map_err(|e| e.to_string())?; + Ok(due) +} + +#[tauri::command] +fn star_done() -> Result<(), String> { + store() + .edit_prefs(|prefs| { + prefs.asked_for_a_star = true; + true + }) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + #[cfg(windows)] fn owner(app: &AppHandle) -> Option { app.get_webview_window("settings") @@ -1236,7 +1291,9 @@ pub fn run() { settings_painted, update_ready, update_install, - update_candidates + update_candidates, + star_due, + star_done ]) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index f17ac55..62ece56 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -312,6 +312,7 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { const [state, setState] = useState(null); const [here, setHere] = useState(null); const [problem, setProblem] = useState(null); + const [starring, setStarring] = useState(false); // Choosing the default browser happens in Windows, not here, so the answer to «does Windows // send links our way» changes while this window is in the background. Asked again every time @@ -331,6 +332,10 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { .catch(noop); }, []); + useEffect(() => { + void invoke("star_due").then(setStarring).catch(noop); + }, []); + const change = useCallback( (command: string, enabled: boolean) => { setProblem(null); @@ -423,7 +428,16 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { /> )} {page === "care" && } - {page === "about" && } + {page === "about" && ( + setStarring(false)} + onSettled={settled} + onLook={look} + /> + )} ); diff --git a/app/src/i18n.ts b/app/src/i18n.ts index bf3458f..6b06148 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -222,7 +222,18 @@ const ES = { aboutPrivacy: "Sin cuenta, sin publicidad, sin telemetría, sin servidor. Los enlaces que abres no salen de este equipo.", aboutSupportWhy: - "LinkUnbound es gratuito y lo seguirá siendo. Si te ahorra un fastidio al día, un café mantiene el trabajo en marcha.", + "LinkUnbound es gratuito y lo seguirá siendo. Si te ahorra un fastidio al día, una estrella ayuda a que lo encuentren y un café mantiene el trabajo en marcha.", + aboutStar: "Dar una estrella en GitHub", + aboutRate: "Valorar en la Store", + badgeLocal: "Tus datos, tuyos", + badgeOpen: "Código abierto", + badgeFree: "Gratis", + badgeQuiet: "Sin telemetría", + starThanks: "Gracias por usar LinkUnbound.", + starWhy: "Sin telemetría ni anuncios: la gente solo lo encuentra si alguien lo recomienda.", + starGo: "Dar una estrella", + starNo: "No mostrar más", + starLater: "Ahora no", aboutGitHubSponsor: "Patrocinar en GitHub", aboutSponsorSection: "Apoyar a LinkUnbound", aboutRepo: "Abrir el repositorio", @@ -472,7 +483,18 @@ const EN: Record = { aboutPrivacy: "No account, no ads, no telemetry, no server. The links you open never leave this machine.", aboutSupportWhy: - "LinkUnbound is free and will stay free. If it saves you an annoyance a day, a coffee keeps the work going.", + "LinkUnbound is free and will stay free. If it saves you an annoyance a day, a star helps it get found — and a coffee keeps the work going.", + aboutStar: "Star on GitHub", + aboutRate: "Rate it in the Store", + badgeLocal: "Your data, yours", + badgeOpen: "Open source", + badgeFree: "Free", + badgeQuiet: "No telemetry", + starThanks: "Thank you for using LinkUnbound.", + starWhy: "No telemetry, no ads: people only find it when somebody recommends it.", + starGo: "Give it a star", + starNo: "Don't show again", + starLater: "Not now", aboutGitHubSponsor: "Sponsor on GitHub", aboutSponsorSection: "Support LinkUnbound", aboutRepo: "Open the repository", diff --git a/app/src/settings/About.test.tsx b/app/src/settings/About.test.tsx index 813067b..3084d71 100644 --- a/app/src/settings/About.test.tsx +++ b/app/src/settings/About.test.tsx @@ -22,6 +22,7 @@ const BUILD = { repository: "https://github.com/rgdevment/LinkUnbound", candidates: false, candidatesApply: true, + keptByTheStore: false, }; const NEWER: Ready = { @@ -38,12 +39,23 @@ const settled = vi.fn(); /// Stands in for the window that owns the offer, so a look really does move what the screen /// reads instead of the test asserting against a copy only it can see. -function Host({ from, step }: { from: Ready | null; step: Underway | null }) { +function Host({ + from, + step, + starring = false, +}: { + from: Ready | null; + step: Underway | null; + starring?: boolean; +}) { const [ready, setReady] = useState(from); + const [asking, setAsking] = useState(starring); return ( setAsking(false)} onSettled={settled} onLook={(nowPlease) => invoke("update_ready", { nowPlease }).then((one: unknown) => { @@ -59,6 +71,10 @@ function show(ready: Ready | null = null, step: Underway | null = null) { render(); } +function showAsking() { + render(); +} + function answers(overrides: Record Promise> = {}) { invoke.mockImplementation((cmd: string) => { if (cmd in overrides) return overrides[cmd](); @@ -117,6 +133,35 @@ describe("about", () => { ]); }); + it("says what the copy is before asking anything of the reader", async () => { + show(); + expect(await screen.findByText("Sin telemetría")).toBeInTheDocument(); + expect(screen.getByText("Código abierto")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Dar una estrella en GitHub/ })).toBeInTheDocument(); + }); + + it("asks for a rating only from a copy the store keeps", async () => { + show(); + expect(await screen.findByText("2.0.0")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Valorar en la Store/ })).toBeNull(); + cleanup(); + + answers({ about: () => Promise.resolve({ ...BUILD, keptByTheStore: true }) }); + show(); + await userEvent.click(await screen.findByRole("button", { name: /Valorar en la Store/ })); + expect(opened).toEqual(["ms-windows-store://review/?ProductId=9N9F7C8Q43KC"]); + }); + + it("asks for the star in the card instead of twice on the same screen", async () => { + showAsking(); + expect(await screen.findByRole("status")).toHaveTextContent("Gracias por usar LinkUnbound"); + expect(screen.queryByRole("button", { name: /Dar una estrella en GitHub/ })).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Ahora no" })); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByRole("button", { name: /Dar una estrella en GitHub/ })).toBeInTheDocument(); + }); + it("says the copy is current when nothing newer was found", async () => { show(); expect(screen.getByText("Estás en la última versión")).toBeInTheDocument(); diff --git a/app/src/settings/About.tsx b/app/src/settings/About.tsx index ffde546..d778a4c 100644 --- a/app/src/settings/About.tsx +++ b/app/src/settings/About.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { type Key, useSpoken, useWords } from "../i18n"; import { offerMoved, saidPlainly } from "../refusal"; import { Card, Line, Switch } from "./parts"; +import Star from "./Star"; import type { Ready, Underway } from "./update"; const REPO = "https://github.com/rgdevment/LinkUnbound"; @@ -11,6 +12,7 @@ const COFFEE = "https://buymeacoffee.com/rgdevment"; const COPYPASTE = "https://github.com/rgdevment/CopyPaste"; const TISTY = "https://github.com/rgdevment/Tisty"; const SPONSOR = "https://github.com/sponsors/rgdevment"; +const RATING = "ms-windows-store://review/?ProductId=9N9F7C8Q43KC"; type Build = { version: string; @@ -18,6 +20,7 @@ type Build = { repository: string; candidates: boolean; candidatesApply: boolean; + keptByTheStore: boolean; }; /// What happens on «Actualizar» — or, when there is no button, what the person has to do instead: @@ -49,14 +52,24 @@ function Rule({ said }: { said: string }) { ); } +function Badge({ said }: { said: string }) { + return ( + + {said} + + ); +} + function Gives({ said, where, + wide, onPick, children, }: { said: string; where: string; + wide?: boolean; onPick: () => void; children: React.ReactNode; }) { @@ -64,7 +77,9 @@ function Gives({ + + + + + + ); +} + +function noop() {} diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs index 1295ceb..064c9bc 100644 --- a/crates/linkunbound-core/src/lib.rs +++ b/crates/linkunbound-core/src/lib.rs @@ -11,6 +11,7 @@ mod prefs; mod private; mod report; mod rule; +mod star; mod store; pub mod update; mod url; @@ -33,6 +34,7 @@ pub use prefs::{Locale, PickerStyle, Preferences, Theme}; pub use private::private_flag_for; pub use report::{diagnostics, redact}; pub use rule::{Rule, RuleSet, Scope, Target, site_of}; +pub use star::{Asking, asking}; pub use store::{Store, StoreError, data_dir, unmarked}; pub use url::{ host_of, is_launchable, local_file_parts, local_web_file, local_web_file_extensions, diff --git a/crates/linkunbound-core/src/prefs.rs b/crates/linkunbound-core/src/prefs.rs index 29f34ae..c983555 100644 --- a/crates/linkunbound-core/src/prefs.rs +++ b/crates/linkunbound-core/src/prefs.rs @@ -50,6 +50,10 @@ pub struct Preferences { /// settings window ever asks, and only while it is open. #[serde(default = "yes")] pub looks_for_updates: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub here_since: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub asked_for_a_star: bool, } fn default_shortcut() -> Option { @@ -71,6 +75,8 @@ impl Default for Preferences { edge_warning_dismissed: false, picker_style: PickerStyle::default(), looks_for_updates: true, + here_since: None, + asked_for_a_star: false, } } } diff --git a/crates/linkunbound-core/src/star.rs b/crates/linkunbound-core/src/star.rs new file mode 100644 index 0000000..bf0e5af --- /dev/null +++ b/crates/linkunbound-core/src/star.rs @@ -0,0 +1,88 @@ +pub const SETTLED_IN: u64 = 60 * 60 * 24 * 14; +pub const RULES_ENOUGH: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Asking { + Start, + Wait, + Now, +} + +#[must_use] +pub fn asking(asked: bool, since: Option, now: u64, rules: impl FnOnce() -> usize) -> Asking { + if asked { + return Asking::Wait; + } + let Some(since) = since.filter(|at| *at <= now) else { + return Asking::Start; + }; + if now - since < SETTLED_IN { + return Asking::Wait; + } + if rules() < RULES_ENOUGH { + return Asking::Wait; + } + Asking::Now +} + +#[cfg(test)] +mod tests { + use super::{Asking, RULES_ENOUGH, SETTLED_IN, asking}; + + const NOW: u64 = 1_800_000_000; + + #[test] + fn a_copy_that_was_asked_once_is_never_asked_again() { + assert_eq!(asking(true, Some(0), NOW, || 1000), Asking::Wait); + } + + #[test] + fn a_copy_with_no_mark_lays_one_and_says_nothing_yet() { + assert_eq!(asking(false, None, NOW, || 1000), Asking::Start); + } + + #[test] + fn a_mark_from_a_clock_that_was_ahead_is_laid_again_rather_than_waited_on_for_ever() { + assert_eq!(asking(false, Some(NOW + 1), NOW, || 1000), Asking::Start); + } + + #[test] + fn a_fortnight_is_the_floor_and_the_second_before_it_is_not() { + assert_eq!( + asking(false, Some(NOW - SETTLED_IN + 1), NOW, || 1000), + Asking::Wait + ); + assert_eq!( + asking(false, Some(NOW - SETTLED_IN), NOW, || 1000), + Asking::Now + ); + } + + #[test] + fn somebody_who_taught_it_nothing_is_not_asked_for_anything() { + let long_ago = Some(NOW - SETTLED_IN * 2); + assert_eq!( + asking(false, long_ago, NOW, || RULES_ENOUGH - 1), + Asking::Wait + ); + assert_eq!(asking(false, long_ago, NOW, || RULES_ENOUGH), Asking::Now); + } + + #[test] + fn the_rules_are_not_read_when_the_answer_is_known_without_them() { + let counted = std::cell::Cell::new(0); + let count = || { + counted.set(counted.get() + 1); + 1000 + }; + assert_eq!(asking(true, None, NOW, count), Asking::Wait); + assert_eq!(counted.get(), 0); + + let count = || { + counted.set(counted.get() + 1); + 1000 + }; + assert_eq!(asking(false, Some(NOW), NOW, count), Asking::Wait); + assert_eq!(counted.get(), 0); + } +} diff --git a/crates/linkunbound-core/src/store.rs b/crates/linkunbound-core/src/store.rs index acb79eb..8b1d9ae 100644 --- a/crates/linkunbound-core/src/store.rs +++ b/crates/linkunbound-core/src/store.rs @@ -286,8 +286,29 @@ impl Store { path: self.prefs_path(), source: crate::ConfigError::Malformed(source), })?; - keep_the_unread(&self.prefs_path()); - save_atomically(&self.prefs_path(), &body) + guarded(&self.prefs_path(), || { + keep_the_unread(&self.prefs_path()); + save_atomically(&self.prefs_path(), &body) + }) + } + + pub fn edit_prefs( + &self, + edit: impl FnOnce(&mut crate::Preferences) -> bool, + ) -> Result { + guarded(&self.prefs_path(), || { + let mut prefs = self.prefs(); + if !edit(&mut prefs) { + return Ok(false); + } + let body = + serde_json::to_string_pretty(&prefs).map_err(|source| StoreError::Content { + path: self.prefs_path(), + source: crate::ConfigError::Malformed(source), + })?; + keep_the_unread(&self.prefs_path()); + save_atomically(&self.prefs_path(), &body).map(|()| true) + }) } pub fn save_browsers(&self, config: &BrowserConfig) -> Result<(), StoreError> { @@ -307,6 +328,56 @@ mod tests { use super::*; use crate::config::SCHEMA_VERSION; + #[test] + fn a_field_the_window_never_sends_back_survives_a_save() { + let dir = std::env::temp_dir().join("lu-prefs-kept"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a place to write"); + let store = Store::at(&dir); + + store + .edit_prefs(|prefs| { + prefs.here_since = Some(1_700_000_000); + prefs.asked_for_a_star = true; + true + }) + .expect("marked"); + + let arriving = crate::Preferences { + locale: crate::Locale::English, + ..Default::default() + }; + let mut settled = arriving; + store + .edit_prefs(|kept| { + settled.here_since = kept.here_since; + settled.asked_for_a_star = kept.asked_for_a_star; + *kept = settled.clone(); + true + }) + .expect("saved"); + + let read = Store::at(&dir).prefs(); + assert_eq!(read.locale, crate::Locale::English, "the window's change"); + assert_eq!( + read.here_since, + Some(1_700_000_000), + "the mark it never saw" + ); + assert!(read.asked_for_a_star, "and the answer it never saw"); + } + + #[test] + fn a_refused_edit_writes_nothing() { + let dir = std::env::temp_dir().join("lu-prefs-refused"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a place to write"); + let store = Store::at(&dir); + + assert!(!store.edit_prefs(|_| false).expect("asked")); + assert!(!store.prefs_path().exists()); + } + #[test] fn the_files_live_in_a_directory_of_their_own() { let home = under(Some(PathBuf::from("/somewhere"))); diff --git a/package-lock.json b/package-lock.json index 02dd085..928002f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,12 @@ { - "name": "LinkUnbound", + "name": "linkunbound", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "linkunbound", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.20.0" } }, diff --git a/package.json b/package.json index 0a0836a..7802259 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,9 @@ { + "name": "linkunbound", + "private": true, + "repository": "github:rgdevment/LinkUnbound", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.20.0" }, "scripts": { diff --git a/packaging/winget/rgdevment.LinkUnbound.installer.yaml b/packaging/winget/rgdevment.LinkUnbound.installer.yaml new file mode 100644 index 0000000..6b1f9aa --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.installer.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +Platform: + - Windows.Desktop +MinimumOSVersion: 10.0.17763.0 +InstallerType: nullsoft +Scope: user +InstallModes: + - interactive + - silent + - silentWithProgress +UpgradeBehavior: install +RequireExplicitUpgrade: true +ReleaseDate: 2026-09-21 +InstallationMetadata: + DefaultInstallLocation: '%LOCALAPPDATA%\Programs\LinkUnbound' +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/rgdevment/LinkUnbound/releases/download/v2.0.2/linkunbound-installer-2.0.2-windows-x86_64.exe + InstallerSha256: ECBD1F7DC045DF8913FE14674E899109CB0A70AC281FF8C77963EF1BB16D6CFD +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml b/packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml new file mode 100644 index 0000000..54ef75c --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml @@ -0,0 +1,44 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +PackageLocale: en-US +Publisher: rgdevment +PublisherUrl: https://github.com/rgdevment +PublisherSupportUrl: https://github.com/rgdevment/LinkUnbound/issues +PrivacyUrl: https://github.com/rgdevment/LinkUnbound/blob/main/PRIVACY.md +Author: Mario Hidalgo G. +PackageName: LinkUnbound +PackageUrl: https://github.com/rgdevment/LinkUnbound +License: GPL-3.0-only +LicenseUrl: https://github.com/rgdevment/LinkUnbound/blob/main/LICENSE +Copyright: Copyright (c) 2026 rgdevment +CopyrightUrl: https://github.com/rgdevment/LinkUnbound/blob/main/LICENSE +ShortDescription: Choose which browser opens every link. +Description: |- + LinkUnbound puts itself between a link and your browsers, so every link opens where you + want it. It becomes the default handler for http and https, and when a link arrives it + asks which browser — or answers on its own, from a rule you taught it. Rules remember + the choices you make, by site, by profile or by the program the link came from. Work in + one browser and personal in another, without moving a default back and forth. Everything + stays on your machine: no account, no telemetry, no server. +Moniker: linkunbound +Tags: + - browser + - default-browser + - links + - local-first + - offline + - open-source + - privacy + - productivity + - url-handler + - utility +ReleaseNotesUrl: https://github.com/rgdevment/LinkUnbound/releases/tag/v2.0.2 +Documentations: + - DocumentLabel: Privacy + DocumentUrl: https://github.com/rgdevment/LinkUnbound/blob/main/PRIVACY.md + - DocumentLabel: Security + DocumentUrl: https://github.com/rgdevment/LinkUnbound/blob/main/SECURITY.md +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/rgdevment.LinkUnbound.locale.es.yaml b/packaging/winget/rgdevment.LinkUnbound.locale.es.yaml new file mode 100644 index 0000000..208b544 --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.locale.es.yaml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.locale.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +PackageLocale: es +Publisher: rgdevment +PublisherUrl: https://github.com/rgdevment +PublisherSupportUrl: https://github.com/rgdevment/LinkUnbound/issues +PrivacyUrl: https://github.com/rgdevment/LinkUnbound/blob/main/PRIVACY.md +Author: Mario Hidalgo G. +PackageName: LinkUnbound +PackageUrl: https://github.com/rgdevment/LinkUnbound +License: GPL-3.0-only +LicenseUrl: https://github.com/rgdevment/LinkUnbound/blob/main/LICENSE +Copyright: Copyright (c) 2026 rgdevment +ShortDescription: Elige con qué navegador se abre cada enlace. +Description: |- + LinkUnbound se pone entre un enlace y tus navegadores para que cada enlace abra donde tú + quieres. Se registra como la aplicación que atiende http y https, y cuando llega un + enlace pregunta con cuál abrirlo, o responde solo a partir de una regla que le enseñaste. + Las reglas recuerdan tus decisiones por sitio, por perfil o por el programa del que vino + el enlace. Trabaja en un navegador y lo personal en otro, sin cambiar el predeterminado + cada vez. Todo se queda en tu equipo: sin cuenta, sin telemetría, sin servidor. +Tags: + - código-abierto + - enlaces + - navegador + - navegador-predeterminado + - privacidad + - productividad + - sin-conexión + - utilidad +ReleaseNotesUrl: https://github.com/rgdevment/LinkUnbound/releases/tag/v2.0.2 +ManifestType: locale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/rgdevment.LinkUnbound.yaml b/packaging/winget/rgdevment.LinkUnbound.yaml new file mode 100644 index 0000000..2e455bf --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/scripts/generations.py b/scripts/generations.py new file mode 100644 index 0000000..743c16a --- /dev/null +++ b/scripts/generations.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import sys +from datetime import datetime + +MAIN = "refs/heads/main" +TAGS = "refs/heads/refs/tags/" +DRY = os.environ.get("DRY_RUN", "").lower() == "true" +RUST = re.compile(r"^(v0-rust-.+)-[0-9a-f]{8}-[0-9a-f]{8}$") +GONE = "Could not find a cache matching" + + +def gh(*args): + done = subprocess.run(["gh", *args], capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"gh {' '.join(args)} failed: {done.stderr.strip()}") + return done.stdout + + +def listed(ref=None): + args = ["cache", "list", "--limit", "100", "--json", "key,ref,sizeInBytes,createdAt"] + if ref: + args += ["--ref", ref] + return json.loads(gh(*args)) + + +def superseded(): + households = {} + for one in listed(MAIN): + found = RUST.match(one["key"]) + if found: + households.setdefault(found.group(1), []).append({**one, "ref": MAIN}) + stale = [] + for household in households.values(): + household.sort(key=lambda one: datetime.fromisoformat(one["createdAt"]), reverse=True) + stale.extend(household[1:]) + return stale + + +def petrified(): + return [one for one in listed() if one["ref"].startswith(TAGS)] + + +def main(): + stale = superseded() + petrified() + if not stale: + print("nothing superseded and no tag left anything behind") + return + + freed = 0 + failed = [] + for one in stale: + print(f" {one['sizeInBytes'] // 1048576:>5} MB {one['ref']} {one['key']}") + if DRY: + freed += one["sizeInBytes"] + continue + done = subprocess.run( + ["gh", "cache", "delete", one["key"], "--ref", one["ref"]], + capture_output=True, + text=True, + ) + if done.returncode == 0: + freed += one["sizeInBytes"] + elif GONE in done.stderr: + print(f" evicted before we got to it: {one['key']}") + else: + failed.append(f"{one['key']}: {done.stderr.strip()}") + + said = "would sweep" if DRY else "swept" + print(f"::notice::{said} {len(stale) - len(failed)} cache(s), {freed // 1048576} MB") + if failed: + sys.exit("::error::" + "; ".join(failed)) + + +if __name__ == "__main__": + main() diff --git a/scripts/sidecar.sh b/scripts/sidecar.sh index 4c5d18a..4c20f56 100644 --- a/scripts/sidecar.sh +++ b/scripts/sidecar.sh @@ -13,7 +13,7 @@ out="app/src-tauri/binaries" mkdir -p "$out" flags=() -case "$profile" in release) flags+=(--release) ;; esac +case "$profile" in release) flags+=(--release --locked) ;; esac build() { local triple="$1"