From 38dc7e443266301c4d92fd4aea9be4f1e8efb343 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 22:34:41 +0000 Subject: [PATCH 1/2] Harden install, release, and CLI clipboard defaults. Replace live-main zip installers with tagged GitHub Release wheels plus SHA256SUMS verification. Stop auto-tagging, auto-publishing, and merged-branch deletion. Pass workflow_dispatch values through env. Interactive CLI copies only with --copy. Pin the product-contract hash, keep verified release-registry rules, refuse UNVERIFIED Bayesian confirmatory scores, and cap Bayesian checkpoint JSON size. Co-authored-by: Byte271 --- .github/workflows/cycle8-carrier-detector.yml | 7 +- .../workflows/e26-open-adapter-transfer.yml | 32 +++-- .github/workflows/release-engineering.yml | 58 +++----- .../workflows/synthid-eligible-geometry.yml | 16 ++- .../workflows/synthid-geometry-headroom.yml | 16 ++- .github/workflows/synthid-geometry.yml | 19 ++- .../synthid-high-repetition-detector.yml | 16 ++- .github/workflows/synthid-postselection.yml | 19 ++- .../workflows/synthid-repetition-strata.yml | 16 ++- .github/workflows/synthid-schedule-stress.yml | 19 ++- .github/workflows/synthid-smoke.yml | 6 +- .github/workflows/tiny-dev-corpus.yml | 7 +- .github/workflows/tiny-dev-detector.yml | 7 +- .gitignore | 1 - CHANGELOG.md | 8 ++ README.md | 36 +---- docs/cli.md | 2 +- docs/install.md | 72 +++++----- docs/release.md | 11 +- fuckmark/cli.py | 4 +- fuckmark/detectors/bayesian.py | 7 +- fuckmark/detectors/bayesian_evidence.py | 5 + fuckmark/product/__init__.py | 2 + fuckmark/product/contract.py | 6 +- fuckmark/transforms/fidelity_verification.py | 5 +- tests/test_bayesian_evidence.py | 2 + tests/test_bayesian_fixture.py | 10 ++ tests/test_cli.py | 30 +++- tests/test_install_release_hardening.py | 102 ++++++++++++++ tests/test_product_visible_invariance.py | 19 ++- tests/test_repository_hygiene.py | 2 +- tests/test_source_grounded_lexical_release.py | 20 +++ tools/install/unix.sh | 128 ++++++++++++++++++ tools/install/windows.ps1 | 120 ++++++++++++++++ 34 files changed, 644 insertions(+), 186 deletions(-) create mode 100644 tests/test_install_release_hardening.py create mode 100755 tools/install/unix.sh create mode 100644 tools/install/windows.ps1 diff --git a/.github/workflows/cycle8-carrier-detector.yml b/.github/workflows/cycle8-carrier-detector.yml index 2c6dbe05..59ece741 100644 --- a/.github/workflows/cycle8-carrier-detector.yml +++ b/.github/workflows/cycle8-carrier-detector.yml @@ -43,10 +43,13 @@ jobs: - name: Install maintained Transformers SynthID dependencies run: python -m pip install -r requirements-smoke.txt - name: Run Cycle 8 exploratory detector compare on seed 890000 + env: + INPUT_DEVICE: ${{ inputs.device || 'cpu' }} + INPUT_MAX_ATTEMPTS: ${{ inputs.max_attempts || '64' }} run: >- python -m fuckmark.cycle8_hf - --device "${{ inputs.device || 'cpu' }}" - --max-attempts "${{ inputs.max_attempts || '64' }}" + --device "$INPUT_DEVICE" + --max-attempts "$INPUT_MAX_ATTEMPTS" --detector-json artifacts/cycle8-exploratory-890000/detector-compare.json - uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/e26-open-adapter-transfer.yml b/.github/workflows/e26-open-adapter-transfer.yml index 398adfd4..8e89ba27 100644 --- a/.github/workflows/e26-open-adapter-transfer.yml +++ b/.github/workflows/e26-open-adapter-transfer.yml @@ -49,17 +49,23 @@ jobs: - name: Install maintained Transformers SynthID dependencies run: python -m pip install -r requirements-smoke.txt - name: Run maintained Transformers native smoke + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_SEED_BASE: ${{ inputs.seed_base }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_TARGET_FPR: ${{ inputs.target_fpr }} run: >- python -m fuckmark.synthid_smoke_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --seed-base "${{ inputs.seed_base }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --seed-base "$INPUT_SEED_BASE" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" --temperature 0.8 --top-k 50 --top-p 0.95 - --target-fpr "${{ inputs.target_fpr }}" + --target-fpr "$INPUT_TARGET_FPR" --json artifacts/huggingface.json --csv artifacts/huggingface.csv - uses: actions/upload-artifact@v4 @@ -86,17 +92,23 @@ jobs: python -m pip install "numpy==1.26.0" "immutabledict==4.2.0" "transformers==4.43.3" python -m pip install --no-deps "git+https://github.com/google-deepmind/synthid-text.git@addb4a158143c7c6851a1308f78b89fceed59683" - name: Run pinned DeepMind reference native smoke + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_SEED_BASE: ${{ inputs.seed_base }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_TARGET_FPR: ${{ inputs.target_fpr }} run: >- python -m fuckmark.synthid_smoke_deepmind - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --seed-base "${{ inputs.seed_base }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --seed-base "$INPUT_SEED_BASE" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" --temperature 0.8 --top-k 50 --top-p 0.95 - --target-fpr "${{ inputs.target_fpr }}" + --target-fpr "$INPUT_TARGET_FPR" --json artifacts/deepmind.json --csv artifacts/deepmind.csv - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-engineering.yml b/.github/workflows/release-engineering.yml index 5da0f34d..f403949c 100644 --- a/.github/workflows/release-engineering.yml +++ b/.github/workflows/release-engineering.yml @@ -17,6 +17,11 @@ on: - "docs/**" - "LICENSE" workflow_dispatch: + inputs: + publish_github_release: + description: Publish a GitHub Release for the existing v tag. This job never creates tags and never deletes branches. + type: boolean + default: false permissions: contents: read @@ -62,27 +67,25 @@ jobs: if-no-files-found: error release: - name: Ensure GitHub Release - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + name: Publish GitHub Release + if: github.event_name == 'workflow_dispatch' && inputs.publish_github_release == true needs: package-e2e runs-on: ubuntu-latest permissions: contents: write - outputs: - published: ${{ steps.state.outputs.publish }} steps: - name: Checkout release commit uses: actions/checkout@v7 with: fetch-depth: 0 - persist-credentials: true + persist-credentials: false - name: Set up Python uses: actions/setup-python@v7 with: python-version: "3.12" - - name: Resolve release state + - name: Require existing version tag id: state env: GH_TOKEN: ${{ github.token }} @@ -93,10 +96,14 @@ jobs: release_tag="v${package_version}" echo "tag=$release_tag" >> "$GITHUB_OUTPUT" git fetch --tags --force - if git rev-parse --verify --quiet "refs/tags/$release_tag" >/dev/null; then - echo "tag_exists=true" >> "$GITHUB_OUTPUT" - else - echo "tag_exists=false" >> "$GITHUB_OUTPUT" + if ! git rev-parse --verify --quiet "refs/tags/$release_tag" >/dev/null; then + printf '%s\n' "Tag $release_tag does not exist. Create and push that tag before publishing." + exit 1 + fi + tagged_sha="$(git rev-parse "refs/tags/$release_tag^{commit}")" + if [ "$tagged_sha" != "$GITHUB_SHA" ]; then + printf '%s\n' "Tag $release_tag points at $tagged_sha, not this commit $GITHUB_SHA." + exit 1 fi if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "publish=false" >> "$GITHUB_OUTPUT" @@ -104,16 +111,6 @@ jobs: echo "publish=true" >> "$GITHUB_OUTPUT" fi - - name: Create immutable release tag - if: steps.state.outputs.tag_exists == 'false' - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "${{ steps.state.outputs.tag }}" "$GITHUB_SHA" -m "${{ steps.state.outputs.tag }}" - git push origin "refs/tags/${{ steps.state.outputs.tag }}" - - name: Download verified distributions if: steps.state.outputs.publish == 'true' uses: actions/download-artifact@v8 @@ -130,24 +127,3 @@ jobs: run: | set -euo pipefail gh release create "$RELEASE_TAG" release-dist/* --verify-tag --title "FuckMark $RELEASE_TAG" --generate-notes - - cleanup-merged-branches: - name: Remove merged branches - if: needs.release.outputs.published == 'true' - needs: release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Delete merged pull-request branches - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - branches="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls?state=closed&per_page=100" | jq -r --arg repo "$GITHUB_REPOSITORY" '.[][] | select(.merged_at != null and .head.repo.full_name == $repo) | .head.ref' | sort -u)" - while IFS= read -r branch; do - [[ -n "$branch" ]] || continue - encoded="$(jq -rn --arg value "$branch" '$value | @uri')" - gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/heads/$encoded" || true - done <<< "$branches" diff --git a/.github/workflows/synthid-eligible-geometry.yml b/.github/workflows/synthid-eligible-geometry.yml index a2b7ce39..8c157147 100644 --- a/.github/workflows/synthid-eligible-geometry.yml +++ b/.github/workflows/synthid-eligible-geometry.yml @@ -53,14 +53,20 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run public-eligibility geometry pilot + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} run: >- python -m fuckmark.synthid_eligible_geometry_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-geometry-headroom.yml b/.github/workflows/synthid-geometry-headroom.yml index 095a386c..78120b8c 100644 --- a/.github/workflows/synthid-geometry-headroom.yml +++ b/.github/workflows/synthid-geometry-headroom.yml @@ -53,14 +53,20 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run detector-free geometry headroom diagnostic + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} run: >- python -m fuckmark.synthid_geometry_headroom_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-geometry.yml b/.github/workflows/synthid-geometry.yml index cfe8905e..dde58653 100644 --- a/.github/workflows/synthid-geometry.yml +++ b/.github/workflows/synthid-geometry.yml @@ -58,15 +58,22 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run matched random-vs-greedy geometry pilot + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} + INPUT_RANDOM_SEED_COUNT: ${{ inputs.random_seed_count }} run: >- python -m fuckmark.synthid_geometry_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" - --random-seed-count "${{ inputs.random_seed_count }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" + --random-seed-count "$INPUT_RANDOM_SEED_COUNT" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-high-repetition-detector.yml b/.github/workflows/synthid-high-repetition-detector.yml index 61312622..7db57124 100644 --- a/.github/workflows/synthid-high-repetition-detector.yml +++ b/.github/workflows/synthid-high-repetition-detector.yml @@ -53,14 +53,20 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run frozen high-repetition detector pilot + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} run: >- python -m fuckmark.synthid_high_repetition_detector_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-postselection.yml b/.github/workflows/synthid-postselection.yml index 7592e930..fd3de595 100644 --- a/.github/workflows/synthid-postselection.yml +++ b/.github/workflows/synthid-postselection.yml @@ -58,15 +58,22 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run post-selection descriptive audit + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} + INPUT_RANDOM_SEED_COUNT: ${{ inputs.random_seed_count }} run: >- python -m fuckmark.synthid_postselection_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" - --random-seed-count "${{ inputs.random_seed_count }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" + --random-seed-count "$INPUT_RANDOM_SEED_COUNT" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-repetition-strata.yml b/.github/workflows/synthid-repetition-strata.yml index cf524c5d..cbb3a79f 100644 --- a/.github/workflows/synthid-repetition-strata.yml +++ b/.github/workflows/synthid-repetition-strata.yml @@ -53,14 +53,20 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run repetition-stratified detector-free headroom analysis + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} run: >- python -m fuckmark.synthid_repetition_strata_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-schedule-stress.yml b/.github/workflows/synthid-schedule-stress.yml index 51880c5e..31b5a96e 100644 --- a/.github/workflows/synthid-schedule-stress.yml +++ b/.github/workflows/synthid-schedule-stress.yml @@ -58,15 +58,22 @@ jobs: - name: Install SynthID experiment dependencies run: python -m pip install -r requirements-smoke.txt - name: Run public-geometry headroom stress analysis + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_BUDGETS: ${{ inputs.budgets }} + INPUT_RANDOM_SEED_COUNT: ${{ inputs.random_seed_count }} run: >- python -m fuckmark.synthid_schedule_stress_hf - --model "${{ inputs.model }}" + --model "$INPUT_MODEL" --device cpu - --prompt-limit "${{ inputs.prompt_limit }}" - --max-new-tokens "${{ inputs.max_new_tokens }}" - --registry "${{ inputs.registry }}" - --budgets "${{ inputs.budgets }}" - --random-seed-count "${{ inputs.random_seed_count }}" + --prompt-limit "$INPUT_PROMPT_LIMIT" + --max-new-tokens "$INPUT_MAX_NEW_TOKENS" + --registry "$INPUT_REGISTRY" + --budgets "$INPUT_BUDGETS" + --random-seed-count "$INPUT_RANDOM_SEED_COUNT" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/synthid-smoke.yml b/.github/workflows/synthid-smoke.yml index fa986d0e..3df2b774 100644 --- a/.github/workflows/synthid-smoke.yml +++ b/.github/workflows/synthid-smoke.yml @@ -39,7 +39,11 @@ jobs: - name: Install smoke dependencies run: python -m pip install -r requirements-smoke.txt - name: Run open SynthID smoke - run: python -m fuckmark.synthid_smoke_hf --model "${{ inputs.model }}" --device cpu --prompt-limit "${{ inputs.prompt_limit }}" --max-new-tokens "${{ inputs.max_new_tokens }}" + env: + INPUT_MODEL: ${{ inputs.model }} + INPUT_PROMPT_LIMIT: ${{ inputs.prompt_limit }} + INPUT_MAX_NEW_TOKENS: ${{ inputs.max_new_tokens }} + run: python -m fuckmark.synthid_smoke_hf --model "$INPUT_MODEL" --device cpu --prompt-limit "$INPUT_PROMPT_LIMIT" --max-new-tokens "$INPUT_MAX_NEW_TOKENS" - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/tiny-dev-corpus.yml b/.github/workflows/tiny-dev-corpus.yml index fab8a567..e020b483 100644 --- a/.github/workflows/tiny-dev-corpus.yml +++ b/.github/workflows/tiny-dev-corpus.yml @@ -41,13 +41,16 @@ jobs: - name: Install maintained Transformers SynthID dependencies run: python -m pip install -r requirements-smoke.txt - name: Generate real TinyDev corpus + env: + INPUT_SEED_BASE: ${{ inputs.seed_base || '401000' }} + INPUT_MAX_ATTEMPTS: ${{ inputs.max_attempts || '16' }} run: >- python -m fuckmark.tiny_dev_corpus_hf --model openai-community/gpt2 --model-revision 607a30d783dfa663caf39e06633721c8d4cfcd7e --device cpu - --seed-base "${{ inputs.seed_base || '401000' }}" - --max-attempts "${{ inputs.max_attempts || '16' }}" + --seed-base "$INPUT_SEED_BASE" + --max-attempts "$INPUT_MAX_ATTEMPTS" --temperature 0.8 --top-k 50 --top-p 0.95 diff --git a/.github/workflows/tiny-dev-detector.yml b/.github/workflows/tiny-dev-detector.yml index ebd08cdb..b944b81a 100644 --- a/.github/workflows/tiny-dev-detector.yml +++ b/.github/workflows/tiny-dev-detector.yml @@ -48,13 +48,16 @@ jobs: - name: Install maintained Transformers SynthID dependencies run: python -m pip install -r requirements-smoke.txt - name: Reproduce real TinyDev corpus + env: + INPUT_SEED_BASE: ${{ inputs.seed_base || '401000' }} + INPUT_MAX_ATTEMPTS: ${{ inputs.max_attempts || '16' }} run: >- python -m fuckmark.tiny_dev_corpus_hf --model openai-community/gpt2 --model-revision 607a30d783dfa663caf39e06633721c8d4cfcd7e --device cpu - --seed-base "${{ inputs.seed_base || '401000' }}" - --max-attempts "${{ inputs.max_attempts || '16' }}" + --seed-base "$INPUT_SEED_BASE" + --max-attempts "$INPUT_MAX_ATTEMPTS" --temperature 0.8 --top-k 50 --top-p 0.95 diff --git a/.gitignore b/.gitignore index a0338668..b9e83619 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,3 @@ venv/ .env.* .DS_Store artifacts/ -spec.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d29dc03..983fa5b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ - Chromium `pre` screenshots: U+034F / U+FE00 pixel-equal; U+200C PNG bytes differ. - Detector-blind GPT-2 / SynthID development scoring on seeds `890000`, `900000`, and `910000` reduced watermarked detections for U+034F space-carrier x1 without unwatermarked inflation on these tiny 4-pair corpora. This is not confirmation and is not a product promotion. - Product contract loading uses the embedded v1 payload in installed wheels. Writers that replace pinned `specs/cycle8` tokenizer JSON refuse to run without a GPT-2 encoder; the detector harness still scores when tiktoken is absent. Chromium HTML evidence encodes less-than and greater-than as JSON unicode escapes so an ASCII script terminator cannot close the inline script. +- Official install docs now use the tagged GitHub Release wheel plus `SHA256SUMS.txt`. In-repo installers verify that checksum, do not start the CLI, and do not use sudo. `d.q1z.org/mark` is no longer documented as the install path. +- Release Engineering no longer auto-tags, auto-publishes, or deletes merged branches on `main` push. Publishing is `workflow_dispatch` only against an existing tag. +- GitHub Actions `workflow_dispatch` values are passed through `env:` instead of being interpolated into `run:` scripts. +- Interactive CLI copies to the clipboard only with `--copy`. +- Product contract loading requires the frozen v1 hash even when `specs/` is absent from an installed wheel. +- `source_verified_release_transform_registry` keeps verified visible-preserving rules instead of dropping them after promotion checks. +- `BayesianDetectorEvidence.confirmatory_score()` refuses UNVERIFIED status. `raw_score` stays the frozen v1 field. +- `load_bayesian_checkpoint` rejects JSON larger than 64 MiB. ## v0.2.0 — Exact-survival confirmation and release hardening diff --git a/README.md b/README.md index bd074eb9..c30e40d7 100644 --- a/README.md +++ b/README.md @@ -50,42 +50,16 @@ The public CLI uses only `release_transform_registry()`, which is now the produc ## Install -Python 3.11 or newer is required. - -### Linux - -```sh -curl -fsSL https://d.q1z.org/mark | sh -``` - -### macOS - -```sh -curl -fsSL https://d.q1z.org/mark | sh -``` - -### Windows - -Run PowerShell: - -```powershell -irm https://d.q1z.org/mark | iex -``` - -Verify the installation: +Python 3.11 or newer is required. Install a **tagged GitHub Release wheel** and check `SHA256SUMS.txt`. Do not pipe `https://d.q1z.org/mark` into a shell. ```text -FuckMark --version +python3 -m venv .venv +.venv/bin/python -m pip install https://github.com/byte271/FuckMark/releases/download/v0.2.0/fuckmark-0.2.0-py3-none-any.whl ``` -For a manual tagged install: - -```text -python -m venv .venv -python -m pip install https://github.com/byte271/FuckMark/archive/refs/tags/v0.2.0.zip -``` +v0.2.0 wheel SHA-256: `3478f46511120d37e319aae3226eab07da0440927d1f117894539c1d181be8f2` -See [`docs/install.md`](docs/install.md) for platform details and update behavior. +The installed CLI currently returns input text unchanged. See [`docs/install.md`](docs/install.md) for checksum verification, the in-repo installer, and Windows notes. ## CLI diff --git a/docs/cli.md b/docs/cli.md index 74fcbb38..d829a9b8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,7 +18,7 @@ FuckMark Paste the source text and finish with `:done` on its own line. The legacy `ok` terminator remains accepted. -Interactive mode reports the exact project version, processes the text deterministically, reports whether any product-authorized invisible transform was applied, and copies the result to the platform clipboard. Color is emitted only when standard output is an interactive terminal. `--no-color` and the `NO_COLOR` environment variable disable ANSI output. +Interactive mode reports the exact project version, processes the text deterministically, prints the result, and reports whether any product-authorized invisible transform was applied. It does not copy to the clipboard unless `--copy` is passed. Color is emitted only when standard output is an interactive terminal. `--no-color` and the `NO_COLOR` environment variable disable ANSI output. ## Stream use diff --git a/docs/install.md b/docs/install.md index 0a75bab7..d14d0c5c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,60 +1,64 @@ # Installation -FuckMark v0.2.0 supports Windows, macOS, and Linux with Python 3.11 or newer. The official installer endpoint is `https://d.q1z.org/mark`. +Python 3.11 or newer is required. The public CLI currently returns input text unchanged: v0.2.0 has no product-authorized invisible carrier. -## Linux +Install only a GitHub Release wheel and check `SHA256SUMS.txt` from that same release. Do not pipe `https://d.q1z.org/mark` into a shell. That endpoint still installs live `main` without a checksum. -```sh -curl -fsSL https://d.q1z.org/mark | sh -``` +## Tagged wheel (recommended) -## macOS +v0.2.0 wheel SHA-256: -```sh -curl -fsSL https://d.q1z.org/mark | sh +```text +3478f46511120d37e319aae3226eab07da0440927d1f117894539c1d181be8f2 fuckmark-0.2.0-py3-none-any.whl ``` -## Windows +```text +python3 -m venv .venv +.venv/bin/python -m pip install https://github.com/byte271/FuckMark/releases/download/v0.2.0/fuckmark-0.2.0-py3-none-any.whl +``` -Run PowerShell: +On Windows, create the venv with `py -3.12 -m venv .venv` and use `.venv\Scripts\python.exe`. -```powershell -irm https://d.q1z.org/mark | iex +Download `SHA256SUMS.txt` from the same release and confirm the installed wheel digest before trusting the environment: + +```text +https://github.com/byte271/FuckMark/releases/download/v0.2.0/SHA256SUMS.txt ``` -The endpoint selects PowerShell for Windows clients and a Unix dispatcher for Linux/macOS clients. The installer creates an isolated user-level virtual environment and exposes FuckMark without requiring an administrator installation. +## In-repo installer (tagged wheel + checksum) -## Verify +These scripts download the GitHub Release wheel, verify `SHA256SUMS.txt`, install into a user virtualenv, and print `fuckmark --help`. They do not start the CLI, do not use sudo, and do not install Python. -Open a new terminal after installation and run: +Linux / macOS, from a clone: -```text -FuckMark --version +```sh +sh tools/install/unix.sh ``` -For v0.2.0, the command must begin with: +Windows PowerShell, from a clone: -```text -FuckMark 0.2.0 +```powershell +powershell -ExecutionPolicy Bypass -File tools/install/windows.ps1 ``` -It also reports the release CLI and transform-registry algorithm identities. +Optional override: `FUCKMARK_RELEASE_TAG=v0.2.0`. -## Manual tagged installation +## Verify -Users who do not want to pipe a remote installer into a shell can install the immutable tagged source explicitly: +```text +fuckmark --version +``` + +For v0.2.0 the command must begin with: ```text -python -m venv .venv -python -m pip install https://github.com/byte271/FuckMark/archive/refs/tags/v0.2.0.zip +FuckMark 0.2.0 ``` -Activate the virtual environment using the platform's normal command, then run `FuckMark --version`. +It also reports `release-cli-v4`. Transforming text currently prints the same visible input. ## Install from a local clone -From the repository root: - ```text python -m pip install . ``` @@ -65,18 +69,16 @@ For development and tests: python -m pip install -e ".[dev]" ``` -The core package has no runtime dependencies. Research workflows that reproduce open SynthID experiments install their pinned model/runtime dependencies separately through `requirements-smoke.txt` and the workflow definitions. +The core package has no runtime dependencies. Research workflows that reproduce open SynthID experiments install pinned model/runtime dependencies through `requirements-smoke.txt`. ## Update -Run the same one-command installer again. It replaces the package inside the managed virtual environment while preserving the command location. - -After any update, verify the installed version with `FuckMark --version`. +Install a newer tagged wheel the same way and verify `FuckMark --version` against that tag. Do not follow a moving `main.zip`. ## Troubleshooting -If the command is not found immediately after installation, open a new terminal so the updated user PATH is loaded. +If the command is not found, open a new terminal so PATH updates load. -On Linux, clipboard copying requires one of `wl-copy`, `xclip`, `xsel`, or `clip.exe`. The CLI still produces transformed output if clipboard transfer is unavailable. +On Linux, clipboard copying with `--copy` requires one of `wl-copy`, `xclip`, `xsel`, or `clip.exe`. The CLI still prints output if clipboard transfer is unavailable. -Website: [mark.q1z.org](https://mark.q1z.org) +Website: [mark.q1z.org](https://mark.q1z.org). The website one-click installer is not the source of truth for this repository. diff --git a/docs/release.md b/docs/release.md index f55e5223..adc246ce 100644 --- a/docs/release.md +++ b/docs/release.md @@ -25,18 +25,17 @@ The public CLI is restricted to product-authorized invisible transforms. None ar 7. Verify every installed console alias, `--version`, deterministic stream output, wheel metadata, and source-distribution metadata. 8. Generate `SHA256SUMS.txt` from the exact verified wheel and source distribution. 9. Merge the release branch only after required GitHub Actions are green. -10. On the resulting `main` push, rerun the cross-platform release matrix. -11. If `v` does not already exist, the release job creates that immutable tag at the exact verified `main` commit. -12. The same job separately checks whether the GitHub Release exists. If the tag exists but the Release does not, publication is repaired from the verified distributions instead of silently stopping in a half-published state. -13. Later `main` pushes with the same project version and an existing GitHub Release are safe no-ops for publication. +10. On the resulting `main` push, rerun the cross-platform package matrix. That push must **not** create tags, publish a GitHub Release, or delete branches. +11. Create and push the immutable `v` tag yourself after the package matrix is green. The workflow never runs `git tag`. +12. Publish with `workflow_dispatch` and `publish_github_release=true` on that same commit. The job refuses to run unless the tag already exists and points at the dispatch SHA. If the GitHub Release is missing, it uploads the verified wheel and sdist. It does not delete merged branches. ## Publication workflow The `Release Engineering` workflow runs its package matrix on Ubuntu, macOS, and Windows. It uses current Node 24 GitHub Actions, builds the distributions independently in each operating-system job, runs `twine check`, and invokes `tools/verify_release_install.py` against both artifacts. -The Linux job uploads the verified release distributions. On a `main` push, the publication job derives `v` from `pyproject.toml`, checks the Git tag and GitHub Release independently, creates the tag only when it is missing, and creates the GitHub Release only when it is missing. This makes publication idempotent and repairs the specific boundary where a tag was created successfully but Release creation failed afterward. +The Linux job uploads the verified release distributions. Publication is **manual**: `workflow_dispatch` with `publish_github_release=true`. The job never creates tags, never uses `persist-credentials` to push, and never deletes merged pull-request branches. It publishes only when `v` already exists and matches the dispatch commit. -Direct tag pushes do not trigger the release workflow. That avoids duplicate release runs racing against the `main` run that created the tag. There is no hard-coded release tag and no commit-message release trigger. +Direct tag pushes do not trigger the workflow. There is no hard-coded release tag and no commit-message release trigger. ## v0.2.0 scientific result diff --git a/fuckmark/cli.py b/fuckmark/cli.py index 5f64cfb3..1fe4c288 100644 --- a/fuckmark/cli.py +++ b/fuckmark/cli.py @@ -293,7 +293,7 @@ def main( except (KeyError, TypeError, ValueError) as error: return _error(errors, f"transformation failed: {error}") - should_copy = arguments.copy or (interactive and arguments.output is None) + should_copy = arguments.copy copy_failed: Exception | None = None if should_copy: writer = copy_to_clipboard if clipboard_writer is None else clipboard_writer @@ -303,7 +303,7 @@ def main( copy_failed = error try: - if batch_mode or arguments.output is not None or copy_failed is not None: + if batch_mode or arguments.output is not None or copy_failed is not None or (interactive and not should_copy): _write_result(result.output_text, arguments.output, output) if interactive and (arguments.output is None or arguments.output == "-"): output.write("\n") diff --git a/fuckmark/detectors/bayesian.py b/fuckmark/detectors/bayesian.py index ede07842..2584878d 100644 --- a/fuckmark/detectors/bayesian.py +++ b/fuckmark/detectors/bayesian.py @@ -13,6 +13,7 @@ BAYESIAN_CHECKPOINT_ALGORITHM_VERSION = "deepmind-bayesian-checkpoint-v1" +BAYESIAN_CHECKPOINT_JSON_MAX_BYTES = 64 * 1024 * 1024 BAYESIAN_SCORER_ALGORITHM_VERSION = "deepmind-bayesian-posterior-v1" @@ -139,7 +140,11 @@ def _finite_float(name: str, value: object) -> float: def load_bayesian_checkpoint(path: str | Path) -> BayesianCheckpoint: if not isinstance(path, (str, Path)): raise TypeError("path must be a string or Path") - data = json.loads(Path(path).read_text(encoding="utf-8")) + file_path = Path(path) + size = file_path.stat().st_size + if size > BAYESIAN_CHECKPOINT_JSON_MAX_BYTES: + raise ValueError("Bayesian checkpoint JSON exceeds the size limit") + data = json.loads(file_path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError("Bayesian checkpoint JSON must contain an object") return BayesianCheckpoint.from_mapping(data) diff --git a/fuckmark/detectors/bayesian_evidence.py b/fuckmark/detectors/bayesian_evidence.py index 4327c4a3..da4d46fd 100644 --- a/fuckmark/detectors/bayesian_evidence.py +++ b/fuckmark/detectors/bayesian_evidence.py @@ -107,6 +107,11 @@ def __post_init__(self) -> None: if self.evidence_hash != sha256_json(self._payload()): raise ValueError("evidence_hash does not match Bayesian detector evidence") + def confirmatory_score(self) -> float: + if self.compatibility_status is CompatibilityStatus.UNVERIFIED: + raise ValueError("UNVERIFIED Bayesian evidence cannot be used as confirmatory score") + return self.raw_score + def _payload(self) -> dict[str, object]: return { "algorithm_version": self.algorithm_version, diff --git a/fuckmark/product/__init__.py b/fuckmark/product/__init__.py index 3483c414..e97665a9 100644 --- a/fuckmark/product/__init__.py +++ b/fuckmark/product/__init__.py @@ -5,6 +5,7 @@ space_carrier_rule, ) from .contract import ( + FROZEN_PRODUCT_CONTRACT_HASH, PRODUCT_CONTRACT_PATH, load_product_contract, product_contract_hash, @@ -29,6 +30,7 @@ __all__ = [ "InvisibleCarrierAfterAsciiLetterRule", + "FROZEN_PRODUCT_CONTRACT_HASH", "PRODUCT_CONTRACT_ID", "PRODUCT_CONTRACT_PATH", "PRODUCT_DOMAIN_ID", diff --git a/fuckmark/product/contract.py b/fuckmark/product/contract.py index 6220b4ce..edbc74a8 100644 --- a/fuckmark/product/contract.py +++ b/fuckmark/product/contract.py @@ -9,6 +9,7 @@ PRODUCT_CONTRACT_PATH = Path(__file__).resolve().parents[2] / "specs" / "fuckmark-user-visible-invariance-v1.contract.json" +FROZEN_PRODUCT_CONTRACT_HASH = "5afd79586f82e31d0d673acbebebf0ac00804cff74b9f644f000bddfd3dc07d1" def product_contract_payload() -> dict[str, object]: @@ -157,7 +158,10 @@ def product_contract_hash() -> str: def load_product_contract() -> dict[str, object]: payload = product_contract_payload() - contract = {**payload, "contract_hash": sha256_json(payload)} + digest = sha256_json(payload) + if digest != FROZEN_PRODUCT_CONTRACT_HASH: + raise ValueError("embedded product contract hash does not match frozen v1 digest") + contract = {**payload, "contract_hash": digest} if PRODUCT_CONTRACT_PATH.is_file(): disk = json.loads(PRODUCT_CONTRACT_PATH.read_text(encoding="utf-8")) if disk != contract: diff --git a/fuckmark/transforms/fidelity_verification.py b/fuckmark/transforms/fidelity_verification.py index 40753295..bc9d1531 100644 --- a/fuckmark/transforms/fidelity_verification.py +++ b/fuckmark/transforms/fidelity_verification.py @@ -246,6 +246,7 @@ def source_verified_release_transform_registry( if len({value.rule.rule_hash for value in promotion_tuple}) != len(promotion_tuple): raise FidelityEvidenceVerificationError("lexical promotion rules must be unique") approved_carriers = product_approved_carriers_v1() + approved_rules: list[LexicalTemplateRule] = [] for promotion in promotion_tuple: identity_hash = promotion.model_tokenizer_identity.identity_hash try: @@ -257,4 +258,6 @@ def source_verified_release_transform_registry( raise FidelityEvidenceVerificationError( "lexical promotion changes user-visible text and cannot enter the product release registry" ) - return product_transform_registry(identifiers) + approved_rules.append(promotion.rule) + ordered = tuple(sorted(approved_rules, key=lambda value: (value.rule_id, value.version, value.rule_hash))) + return product_transform_registry(identifiers, rules=ordered) diff --git a/tests/test_bayesian_evidence.py b/tests/test_bayesian_evidence.py index 276ea4d1..ded78736 100644 --- a/tests/test_bayesian_evidence.py +++ b/tests/test_bayesian_evidence.py @@ -44,6 +44,8 @@ def test_bayesian_evidence_replays_posterior_and_stays_explicitly_unverified() - assert evidence.checkpoint_hash == checkpoint.checkpoint_hash assert evidence.observation_batch_hash == sha256_json(batch) verify_bayesian_evidence(evidence, batch, checkpoint) + with pytest.raises(ValueError, match="cannot be used as confirmatory score"): + evidence.confirmatory_score() def test_bayesian_evidence_replay_rejects_rehashed_wrong_posterior() -> None: diff --git a/tests/test_bayesian_fixture.py b/tests/test_bayesian_fixture.py index 93d5eb00..2b3d679e 100644 --- a/tests/test_bayesian_fixture.py +++ b/tests/test_bayesian_fixture.py @@ -102,3 +102,13 @@ def test_bayesian_checkpoint_schema_rejects_extra_or_missing_fields() -> None: data["extra"] = True with pytest.raises(ValueError, match="schema"): BayesianCheckpoint.from_mapping(data) + + +def test_load_bayesian_checkpoint_rejects_oversized_json(monkeypatch, tmp_path: Path) -> None: + from fuckmark.detectors import bayesian as bayesian_module + + monkeypatch.setattr(bayesian_module, "BAYESIAN_CHECKPOINT_JSON_MAX_BYTES", 8) + path = tmp_path / "oversized.json" + path.write_text('{"too": "large"}', encoding="utf-8") + with pytest.raises(ValueError, match="size limit"): + load_bayesian_checkpoint(path) diff --git a/tests/test_cli.py b/tests/test_cli.py index 092fef30..c7211582 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,29 +67,45 @@ def test_cli_reads_multiline_paste_until_ok_line() -> None: assert "Finish with :done on its own line" in output.getvalue() -def test_cli_main_processes_and_copies_unchanged_visible_text() -> None: +def test_cli_main_processes_unchanged_visible_text_without_copying() -> None: source = StringIO("I do not agree.\nok\n") output = StringIO() copied: list[str] = [] status = main(source, output, copied.append) assert status == 0 - assert copied == ["I do not agree."] + assert copied == [] rendered = output.getvalue() assert "FuckMark 0.2.0" in rendered assert "Processing..." in rendered assert "visible text left unchanged" in rendered - assert "Copied to clipboard." in rendered + assert "Copied to clipboard." not in rendered + assert "I do not agree." in rendered assert "I don't agree." not in rendered -def test_cli_main_copies_original_when_no_change_is_eligible() -> None: +def test_cli_main_leaves_original_when_no_change_is_eligible() -> None: source = StringIO("Already concise.\nok\n") output = StringIO() copied: list[str] = [] status = main(source, output, copied.append) assert status == 0 - assert copied == ["Already concise."] - assert "visible text left unchanged" in output.getvalue() + assert copied == [] + rendered = output.getvalue() + assert "Already concise." in rendered + assert "visible text left unchanged" in rendered + assert "Copied to clipboard." not in rendered + + +def test_cli_main_copies_only_with_copy_flag() -> None: + source = StringIO("I do not agree.\nok\n") + output = StringIO() + copied: list[str] = [] + status = main(source, output, copied.append, argv=("--copy",)) + assert status == 0 + assert copied == ["I do not agree."] + rendered = output.getvalue() + assert "Copied to clipboard." in rendered + assert "I don't agree." not in rendered def test_cli_main_prints_result_if_clipboard_copy_fails() -> None: @@ -100,7 +116,7 @@ def test_cli_main_prints_result_if_clipboard_copy_fails() -> None: def fail(_: str) -> None: raise RuntimeError("clipboard unavailable") - status = main(source, output, fail, error_stream=errors) + status = main(source, output, fail, error_stream=errors, argv=("--copy",)) assert status == 2 rendered = output.getvalue() assert "I do not agree." in rendered diff --git a/tests/test_install_release_hardening.py b/tests/test_install_release_hardening.py new file mode 100644 index 00000000..3ae7f0a1 --- /dev/null +++ b/tests/test_install_release_hardening.py @@ -0,0 +1,102 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +RELEASE_WORKFLOW = WORKFLOWS / "release-engineering.yml" +UNIX_INSTALLER = ROOT / "tools" / "install" / "unix.sh" +WINDOWS_INSTALLER = ROOT / "tools" / "install" / "windows.ps1" + + +def _workflow_run_scripts(text: str) -> list[str]: + scripts: list[str] = [] + lines = text.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + stripped = line.lstrip() + indent = len(line) - len(stripped) + if stripped.startswith("run:"): + remainder = stripped[4:].lstrip() + if remainder[:1] in {"|", ">"}: + body: list[str] = [] + index += 1 + while index < len(lines): + current = lines[index] + if current.strip() == "": + body.append(current) + index += 1 + continue + current_indent = len(current) - len(current.lstrip()) + if current_indent > indent: + body.append(current) + index += 1 + continue + break + scripts.append("\n".join(body)) + continue + scripts.append(remainder) + index += 1 + return scripts + + +def test_release_engineering_does_not_auto_tag_publish_or_delete_branches() -> None: + text = RELEASE_WORKFLOW.read_text(encoding="utf-8") + assert "git tag" not in text + assert "git push" not in text + assert "persist-credentials: true" not in text + assert "cleanup-merged" not in text + assert "/git/refs/heads" not in text + assert "publish_github_release" in text + assert "workflow_dispatch" in text + assert "github.event_name == 'workflow_dispatch'" in text + assert "inputs.publish_github_release == true" in text + + +def test_workflow_dispatch_inputs_are_not_interpolated_into_run_scripts() -> None: + workflows = sorted(WORKFLOWS.glob("*.yml")) + assert workflows + for path in workflows: + scripts = _workflow_run_scripts(path.read_text(encoding="utf-8")) + for script in scripts: + assert "${{ inputs." not in script, path + assert '""$INPUT_' not in script, path + + +def test_unix_installer_uses_tagged_release_checksum_and_does_not_start_cli() -> None: + text = UNIX_INSTALLER.read_text(encoding="utf-8") + assert UNIX_INSTALLER.stat().st_mode & 0o111 + assert "releases/download" in text + assert "SHA256SUMS" in text + assert "main.zip" not in text + assert "force-reinstall" not in text + assert not any(line.strip().startswith("sudo") for line in text.splitlines()) + assert "apt-get" not in text + assert text.count("-m fuckmark.cli") == 1 + assert 'Command: fuckmark --help' in text + assert not text.rstrip().endswith('-m fuckmark.cli') + + +def test_windows_installer_uses_tagged_release_checksum_and_does_not_start_cli() -> None: + text = WINDOWS_INSTALLER.read_text(encoding="utf-8") + assert "releases/download" in text + assert "SHA256SUMS" in text + assert "main.zip" not in text + assert "force-reinstall" not in text + assert "winget install" not in text + assert "Starting FuckMark" not in text + assert text.count("-m fuckmark.cli") == 1 + assert "Command: fuckmark --help" in text + + +def test_install_docs_do_not_recommend_live_main_or_pipe_installers() -> None: + for relative in ("README.md", "docs/install.md"): + text = (ROOT / relative).read_text(encoding="utf-8") + assert "archive/refs/heads/main.zip" not in text + assert "| sh" not in text + assert "|sh" not in text + assert "| iex" not in text + assert "irm https" not in text + assert "releases/download/v0.2.0" in text + assert "SHA256SUMS" in text + assert "3478f46511120d37e319aae3226eab07da0440927d1f117894539c1d181be8f2" in text diff --git a/tests/test_product_visible_invariance.py b/tests/test_product_visible_invariance.py index e76b2b63..29451413 100644 --- a/tests/test_product_visible_invariance.py +++ b/tests/test_product_visible_invariance.py @@ -6,7 +6,12 @@ from fuckmark.cycle7.whitespace_collapse import collapse_horizontal_ascii_whitespace from fuckmark.hashing import sha256_json from fuckmark.product.carriers import rule_preserves_visible_projection, space_carrier_rule -from fuckmark.product.contract import load_product_contract, product_contract_hash, product_contract_payload +from fuckmark.product.contract import ( + FROZEN_PRODUCT_CONTRACT_HASH, + load_product_contract, + product_contract_hash, + product_contract_payload, +) from fuckmark.product.domain import is_supported_product_domain_v1 from fuckmark.product.invariants import UserVisibleInvariantReason, validate_user_visible_invariants from fuckmark.product.registry import ProductTransformRegistry, product_transform_registry @@ -21,7 +26,7 @@ def test_product_contract_file_matches_embedded_payload() -> None: contract = load_product_contract() payload = {key: value for key, value in contract.items() if key != "contract_hash"} assert payload == product_contract_payload() - assert contract["contract_hash"] == product_contract_hash() == sha256_json(payload) + assert contract["contract_hash"] == product_contract_hash() == sha256_json(payload) == FROZEN_PRODUCT_CONTRACT_HASH path = Path(__file__).resolve().parents[1] / "specs" / "fuckmark-user-visible-invariance-v1.contract.json" disk = json.loads(path.read_text(encoding="utf-8")) assert disk == contract @@ -34,7 +39,7 @@ def test_load_product_contract_uses_embedded_payload_without_checkout_file(monke contract = contract_module.load_product_contract() payload = {key: value for key, value in contract.items() if key != "contract_hash"} assert payload == product_contract_payload() - assert contract["contract_hash"] == product_contract_hash() + assert contract["contract_hash"] == product_contract_hash() == FROZEN_PRODUCT_CONTRACT_HASH def test_load_product_contract_rejects_mismatched_checkout_file(monkeypatch, tmp_path: Path) -> None: @@ -47,6 +52,14 @@ def test_load_product_contract_rejects_mismatched_checkout_file(monkeypatch, tmp contract_module.load_product_contract() +def test_load_product_contract_rejects_unfrozen_embedded_hash(monkeypatch) -> None: + from fuckmark.product import contract as contract_module + + monkeypatch.setattr(contract_module, "FROZEN_PRODUCT_CONTRACT_HASH", "0" * 64) + with pytest.raises(ValueError, match="frozen v1 digest"): + contract_module.load_product_contract() + + def test_visible_projection_rejects_contractions_spaces_and_punctuation() -> None: cases = ( ("I do not agree.", "I don't agree."), diff --git a/tests/test_repository_hygiene.py b/tests/test_repository_hygiene.py index 412a4fa7..e5d9d865 100644 --- a/tests/test_repository_hygiene.py +++ b/tests/test_repository_hygiene.py @@ -24,9 +24,9 @@ def test_gitignore_excludes_generated_artifacts_and_local_secrets() -> None: ".venv/", ".env", ".env.*", - "spec.md", } assert required <= rules + assert "spec.md" not in rules def test_internal_release_metadata_is_absent() -> None: diff --git a/tests/test_source_grounded_lexical_release.py b/tests/test_source_grounded_lexical_release.py index df3cace6..fe538d14 100644 --- a/tests/test_source_grounded_lexical_release.py +++ b/tests/test_source_grounded_lexical_release.py @@ -98,6 +98,26 @@ def _promotion(human_audit=None): ) +def test_source_grounded_release_empty_promotions_stay_empty_product_registry() -> None: + registry = source_verified_release_transform_registry((), {}) + assert registry.rules == () + + +def test_source_grounded_release_keeps_verified_visible_preserving_rules(monkeypatch) -> None: + promotion = _promotion() + + def allow(*_args, **_kwargs) -> bool: + return True + + monkeypatch.setattr("fuckmark.product.carriers.rule_preserves_visible_projection", allow) + monkeypatch.setattr("fuckmark.product.registry.rule_preserves_visible_projection", allow) + registry = source_verified_release_transform_registry( + (promotion,), + {promotion.model_tokenizer_identity.identity_hash: _tokenizer}, + ) + assert registry.rules == (promotion.rule,) + + def test_source_grounded_release_rejects_visible_lexical_promotion() -> None: promotion = _promotion() summary = verify_lexical_promotion_evidence(promotion, _tokenizer) diff --git a/tools/install/unix.sh b/tools/install/unix.sh new file mode 100755 index 00000000..ab049a2b --- /dev/null +++ b/tools/install/unix.sh @@ -0,0 +1,128 @@ +#!/bin/sh +set -eu + +RELEASE_TAG="${FUCKMARK_RELEASE_TAG:-v0.2.0}" +PACKAGE_VERSION="${RELEASE_TAG#v}" +WHEEL_NAME="fuckmark-${PACKAGE_VERSION}-py3-none-any.whl" +RELEASE_BASE="https://github.com/byte271/FuckMark/releases/download/${RELEASE_TAG}" +ROOT="${FUCKMARK_HOME:-$HOME/.local/share/q1z/fuckmark}" +VENV="$ROOT/venv" +BIN="${FUCKMARK_BIN:-$HOME/.local/bin}" +STAGE="$ROOT/stage" + +find_python() { + for candidate in python3.13 python3.12 python3.11 python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + if "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + fi + done + return 1 +} + +ensure_path() { + shell_name="$(basename "${SHELL:-sh}")" + line='export PATH="$HOME/.local/bin:$PATH"' + case "$shell_name" in + zsh) + file="$HOME/.zshrc" + ;; + bash) + file="$HOME/.bashrc" + ;; + fish) + file="$HOME/.config/fish/config.fish" + mkdir -p "$(dirname "$file")" + touch "$file" + fish_line='fish_add_path "$HOME/.local/bin"' + grep -F "$fish_line" "$file" >/dev/null 2>&1 || printf '\n%s\n' "$fish_line" >> "$file" + return 0 + ;; + *) + file="$HOME/.profile" + ;; + esac + touch "$file" + grep -F "$line" "$file" >/dev/null 2>&1 || printf '\n%s\n' "$line" >> "$file" +} + +download() { + url="$1" + dest="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$dest" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$dest" "$url" + else + printf '%s\n' "curl or wget is required to download the release." + exit 1 + fi +} + +if [ "$(uname -s)" != "Linux" ] && [ "$(uname -s)" != "Darwin" ]; then + printf '%s\n' "This installer is for Linux and macOS." + exit 1 +fi + +printf '\n%s\n%s\n\n' "FuckMark" "Installing tagged release ${RELEASE_TAG} with SHA-256 verification." + +PYTHON="$(find_python || true)" +if [ -z "$PYTHON" ]; then + printf '%s\n' "Python 3.11 or newer is required. Install it yourself, then rerun." + printf '%s\n' "This installer does not use sudo and does not install Python." + exit 1 +fi + +mkdir -p "$ROOT" "$BIN" "$STAGE" +rm -f "$STAGE/$WHEEL_NAME" "$STAGE/SHA256SUMS.txt" + +printf '%s\n' "Downloading ${WHEEL_NAME} and SHA256SUMS.txt..." +download "$RELEASE_BASE/SHA256SUMS.txt" "$STAGE/SHA256SUMS.txt" +download "$RELEASE_BASE/$WHEEL_NAME" "$STAGE/$WHEEL_NAME" + +if [ ! -x "$VENV/bin/python" ]; then + rm -rf "$VENV" + "$PYTHON" -m venv "$VENV" +fi + +"$VENV/bin/python" - "$STAGE/SHA256SUMS.txt" "$STAGE/$WHEEL_NAME" <<'PY' +import hashlib +import pathlib +import sys + +sums_path = pathlib.Path(sys.argv[1]) +wheel_path = pathlib.Path(sys.argv[2]) +expected = None +for line in sums_path.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) != 2: + raise SystemExit("SHA256SUMS.txt line must contain digest and filename") + digest, name = parts + if name == wheel_path.name: + expected = digest.lower() + break +if expected is None: + raise SystemExit("wheel filename is missing from SHA256SUMS.txt") +actual = hashlib.sha256(wheel_path.read_bytes()).hexdigest() +if actual != expected: + raise SystemExit("wheel SHA-256 does not match SHA256SUMS.txt") +PY + +printf '%s\n' "Checksum matched. Installing the verified wheel..." +"$VENV/bin/python" -m pip install --disable-pip-version-check --no-cache-dir --upgrade "$STAGE/$WHEEL_NAME" + +cat > "$BIN/fuckmark" <= (3, 11) else 1)" 2>$null + if ($LASTEXITCODE -eq 0) { + return $Candidate + } + } + catch {} + } + return $null +} + +Write-Host "" +Write-Host "FuckMark" +Write-Host "Installing tagged release $ReleaseTag with SHA-256 verification." +Write-Host "" + +$Candidate = Find-Python +if (-not $Candidate) { + throw "Python 3.11 or newer is required. Install it yourself, then rerun. This installer does not use winget or administrator rights." +} + +New-Item -ItemType Directory -Force -Path $Root | Out-Null +New-Item -ItemType Directory -Force -Path $Bin | Out-Null +New-Item -ItemType Directory -Force -Path $Stage | Out-Null + +$SumsPath = Join-Path $Stage "SHA256SUMS.txt" +$WheelPath = Join-Path $Stage $WheelName +Invoke-WebRequest -Uri "$ReleaseBase/SHA256SUMS.txt" -OutFile $SumsPath +Invoke-WebRequest -Uri "$ReleaseBase/$WheelName" -OutFile $WheelPath + +if (-not (Test-Path $Python)) { + if (Test-Path $Venv) { + Remove-Item -Recurse -Force $Venv + } + & $Candidate.Exe @($Candidate.Args) -m venv $Venv + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $Python)) { + throw "Failed to create the FuckMark environment." + } +} + +$Verify = @" +import hashlib +import pathlib +import sys +sums_path = pathlib.Path(sys.argv[1]) +wheel_path = pathlib.Path(sys.argv[2]) +expected = None +for line in sums_path.read_text(encoding='utf-8').splitlines(): + parts = line.split() + if len(parts) != 2: + raise SystemExit('SHA256SUMS.txt line must contain digest and filename') + digest, name = parts + if name == wheel_path.name: + expected = digest.lower() + break +if expected is None: + raise SystemExit('wheel filename is missing from SHA256SUMS.txt') +actual = hashlib.sha256(wheel_path.read_bytes()).hexdigest() +if actual != expected: + raise SystemExit('wheel SHA-256 does not match SHA256SUMS.txt') +"@ +$VerifyPy = Join-Path $Stage "verify_checksum.py" +Set-Content -Path $VerifyPy -Value $Verify -Encoding utf8 +& $Python $VerifyPy $SumsPath $WheelPath +if ($LASTEXITCODE -ne 0) { + throw "Release checksum verification failed." +} + +Write-Host "Checksum matched. Installing the verified wheel..." +& $Python -m pip install --disable-pip-version-check --no-cache-dir --upgrade $WheelPath +if ($LASTEXITCODE -ne 0) { + throw "FuckMark installation failed." +} + +$LauncherBody = "@echo off`r`n`"$Python`" -m fuckmark.cli %*`r`n" +[IO.File]::WriteAllText($Launcher, $LauncherBody, [Text.Encoding]::ASCII) + +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +$Entries = @() +if ($UserPath) { + $Entries = $UserPath -split ";" | Where-Object { $_ } +} +if ($Entries -notcontains $Bin) { + [Environment]::SetEnvironmentVariable("Path", (($Entries + $Bin) -join ";"), "User") +} +if (($env:Path -split ";") -notcontains $Bin) { + $env:Path = "$Bin;$env:Path" +} + +Write-Host "" +Write-Host "FuckMark $PackageVersion installed." +Write-Host "The public CLI currently returns input text unchanged." +Write-Host "Command: fuckmark --help" +Write-Host "Open a new terminal if the command is not on PATH yet." +Write-Host "" From d8910e6c412da52c93e26d1bb2585f9280cb2e47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 22:48:02 +0000 Subject: [PATCH 2/2] Advance package identity to v0.3.0. Keep the public CLI on release-cli-v4 identity behavior and stop sharing the v0.2.0 contraction tag. Install docs and in-repo installers target the v0.3.0 GitHub Release wheel after that tag is published. Co-authored-by: Byte271 --- CHANGELOG.md | 3 ++- README.md | 14 +++++++++---- docs/cli.md | 2 +- docs/install.md | 22 +++++++++---------- docs/release.md | 28 ++++++++++--------------- fuckmark/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_cli.py | 5 +++-- tests/test_install_release_hardening.py | 6 ++++-- tests/test_project_identity.py | 6 +++--- tools/install/unix.sh | 2 +- tools/install/windows.ps1 | 2 +- uv.lock | 2 +- 13 files changed, 49 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983fa5b1..7e5ea480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -## Unreleased — exact user-visible invariance +## v0.3.0 — Visible invariance, install and release hardening +- Advanced the project version to 0.3.0 so the identity CLI is not published under the v0.2.0 contraction tag. - Restored Priority Zero: product output must keep the exact user-visible projection of the input. - Public CLI `release-cli-v4` fail-closes instead of applying contractions or other visible edits. - `release_transform_registry()` is the empty product-visible-invariance registry. Historical contraction/lexical/syntax/spacing catalogs remain replayable under explicit historical names. diff --git a/README.md b/README.md index c30e40d7..738f3515 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,16 @@ The public CLI currently fail-closes: no product-authorized invisible carrier ha See [`docs/product-contract.md`](docs/product-contract.md) and `specs/fuckmark-user-visible-invariance-v1.contract.json`. -**Current tagged release: v0.2.0** +**Current release: v0.3.0** Website: [mark.q1z.org](https://mark.q1z.org) License: MIT +## What changed in v0.3.0 + +v0.3.0 is the visible-invariance product release. The public CLI is `release-cli-v4` and returns input text unchanged. Official install uses a tagged GitHub Release wheel plus `SHA256SUMS.txt`. Release Engineering no longer auto-tags, auto-publishes, or deletes merged branches. + +The historical `v0.2.0` tag still applies contractions. Do not treat that tag as the current product contract. + ## What changed in v0.2.0 v0.2.0 promotes the Cycle 4 research infrastructure and the first confirmatory improvement from exact post-retokenization observation scheduling. @@ -54,10 +60,10 @@ Python 3.11 or newer is required. Install a **tagged GitHub Release wheel** and ```text python3 -m venv .venv -.venv/bin/python -m pip install https://github.com/byte271/FuckMark/releases/download/v0.2.0/fuckmark-0.2.0-py3-none-any.whl +.venv/bin/python -m pip install https://github.com/byte271/FuckMark/releases/download/v0.3.0/fuckmark-0.3.0-py3-none-any.whl ``` -v0.2.0 wheel SHA-256: `3478f46511120d37e319aae3226eab07da0440927d1f117894539c1d181be8f2` +Download `SHA256SUMS.txt` from the same release and confirm the wheel digest before trusting the environment. The installed CLI currently returns input text unchanged. See [`docs/install.md`](docs/install.md) for checksum verification, the in-repo installer, and Windows notes. @@ -149,7 +155,7 @@ The repository's GitHub Actions additionally run package installation checks, fr ## Release engineering -v0.2.0 builds one wheel and one source distribution and verifies both on Linux, macOS, and Windows before publication. The release workflow also runs `twine check`, verifies the installed console commands, generates SHA-256 checksums for the built artifacts, and refuses to publish a tag whose name does not match the package version. +v0.3.0 builds one wheel and one source distribution and verifies both on Linux, macOS, and Windows before publication. The release workflow also runs `twine check`, verifies the installed console commands, generates SHA-256 checksums for the built artifacts, and refuses to publish unless an existing `v*` tag already matches the package version and commit. GitHub Release publication happens only from an immutable `v*` tag after the package matrix succeeds. diff --git a/docs/cli.md b/docs/cli.md index d829a9b8..486de085 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # FuckMark CLI -FuckMark v0.2.0 still exposes the same command under `FuckMark`, `Fuckmark`, and `fuckmark`. Current `main` uses `release-cli-v4` and the product visible-invariance registry. +FuckMark v0.3.0 exposes the same command under `FuckMark`, `Fuckmark`, and `fuckmark`. The public CLI uses `release-cli-v4` and the product visible-invariance registry. The public CLI is intentionally smaller than the research harness. It does not load detector code, watermark keys, model weights, network services, Cycle 4/6/7 visible-edit schedulers, or quarantined U+200C diagnostics. It also does not apply contractions. diff --git a/docs/install.md b/docs/install.md index d14d0c5c..14a74389 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,20 +1,16 @@ # Installation -Python 3.11 or newer is required. The public CLI currently returns input text unchanged: v0.2.0 has no product-authorized invisible carrier. +Python 3.11 or newer is required. The public CLI currently returns input text unchanged: v0.3.0 has no product-authorized invisible carrier. Install only a GitHub Release wheel and check `SHA256SUMS.txt` from that same release. Do not pipe `https://d.q1z.org/mark` into a shell. That endpoint still installs live `main` without a checksum. ## Tagged wheel (recommended) -v0.2.0 wheel SHA-256: - -```text -3478f46511120d37e319aae3226eab07da0440927d1f117894539c1d181be8f2 fuckmark-0.2.0-py3-none-any.whl -``` +After the `v0.3.0` GitHub Release is published: ```text python3 -m venv .venv -.venv/bin/python -m pip install https://github.com/byte271/FuckMark/releases/download/v0.2.0/fuckmark-0.2.0-py3-none-any.whl +.venv/bin/python -m pip install https://github.com/byte271/FuckMark/releases/download/v0.3.0/fuckmark-0.3.0-py3-none-any.whl ``` On Windows, create the venv with `py -3.12 -m venv .venv` and use `.venv\Scripts\python.exe`. @@ -22,12 +18,14 @@ On Windows, create the venv with `py -3.12 -m venv .venv` and use `.venv\Scripts Download `SHA256SUMS.txt` from the same release and confirm the installed wheel digest before trusting the environment: ```text -https://github.com/byte271/FuckMark/releases/download/v0.2.0/SHA256SUMS.txt +https://github.com/byte271/FuckMark/releases/download/v0.3.0/SHA256SUMS.txt ``` +Until that release exists, install from a local clone as below. + ## In-repo installer (tagged wheel + checksum) -These scripts download the GitHub Release wheel, verify `SHA256SUMS.txt`, install into a user virtualenv, and print `fuckmark --help`. They do not start the CLI, do not use sudo, and do not install Python. +These scripts download the GitHub Release wheel, verify `SHA256SUMS.txt`, install into a user virtualenv, and print `fuckmark --help`. They do not start the CLI, do not use sudo, and do not install Python. They default to `v0.3.0`. Linux / macOS, from a clone: @@ -41,7 +39,7 @@ Windows PowerShell, from a clone: powershell -ExecutionPolicy Bypass -File tools/install/windows.ps1 ``` -Optional override: `FUCKMARK_RELEASE_TAG=v0.2.0`. +Optional override: `FUCKMARK_RELEASE_TAG=v0.3.0`. ## Verify @@ -49,10 +47,10 @@ Optional override: `FUCKMARK_RELEASE_TAG=v0.2.0`. fuckmark --version ``` -For v0.2.0 the command must begin with: +For v0.3.0 the command must begin with: ```text -FuckMark 0.2.0 +FuckMark 0.3.0 ``` It also reports `release-cli-v4`. Transforming text currently prints the same visible input. diff --git a/docs/release.md b/docs/release.md index adc246ce..2f16753b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,12 +1,12 @@ -# v0.2.0 Release Process +# v0.3.0 Release Process -FuckMark v0.2.0 is a deterministic CLI and research-infrastructure release. The published tag remains historically valid. Current `main` after the visible-invariance repair still reports package version `0.2.0`, but the public CLI algorithm identity is `release-cli-v4` and the release registry is the empty product-visible-invariance registry. Do not treat the v0.2.0 tag's contraction behavior as the current product contract. +FuckMark v0.3.0 is the visible-invariance product release. The public CLI algorithm identity is `release-cli-v4` and the release registry is the empty product-visible-invariance registry. The historical `v0.2.0` tag still exists and still applies contractions; do not treat that tag as the current product contract. The public CLI is restricted to product-authorized invisible transforms. None are authorized yet, so the CLI fail-closes to unchanged text. Cycle 4 exact-survival machinery remains in the research path. ## Release invariants -1. `pyproject.toml`, `fuckmark.__version__`, `uv.lock`, installed CLI output, and the Git tag must agree on `0.2.0` / `v0.2.0`. +1. `pyproject.toml`, `fuckmark.__version__`, `uv.lock`, installed CLI output, and the Git tag must agree on `0.3.0` / `v0.3.0`. 2. The release registry is not broadened merely because a development experiment succeeds. Product authorization additionally requires exact visible-projection equality. 3. U+200C remains diagnostic-only and is never promoted into automatic CLI behavior. 4. Visible-edit historical catalogs (contractions, Cycle 6 spacing, Cycle 7 durable families) remain replayable and must not silently become release defaults. @@ -16,18 +16,10 @@ The public CLI is restricted to product-authorized invisible transforms. None ar ## Required release sequence -1. Start from the latest green `main`. -2. Update the package version, lock metadata, tests, current documentation, and release notes on a dedicated release branch. -3. Run the complete test suite and `uv lock --check` through CI. -4. Build one wheel and one source distribution. -5. Run `twine check` on both distributions. -6. Clean-install and execute both distributions on Linux, macOS, and Windows. -7. Verify every installed console alias, `--version`, deterministic stream output, wheel metadata, and source-distribution metadata. -8. Generate `SHA256SUMS.txt` from the exact verified wheel and source distribution. -9. Merge the release branch only after required GitHub Actions are green. -10. On the resulting `main` push, rerun the cross-platform package matrix. That push must **not** create tags, publish a GitHub Release, or delete branches. -11. Create and push the immutable `v` tag yourself after the package matrix is green. The workflow never runs `git tag`. -12. Publish with `workflow_dispatch` and `publish_github_release=true` on that same commit. The job refuses to run unless the tag already exists and points at the dispatch SHA. If the GitHub Release is missing, it uploads the verified wheel and sdist. It does not delete merged branches. +1. Merge this v0.3.0 branch to green `main`. +2. On the resulting `main` push, rerun the cross-platform package matrix. That push must **not** create tags, publish a GitHub Release, or delete branches. +3. Create and push the immutable `v0.3.0` tag on that merge commit yourself. The workflow never runs `git tag`. Do not tag a pull-request SHA if `main` will be a squash merge. +4. Publish with `workflow_dispatch` and `publish_github_release=true` on that same commit. The job refuses to run unless `v0.3.0` already exists and points at the dispatch SHA. If the GitHub Release is missing, it uploads the verified wheel and sdist. It does not delete merged branches. ## Publication workflow @@ -53,10 +45,12 @@ The expanded pool plus scheduler v2 achieved 0/12 detected in its fresh developm ## Historical boundary -`specs/fuckmark-v0.1.0-release-readiness-baseline.json` remains an immutable historical artifact. v0.2.0 does not rewrite the old baseline's gate statuses or reinterpret the repository state it captured. +`specs/fuckmark-v0.1.0-release-readiness-baseline.json` remains an immutable historical artifact. v0.3.0 does not rewrite the old baseline's gate statuses or reinterpret the repository state it captured. Likewise, rejected experiments remain rejected records. The U+200C visible-projection mechanism stays quarantined because Unicode format-character stripping restores the evaluated detection behavior. Cycle 6 `NONZERO_RESIDUAL` 7/192 remains the frozen formal detector result and is product-disqualified because it used visible ASCII spaces. +The `v0.2.0` GitHub Release remains the historical contraction CLI. Current product behavior is v0.3.0 identity. + ## Distribution scope -The GitHub Release is the authoritative v0.2.0 publication produced by this repository workflow. Publishing to another package index is a separate action and must not be implied unless that publication actually occurs and its credentials/workflow are configured. +The GitHub Release is the authoritative v0.3.0 publication produced by this repository workflow. Publishing to another package index is a separate action and must not be implied unless that publication actually occurs and its credentials/workflow are configured. diff --git a/fuckmark/__init__.py b/fuckmark/__init__.py index c17fec65..ba06becc 100644 --- a/fuckmark/__init__.py +++ b/fuckmark/__init__.py @@ -1,5 +1,5 @@ __project_name__ = "FuckMark" -__version__ = "0.2.0" +__version__ = "0.3.0" from .adapters import ( DEEPMIND_REFERENCE_ADAPTER_ID, diff --git a/pyproject.toml b/pyproject.toml index d0e19031..5235b696 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fuckmark" -version = "0.2.0" +version = "0.3.0" description = "FuckMark deterministic research infrastructure for statistical text watermark robustness" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_cli.py b/tests/test_cli.py index c7211582..54146af9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ import pytest +from fuckmark import __version__ from fuckmark.cli import RELEASE_CLI_ALGORITHM_VERSION, main, process_text, read_pasted_text, transform_text from fuckmark.product.invariants import validate_user_visible_invariants from fuckmark.product.visible_projection import is_carrier_insertion_v1 @@ -75,7 +76,7 @@ def test_cli_main_processes_unchanged_visible_text_without_copying() -> None: assert status == 0 assert copied == [] rendered = output.getvalue() - assert "FuckMark 0.2.0" in rendered + assert f"FuckMark {__version__}" in rendered assert "Processing..." in rendered assert "visible text left unchanged" in rendered assert "Copied to clipboard." not in rendered @@ -129,7 +130,7 @@ def test_cli_version_reports_project_and_algorithm_identity(capsys) -> None: main(argv=("--version",)) assert result.value.code == 0 rendered = capsys.readouterr().out - assert "FuckMark 0.2.0" in rendered + assert f"FuckMark {__version__}" in rendered assert RELEASE_CLI_ALGORITHM_VERSION == "release-cli-v4" assert "release-cli-v4" in rendered assert "transform-registry-v6" in rendered diff --git a/tests/test_install_release_hardening.py b/tests/test_install_release_hardening.py index 3ae7f0a1..e4effbe5 100644 --- a/tests/test_install_release_hardening.py +++ b/tests/test_install_release_hardening.py @@ -68,6 +68,7 @@ def test_unix_installer_uses_tagged_release_checksum_and_does_not_start_cli() -> assert UNIX_INSTALLER.stat().st_mode & 0o111 assert "releases/download" in text assert "SHA256SUMS" in text + assert "v0.3.0" in text assert "main.zip" not in text assert "force-reinstall" not in text assert not any(line.strip().startswith("sudo") for line in text.splitlines()) @@ -81,6 +82,7 @@ def test_windows_installer_uses_tagged_release_checksum_and_does_not_start_cli() text = WINDOWS_INSTALLER.read_text(encoding="utf-8") assert "releases/download" in text assert "SHA256SUMS" in text + assert "v0.3.0" in text assert "main.zip" not in text assert "force-reinstall" not in text assert "winget install" not in text @@ -97,6 +99,6 @@ def test_install_docs_do_not_recommend_live_main_or_pipe_installers() -> None: assert "|sh" not in text assert "| iex" not in text assert "irm https" not in text - assert "releases/download/v0.2.0" in text + assert "releases/download/v0.3.0" in text + assert "fuckmark-0.3.0-py3-none-any.whl" in text assert "SHA256SUMS" in text - assert "3478f46511120d37e319aae3226eab07da0440927d1f117894539c1d181be8f2" in text diff --git a/tests/test_project_identity.py b/tests/test_project_identity.py index d80b5887..01404753 100644 --- a/tests/test_project_identity.py +++ b/tests/test_project_identity.py @@ -4,13 +4,13 @@ import fuckmark -def test_project_identity_is_fuckmark_v020() -> None: +def test_project_identity_is_fuckmark_v030() -> None: root = Path(__file__).resolve().parents[1] project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))["project"] assert project["name"] == "fuckmark" - assert project["version"] == "0.2.0" + assert project["version"] == "0.3.0" assert fuckmark.__project_name__ == "FuckMark" - assert fuckmark.__version__ == "0.2.0" + assert fuckmark.__version__ == "0.3.0" def test_legacy_project_identity_is_absent_outside_immutable_spec() -> None: diff --git a/tools/install/unix.sh b/tools/install/unix.sh index ab049a2b..246dae65 100755 --- a/tools/install/unix.sh +++ b/tools/install/unix.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -RELEASE_TAG="${FUCKMARK_RELEASE_TAG:-v0.2.0}" +RELEASE_TAG="${FUCKMARK_RELEASE_TAG:-v0.3.0}" PACKAGE_VERSION="${RELEASE_TAG#v}" WHEEL_NAME="fuckmark-${PACKAGE_VERSION}-py3-none-any.whl" RELEASE_BASE="https://github.com/byte271/FuckMark/releases/download/${RELEASE_TAG}" diff --git a/tools/install/windows.ps1 b/tools/install/windows.ps1 index df8a34ac..6bc9c0cc 100644 --- a/tools/install/windows.ps1 +++ b/tools/install/windows.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$ReleaseTag = if ($env:FUCKMARK_RELEASE_TAG) { $env:FUCKMARK_RELEASE_TAG } else { "v0.2.0" } +$ReleaseTag = if ($env:FUCKMARK_RELEASE_TAG) { $env:FUCKMARK_RELEASE_TAG } else { "v0.3.0" } $PackageVersion = $ReleaseTag.TrimStart("v") $WheelName = "fuckmark-$PackageVersion-py3-none-any.whl" $ReleaseBase = "https://github.com/byte271/FuckMark/releases/download/$ReleaseTag" diff --git a/uv.lock b/uv.lock index 39dec284..5c31498f 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "fuckmark" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } [package.optional-dependencies]