diff --git a/.github/actions/package/action.yml b/.github/actions/package/action.yml index 8d8fb35f..4276c170 100644 --- a/.github/actions/package/action.yml +++ b/.github/actions/package/action.yml @@ -43,4 +43,5 @@ runs: with: name: cli-${{ inputs.name }} path: dist/* + if-no-files-found: error retention-days: 5 diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 33029ced..e38613d8 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -16,11 +16,11 @@ 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 diff --git a/.github/workflows/caches.yml b/.github/workflows/caches.yml index 59f2f80c..f54b38d5 100644 --- a/.github/workflows/caches.yml +++ b/.github/workflows/caches.yml @@ -3,6 +3,17 @@ 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 @@ -10,6 +21,7 @@ permissions: 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: @@ -19,3 +31,33 @@ jobs: 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 + # The branch filter on workflow_run reads the branch of the run that triggered it, and a + # fork's pull request can name its branch main. Only a push to this repository counts. + 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 a5814865..1d05dcf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,41 +27,31 @@ jobs: os: [windows-latest, macos-latest] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - 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 - - if: runner.os == 'macOS' - run: cargo clippy --workspace --all-targets - - run: cargo nextest run --workspace --no-tests=pass + # Clippy runs where the cache is written, or the next job pays to rebuild what it needs. + - run: cargo clippy --workspace --all-targets - run: cargo nextest run --workspace --no-tests=pass - env: - LC_ALL: es_ES.UTF-8 - LANG: es_ES.UTF-8 - if: runner.os == 'Windows' run: cargo test --doc --workspace lint: - name: fmt + clippy - runs-on: windows-latest - timeout-minutes: 25 + name: fmt + runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - with: - shared-key: workspace - save-if: false - - uses: ./.github/actions/sidecar + components: rustfmt - run: cargo fmt --all --check - - run: cargo clippy --workspace --all-targets window: name: frontend @@ -111,10 +101,10 @@ jobs: steps: - 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 diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index ad863c3f..b0cb9904 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -34,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 3d68d63d..65d7b37b 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -123,7 +123,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 9e78d717..420fc04a 100644 --- a/.github/workflows/mutants-sweep.yml +++ b/.github/workflows/mutants-sweep.yml @@ -22,8 +22,8 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v7 - - 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: false @@ -57,8 +57,8 @@ jobs: timeout-minutes: 180 steps: - uses: actions/checkout@v7 - - 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: false @@ -96,8 +96,8 @@ jobs: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v7 - - 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: false @@ -135,8 +135,8 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] steps: - uses: actions/checkout@v7 - - 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: false @@ -247,13 +247,14 @@ jobs: - name: The report where a survivor can be read one by one env: KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + BRANCH: ${{ github.ref_name }} run: | if [ -z "$KEY" ]; then echo "::warning::no dashboard key: the report goes no further than the artifact" exit 0 fi curl -sS --fail-with-body -X PUT \ - "https://dashboard.stryker-mutator.io/api/reports/github.com/${{ github.repository }}/${{ github.ref_name }}" \ + "https://dashboard.stryker-mutator.io/api/reports/github.com/$GITHUB_REPOSITORY/$BRANCH" \ -H 'Content-Type: application/json' \ -H "X-Api-Key: $KEY" \ -d @window.json diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index f74f49f5..5fba3c61 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -38,9 +38,9 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3161f259..f0b2437a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,8 @@ concurrency: env: CARGO_TERM_COLOR: always BUILD_MACOS: "true" + PUBLISHER: v1.8.1 + PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc # The command line ships inside the app as the assistant's door; the loose binary and its # formula are built only when a release has a reason to (a fix for whoever installed # only that). Set the repository variable SHIP_CLI to "true" for that tag. @@ -33,7 +35,7 @@ jobs: gate: name: Style and prose runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -45,6 +47,49 @@ jobs: app/package-lock.json - run: npm ci - run: npm run lint:md + # Nothing here runs the suite, and a tag can be put on any commit. The one that reached + # main was already tested there; a tag on anything else has no such claim behind it. + - 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 + 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>/dev/null || echo "") + 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 + + # The registry has rules of its own — a description over 100 characters is the one + # that bites — and finding out after the release is announced is too late. + - name: Would the registry take this listing + run: | + curl -sSL --fail -o publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/$PUBLISHER/mcp-publisher_linux_amd64.tar.gz" + echo "$PUBLISHER_SHA256 publisher.tar.gz" | sha256sum --check --strict + tar xzf publisher.tar.gz mcp-publisher + ./mcp-publisher validate - name: Frontend style working-directory: app run: | @@ -59,10 +104,10 @@ jobs: RUSTFLAGS: -D warnings steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - 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 @@ -83,47 +128,71 @@ jobs: macos: ${{ steps.resolve.outputs.macos }} cli: ${{ steps.resolve.outputs.cli }} env: - 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 }} - 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 }} + 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 "$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 "$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 "$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 [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] && [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then + if [ "$HAS_UPDATER_KEY" = "true" ] && [ "$HAS_UPDATER_PASSWORD" != "true" ]; then fail "TAURI_SIGNING_PRIVATE_KEY is set but its password is empty" fi - if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + if [ "$HAS_UPDATER_KEY" != "true" ]; then echo "::warning::no updater key; this release cannot be installed by an existing copy" fi + + # A dispatch may go unsigned, it publishes nothing. A tag is what people install, and + # an empty secret there ships a build nobody can install and nobody can check. + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + missing="" + want() { [ "$2" = "true" ] || missing="$missing $1"; } + want TAURI_SIGNING_PRIVATE_KEY "$HAS_UPDATER_KEY" + want TAURI_SIGNING_PRIVATE_KEY_PASSWORD "$HAS_UPDATER_PASSWORD" + 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 + [ -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 # Semver's own grammar, not an approximation of it: a version this lets through and @@ -180,8 +249,8 @@ jobs: - name: Build run: | - cargo build --release --bin tisty --target aarch64-apple-darwin - cargo build --release --bin tisty --target x86_64-apple-darwin + cargo build --release --locked --bin tisty --target aarch64-apple-darwin + cargo build --release --locked --bin tisty --target x86_64-apple-darwin mkdir -p "target/$TARGET/release" lipo -create -output "target/$TARGET/release/tisty" \ @@ -242,7 +311,12 @@ jobs: xcrun notarytool submit "$RUNNER_TEMP/tisty.zip" \ --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" \ --team-id "$APPLE_TEAM_ID" --wait --timeout 600 - spctl --assess --type exec -vv "$BIN" || true + # A bare executable cannot be stapled, so a refusal here is expected, not a break. + if spctl --assess --type exec -vv "$BIN" 2>&1; then + echo "Gatekeeper accepts it" + else + echo "::notice::Gatekeeper would not assess the loose binary: notarised but not stapled, which is what an executable that is not an app gets" + fi - name: Cleanup keychain if: always() && env.KEYCHAIN_PATH != '' @@ -274,7 +348,7 @@ jobs: - name: Build shell: bash - run: cargo build --release --bin tisty --target "$TARGET" + run: cargo build --release --locked --bin tisty --target "$TARGET" - name: Sign executable if: env.PFX_BASE64 != '' @@ -356,23 +430,55 @@ jobs: - name: The CLI rides along shell: bash run: | - cargo build --release --bin tisty + cargo build --release --locked --bin tisty mkdir -p app/src-tauri/binaries cp target/release/tisty.exe "app/src-tauri/binaries/tisty-$TARGET.exe" - # A public key in the config makes the private one a build requirement, so a run without the - # secret has to say so rather than fail on a line about an environment variable. + # `tauri build` compiles and packs in one step, so what ends up inside the installer can + # only be signed by the bundler. The certificate goes to the store and the command picks it + # by thumbprint: no password on a command line. Merged in, never in the config file — a + # build without the certificate must not fail on a command it cannot run. + - name: Teach the bundler to sign what it packs + if: env.PFX_BASE64 != '' + shell: pwsh + 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 + Write-Host "the bundler signs with $($cert.Thumbprint)" + + # A public key in the config makes the private one a build requirement, so a run without + # the secret has to say so rather than fail on a line about an environment variable. - name: Bundle working-directory: app shell: bash 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 # The MSIX packs these same bytes; building them a second time over there bought nothing. - name: Set the binaries aside for the MSIX - id: stage shell: bash run: | mkdir -p stage/cli @@ -381,6 +487,74 @@ jobs: if [ -d target/release/resources ]; then cp -R target/release/resources stage/resources fi + + # Microsoft signs the package it ingests, not what is inside it: unsigned here is unsigned + # on the machine that installs it. + - name: Sign what the MSIX will carry + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $bytes = [Convert]::FromBase64String($env:PFX_BASE64) + Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream + try { + $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 /f signingCert.pfx /p $env:PFX_PASSWORD ` + /tr http://timestamp.digicert.com /td sha256 /fd sha256 ` + stage/Tisty.exe stage/cli/tisty.exe + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } + } finally { + Remove-Item signingCert.pfx -ErrorAction SilentlyContinue + } + + # The bundler is handed a signing command; whether it used it is only readable in the + # installer it produced, so the installer is opened and what it carries is checked. + - name: What the installer will put on disk is signed + if: env.PFX_BASE64 != '' + 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 } + + $status = 0 + foreach ($name in "Tisty.exe", "tisty.exe") { + $found = Get-ChildItem -Path $peek -Recurse -Filter $name -ErrorAction SilentlyContinue + if (-not $found) { Write-Error "$name is not in the installer"; $status = 1; continue } + foreach ($one in $found) { + $sig = Get-AuthenticodeSignature $one.FullName + if ($sig.SignerCertificate) { + Write-Host "$($one.Name) carries $($sig.SignerCertificate.Subject)" + } else { + Write-Error "$($one.FullName) goes out unsigned" + $status = 1 + } + } + } + exit $status + + - name: What the MSIX job must find unchanged + id: stage + shell: bash + run: | echo "sha=$(cat stage/Tisty.exe stage/cli/tisty.exe | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v7 @@ -659,7 +833,7 @@ jobs: = "${{ needs.bundle-windows.outputs.stage }}" test "$(./msix/cli/tisty.exe --version)" = "tisty $v" mkdir -p msix/Assets - for logo in StoreLogo Square44x44Logo Square71x71Logo Square150x150Logo Square310x310Logo; do + for logo in StoreLogo Square44x44Logo Square71x71Logo Square150x150Logo; do cp "app/src-tauri/icons/$logo.png" "msix/Assets/$logo.png" done cp app/src-tauri/icons/Square44x44Logo.targetsize-*_altform-unplated.png msix/Assets/ @@ -711,6 +885,7 @@ jobs: needs: [version, build-macos, build-windows] if: >- !cancelled() && needs.version.outputs.cli == 'true' + && needs.version.outputs.prerelease == 'false' && needs.build-macos.result == 'success' && needs.build-windows.result == 'success' runs-on: ubuntu-latest timeout-minutes: 15 @@ -736,14 +911,18 @@ jobs: VERSION: ${{ needs.version.outputs.version }} STAGED: staged DIST: dist + REPO: ${{ github.repository }} run: python3 scripts/mcpb.py - uses: actions/upload-artifact@v7 with: name: cli-mcpb path: dist/*.mcpb + if-no-files-found: error retention-days: 5 + # One group per tag, not one for all of them: a queued job is cancelled when a third + # arrives, and a release that silently never happened is worse than one that fails loudly. publish: name: GitHub Release needs: @@ -769,7 +948,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 concurrency: - group: release-publish + group: release-publish-${{ github.ref }} cancel-in-progress: false permissions: contents: write @@ -811,10 +990,16 @@ jobs: v="${{ needs.version.outputs.version }}" status=0 want="tisty-installer-$v-windows-x86_64.exe" + if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then + want="$want tisty-$v-windows-x86_64.msix" + fi if [ "${SHIP_CLI}" = "true" ]; then want="$want tisty-cli-$v-windows-x86_64.zip"; fi if [ "${BUILD_MACOS}" = "true" ]; then if [ "${SHIP_CLI}" = "true" ]; then - want="$want tisty-cli-$v-macos-universal.tar.gz tisty-mcp-$v.mcpb" + want="$want tisty-cli-$v-macos-universal.tar.gz" + if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then + want="$want tisty-mcp-$v.mcpb" + fi fi for arch in aarch64 x86_64; do want="$want tisty-installer-$v-macos-$arch.dmg"; done fi @@ -839,7 +1024,7 @@ jobs: - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: - subject-path: dist/tisty-* + subject-path: dist/tisty-*,dist/SHA256SUMS - name: Notes from the tag id: notes @@ -847,10 +1032,11 @@ 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="TISTY_EOF_$(openssl rand -hex 8)" { - echo "body<> "$GITHUB_OUTPUT" # Asked before anything is out. Found afterwards, the release would already be published @@ -859,6 +1045,10 @@ jobs: 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 @@ -1000,14 +1190,27 @@ 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 [ -s "$RUNNER_TEMP/channel.json" ] && cp "$RUNNER_TEMP/channel.json" candidate.json else - jq -n --arg l "$v" '{schema: 1, latest: $l}' > release-manifest.json + # A candidate of a later series is not retired by a fix to an earlier one: whoever + # follows that channel chose it, and nothing here decided to take them off it. + keep="" + if [ -n "$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 + rm -f candidate.json + fi [ -s "$RUNNER_TEMP/channel.json" ] && cp "$RUNNER_TEMP/channel.json" latest.json - rm -f candidate.json fi cat release-manifest.json @@ -1093,6 +1296,9 @@ jobs: 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 @@ -1122,16 +1328,17 @@ jobs: 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 + group: release-msstore-${{ github.ref }} cancel-in-progress: false env: STORE_PUBLISH: ${{ vars.STORE_PUBLISH }} @@ -1204,10 +1411,11 @@ jobs: registry: name: MCP Registry - 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.cli == 'true' && needs.version.outputs.prerelease == 'false' runs-on: ubuntu-latest @@ -1216,7 +1424,7 @@ jobs: id-token: write contents: read concurrency: - group: release-registry + group: release-registry-${{ github.ref }} cancel-in-progress: false env: MCP_PUBLISH: ${{ vars.MCP_PUBLISH }} @@ -1235,43 +1443,60 @@ jobs: - uses: actions/checkout@v7 if: steps.gate.outputs.go == 'yes' - - name: Point the listing at what this tag published + # A version is immutable once listed, so republishing a tag is a permanent red job. + - name: Is this version already listed + id: listed if: steps.gate.outputs.go == 'yes' env: VERSION: ${{ needs.version.outputs.version }} - BUNDLE: bundle.mcpb run: | - curl -sSL --fail -o bundle.mcpb "https://github.com/rgdevment/Tisty/releases/download/v$VERSION/tisty-mcp-$VERSION.mcpb" - python3 scripts/listing.py + at="https://registry.modelcontextprotocol.io/v0.1/servers/io.github.rgdevment%2Ftisty/versions/$VERSION" + code=$(curl -sS -o /dev/null -w '%{http_code}' "$at") + if [ "$code" = "200" ]; then + echo "::notice::the registry already carries $VERSION; nothing to send" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi - - name: Install mcp-publisher - if: steps.gate.outputs.go == 'yes' + - name: Point the listing at what this tag published + if: steps.listed.outputs.go == 'yes' + env: + VERSION: ${{ needs.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - curl -sSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher + gh release download "v$VERSION" --pattern "tisty-mcp-$VERSION.mcpb" --pattern SHA256SUMS + # A wrong hash publishes without complaint and leaves that version uninstallable + # for ever, because the registry never revisits a version it already took. + sha256sum --check --strict --ignore-missing SHA256SUMS + BUNDLE="tisty-mcp-$VERSION.mcpb" REPO="$GITHUB_REPOSITORY" python3 scripts/listing.py - # The registry refuses on its own rules, and a refusal here is cheaper than one after - # the tag has been announced: description length is the one that bites. - - name: Would the registry take it - if: steps.gate.outputs.go == 'yes' - run: ./mcp-publisher validate + - name: Install mcp-publisher + if: steps.listed.outputs.go == 'yes' + run: | + curl -sSL --fail -o publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/$PUBLISHER/mcp-publisher_linux_amd64.tar.gz" + echo "$PUBLISHER_SHA256 publisher.tar.gz" | sha256sum --check --strict + tar xzf publisher.tar.gz mcp-publisher - name: Publish - if: steps.gate.outputs.go == 'yes' + if: steps.listed.outputs.go == 'yes' run: | + ./mcp-publisher validate ./mcp-publisher login github-oidc ./mcp-publisher publish 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 + group: release-homebrew-${{ github.ref }} cancel-in-progress: false env: GIST_TOKEN: ${{ secrets.GIST_TOKEN }} @@ -1326,7 +1551,9 @@ jobs: echo "::warning::a disk image is missing for ${VERSION}; leaving the cask as it is" fi - 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" @@ -1351,6 +1578,11 @@ jobs: conflicts_with cask: "${CASK_OTHER}" depends_on macos: :ventura + caveats <<~TEXT + Tisty needs macOS 13.3 or newer. Homebrew can only check for 13, + so on 13.0 to 13.2 it installs and then will not open. + TEXT + app "Tisty.app" # The command line travels inside the app, as the assistant's door. @@ -1397,6 +1629,23 @@ jobs: fi done + # The feed retires the candidate when a stable passes it; the tap has to agree, or a + # channel nobody maintains goes on offering a build from months ago. + if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then + beta=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' \ + "Casks/${CASK_OTHER}.rb" 2>/dev/null | head -1) + if [ -n "$beta" ] \ + && [ "$(printf '%s\n%s\n' "${beta%%-*}" "$VERSION" | sort -V | tail -1)" \ + = "$VERSION" ]; then + for gone in "Casks/${CASK_OTHER}.rb" "Formula/${FORMULA_OTHER}.rb"; do + if [ -f "$gone" ]; then + git rm -q "$gone" + echo "retired $gone: it still offered $beta, and $VERSION passes it" + fi + done + fi + fi + git add Casks Formula if git diff --cached --quiet; then echo "tap already at ${VERSION}" @@ -1417,15 +1666,16 @@ jobs: winget: name: Windows Package Manager - 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.prerelease == 'false' runs-on: ubuntu-latest timeout-minutes: 15 concurrency: - group: release-winget + group: release-winget-${{ github.ref }} cancel-in-progress: false env: WINGET_PUBLISH: ${{ vars.WINGET_PUBLISH }} diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index e2810525..e1c59e54 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -25,11 +25,18 @@ jobs: # A workflow that will not parse fails in no time at all, with no job and no line number, and # the one that matters is the release: it is only ever run when a tag is already pushed. + - 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: | set -euo pipefail for one in .github/workflows/*.yml; do - if ! npx --yes js-yaml@4.1.0 "$one" > /dev/null; then + if ! npx --no-install js-yaml "$one" > /dev/null; then echo "::error::$one is not valid YAML, so whatever it runs will not run" exit 1 fi @@ -90,6 +97,21 @@ jobs: exit 1 fi + # The suite runs once, in one locale, because no test reads the ambient one: every + # spawn of the binary pins it. A spawn that forgot would test the runner, not the code. + - name: A spawned binary is never left the runner's language + run: | + status=0 + for at in $(grep -ral 'CARGO_BIN_EXE_tisty' crates/tisty-cli/tests); do + spawns=$(grep -ac 'Command::new(env!("CARGO_BIN_EXE_tisty"))' "$at") + pinned=$(grep -aA9 'Command::new(env!("CARGO_BIN_EXE_tisty"))' "$at" | grep -cE 'env_clear\(\)|env_remove\("LC_ALL"\)') + if [ "$spawns" != "$pinned" ]; then + echo "::error::$at spawns the binary $spawns time(s) and pins the language $pinned" + status=1 + fi + done + exit $status + - name: Both languages carry the same documents run: | same() { @@ -105,8 +127,8 @@ jobs: same README.md README.es.md same app/src-tauri/resources/guide/en/guide.md app/src-tauri/resources/guide/es/guia.md - - 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' }} diff --git a/.gitignore b/.gitignore index f1627111..6cb625a0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ app/reports app/stryker-setup-*.js mutants.json window.json +app/sign.json + +__pycache__/ diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 87dcff38..16f6298e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2576,6 +2576,9 @@ async fn update_ready( // and a copy the Store never signed is not asked again every few hours. shop::Shelf::Silent => { held(&session).keep(|c| c.checked_at = Some(now))?; + if asked { + return Err(Refusal::of("updateUnanswered")); + } Ok(last_said()) } }; diff --git a/app/src-tauri/src/shop.rs b/app/src-tauri/src/shop.rs index 069d6c90..0e503b9c 100644 --- a/app/src-tauri/src/shop.rs +++ b/app/src-tauri/src/shop.rs @@ -116,12 +116,33 @@ mod there { .GetAppAndOptionalStorePackageUpdatesAsync()? .join()?; let ours = Package::Current()?.Id()?.FamilyName()?; + let mut offered: Vec = Vec::new(); for one in 0..updates.Size()? { let id = updates.GetAt(one)?.Package()?.Id()?; - if id.FamilyName()? == ours { + let name = id.FamilyName()?; + if name == ours { return Ok(Some(numbered(&id.Version()?))); } + offered.push(name.to_string()); } + // The Store answers from what it last knew unless it is due to ask again, so an empty + // answer here is either «nothing for you» or «I did not look», and they read the same. + witness::note( + channel::WINDOW, + "the Store was asked what it has and named nothing for this package", + &[ + ("ours", Fact::Id(ours.to_string())), + ("offered", Fact::Count(offered.len())), + ( + "names", + Fact::Why(if offered.is_empty() { + "nothing at all".to_string() + } else { + offered.join(", ") + }), + ), + ], + ); Ok(None) } diff --git a/app/src/locales.ts b/app/src/locales.ts index fda104f4..6925d415 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -605,6 +605,8 @@ const en = { betaWarns: "Candidates come out before a release is finished, so they are less tested and some break. Turning this off leaves any candidate already installed where it is: the next stable release replaces it.", updateFailed: "The update could not be installed — {name}", + updateUnanswered: + "The Microsoft Store did not answer, so nothing is known about updates right now. What it last said still stands.", aboutStore: "Store", aboutRepo: "Open the repository", aboutNotices: "Third-party notices", @@ -1909,6 +1911,8 @@ const es: Catalog = { betaWarns: "Las candidatas salen antes de que una versión esté terminada, así que están menos probadas y algunas fallan. Desactivarlo no deshace la candidata que ya tengas: la reemplaza la siguiente versión estable.", updateFailed: "No se pudo instalar la actualización — {name}", + updateUnanswered: + "La Microsoft Store no respondió, así que ahora mismo no se sabe nada de actualizaciones. Sigue valiendo lo último que dijo.", aboutStore: "Almacén", aboutRepo: "Abrir el repositorio", aboutNotices: "Avisos de terceros", diff --git a/app/src/refusal.ts b/app/src/refusal.ts index b4e9ba0a..ba1db439 100644 --- a/app/src/refusal.ts +++ b/app/src/refusal.ts @@ -14,6 +14,7 @@ const KNOWN = [ "updateMoved", "updateStopped", "updateFailed", + "updateUnanswered", "untitled", "noSuchList", "ambiguousList", diff --git a/app/src/tests/about.test.tsx b/app/src/tests/about.test.tsx index 58af3751..229fb1b9 100644 --- a/app/src/tests/about.test.tsx +++ b/app/src/tests/about.test.tsx @@ -156,6 +156,23 @@ describe("a newer version the Store itself offers", () => { expect(screen.queryByText(/newest version/i)).toBeNull(); }); + // «Could not ask» and «nothing for you» read the same on screen, and that is what hid a Store + // that had not answered for whole versions. + it("reports a Store that did not answer instead of leaving «nothing newer» standing", async () => { + const problems: unknown[] = []; + ipc.answer = (cmd) => + cmd === "update_ready" + ? Promise.reject({ code: "updateUnanswered" }) + : Promise.resolve({ ...build, keptByTheStore: true }); + render( problems.push(problem)} />); + + await screen.findByText("0.1.0"); + await userEvent.click(screen.getByRole("button", { name: /check for updates/i })); + + await waitFor(() => expect(problems.length).toBe(1)); + expect(saidPlainly(problems[0])).toMatch(/did not answer/i); + }); + const waiting = { version: "1.15.0", route: "store" as const, package: null, installs: true }; // A copy left closed for weeks remembers an offer the feed has moved past; the button must not diff --git a/app/stryker.config.json b/app/stryker.config.json index dca74926..52c72574 100644 --- a/app/stryker.config.json +++ b/app/stryker.config.json @@ -2,6 +2,7 @@ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", "packageManager": "npm", "testRunner": "vitest", + "tsconfigFile": "tsconfig.none.json", "vitest": { "configFile": "vite.config.ts" }, diff --git a/package-lock.json b/package-lock.json index eafd5d33..4ded07f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "tisty", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.23.2" } }, @@ -428,9 +429,9 @@ } }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -447,7 +448,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.mjs" + "js-yaml": "bin/js-yaml.js" } }, "node_modules/jsonc-parser": { @@ -596,6 +597,29 @@ "markdownlint-cli2": ">=0.0.4" } }, + "node_modules/markdownlint-cli2/node_modules/js-yaml": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/mdurl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", diff --git a/package.json b/package.json index 965605bc..eb953cbd 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "repository": "github:rgdevment/Tisty", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.23.2" }, "scripts": { diff --git a/scripts/generations.py b/scripts/generations.py new file mode 100644 index 00000000..cea82764 --- /dev/null +++ b/scripts/generations.py @@ -0,0 +1,82 @@ +#!/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" +# Only rust: a setup-node key carries the hash of its lockfile and nothing else, so two live +# caches for one platform look alike and the sweep would take one of them for a leftover. +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 + + +# A tag is written once and never built again, so nothing will ever ask for these by key. +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/listing.py b/scripts/listing.py index efeb5b19..c04b5c74 100644 --- a/scripts/listing.py +++ b/scripts/listing.py @@ -8,7 +8,8 @@ VERSION = os.environ["VERSION"] BUNDLE = os.environ["BUNDLE"] AT = os.environ.get("LISTING", "server.json") -URL = f"https://github.com/rgdevment/Tisty/releases/download/v{VERSION}/tisty-mcp-{VERSION}.mcpb" +REPO = os.environ.get("REPO", "rgdevment/Tisty") +URL = f"https://github.com/{REPO}/releases/download/v{VERSION}/tisty-mcp-{VERSION}.mcpb" listing = json.load(io.open(AT, encoding="utf-8")) listing["version"] = VERSION @@ -18,5 +19,10 @@ if len(listing["description"]) > 100: sys.exit(f"the registry takes 100 characters, and this is {len(listing['description'])}") +# The file in the repository is a template on purpose; publishing it unstamped would list a +# release that does not exist, at a version nobody can ever correct. +if listing["version"] == "0.0.0" or set(listing["packages"][0]["fileSha256"]) == {"0"}: + sys.exit("the listing was not stamped: it still carries the template's version or hash") + io.open(AT, "w", encoding="utf-8", newline="\n").write(json.dumps(listing, indent=2) + "\n") print(json.dumps(listing, indent=2)) diff --git a/scripts/mcpb.py b/scripts/mcpb.py index a36c9085..8baa66e3 100644 --- a/scripts/mcpb.py +++ b/scripts/mcpb.py @@ -9,23 +9,28 @@ VERSION = os.environ["VERSION"] DIST = os.environ.get("DIST", "dist") +LISTING = os.environ.get("LISTING", "server.json") OUT = os.path.join(DIST, f"tisty-mcp-{VERSION}.mcpb") +REPO = os.environ.get("REPO", "rgdevment/Tisty") +SAID = json.load(io.open(LISTING, encoding="utf-8"))["description"] MANIFEST = { "manifest_version": "0.3", "name": "tisty", "display_name": "Tisty", "version": VERSION, - "description": "Notes, documents and tasks as plain Markdown on your own disk.", - "author": {"name": "rgdevment", "url": "https://github.com/rgdevment"}, - "homepage": "https://github.com/rgdevment/Tisty", + "description": SAID, + "author": {"name": REPO.split("/")[0], "url": f"https://github.com/{REPO.split('/')[0]}"}, + "homepage": f"https://github.com/{REPO}", "server": { "type": "binary", "entry_point": "server/tisty", "mcp_config": { - "command": "server/tisty", + # The reference host only substitutes ${...}; nothing there resolves a bare + # relative path against the extension directory, so a plain one finds nothing. + "command": "${__dirname}/server/tisty", "args": ["mcp"], - "platform_overrides": {"win32": {"command": "server/tisty.exe"}}, + "platform_overrides": {"win32": {"command": "${__dirname}/server/tisty.exe"}}, }, }, "compatibility": {"platforms": ["darwin", "win32"]}, diff --git a/server.json b/server.json index 0a7b7987..15e9dcf6 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "url": "https://github.com/rgdevment/Tisty", "source": "github" }, - "version": "1.20.0", + "version": "0.0.0", "packages": [ { "registryType": "mcpb", - "identifier": "https://github.com/rgdevment/Tisty/releases/download/v1.20.0/tisty-mcp-1.20.0.mcpb", - "fileSha256": "45243a87bd8b33073ce04381a73c6162bd4d456771d68b8a80022e9f041637ee", + "identifier": "https://github.com/rgdevment/Tisty/releases/download/v0.0.0/tisty-mcp-0.0.0.mcpb", + "fileSha256": "0000000000000000000000000000000000000000000000000000000000000000", "transport": { "type": "stdio" } } ]