From 011e49da2a836fdd0e6adbea76a4d91c9a93e3cc Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Tue, 14 Jul 2026 20:54:42 +0200 Subject: [PATCH] fix(ci): recover helper publication DAG Make the tag-bound binary attestation job survive the intentionally skipped recovery branch while still requiring every upstream safety gate to succeed. Advance the unused release path to 2.0.2, retain the partial 2.0.1 image immutably as non-release evidence, and keep 2.0.0 as the scheduled-scan fallback and 1.8.0 as rollback. --- .github/scripts/notify-publish-safety.rb | 20 +++++--- .github/workflows/docker.yml | 7 ++- .github/workflows/security.yml | 2 +- docs/helper-publication-rollout.md | 54 +++++++++++--------- docs/relay-spec.md | 2 +- notify/README.md | 4 +- notify/docker-compose.yml | 2 +- notify/release.json | 6 +-- notify/scripts/install.sh | 2 +- notify/scripts/tests/install-dry-run-test.sh | 4 +- 10 files changed, 61 insertions(+), 42 deletions(-) diff --git a/.github/scripts/notify-publish-safety.rb b/.github/scripts/notify-publish-safety.rb index 817a7f4..e2390f4 100644 --- a/.github/scripts/notify-publish-safety.rb +++ b/.github/scripts/notify-publish-safety.rb @@ -544,6 +544,12 @@ def resume_action(existing_digest, expected_digest, mutable:) "image selector must require both mutually exclusive image paths") assert_policy(jobs.fetch("attest-binaries").fetch("needs").include?("image-ready"), "normal binary attestation must follow immutable image selection") +attest_binaries_condition = jobs.fetch("attest-binaries").fetch("if") +assert_policy(attest_binaries_condition.include?("always()") && + %w[publish-safety-policy notify-guard publish-gate image-ready].all? { |name| + attest_binaries_condition.include?("needs.#{name}.result == 'success'") + }, + "normal binary attestation must survive the intentionally skipped recovery path") assert_policy((%w[image-ready attest-binaries] - jobs.fetch("binary-attestation-ready").fetch("needs")).empty?, "binary attestation selector must require image selection and the tag-only attestation job") @@ -616,10 +622,10 @@ def resume_action(existing_digest, expected_digest, mutable:) "publish gate must fail closed and export the finalized public-release state") assert_policy(spec.fetch("format_version") == 1, "release manifest format changed") -assert_policy(spec.fetch("version") == "2.0.1", "release version must remain 2.0.1") -assert_policy(spec.fetch("tag") == "notify-v2.0.1", "release tag must remain notify-v2.0.1") +assert_policy(spec.fetch("version") == "2.0.2", "release version must remain 2.0.2") +assert_policy(spec.fetch("tag") == "notify-v2.0.2", "release tag must remain notify-v2.0.2") assert_policy(spec.fetch("image") == "ghcr.io/psimaker/vaultsync-notify", "image repository changed") -assert_policy(spec.fetch("version_image") == "ghcr.io/psimaker/vaultsync-notify:2.0.1", +assert_policy(spec.fetch("version_image") == "ghcr.io/psimaker/vaultsync-notify:2.0.2", "version image changed") assert_policy(spec.fetch("binaries") == EXPECTED_BINARIES, "expected binary set or order changed") assert_policy(spec.fetch("release_assets").sort == EXPECTED_ASSETS.sort, "release asset set changed") @@ -639,9 +645,9 @@ def resume_action(existing_digest, expected_digest, mutable:) install_text = File.read(INSTALL_PATH) compose_text = File.read(COMPOSE_PATH) -assert_policy(install_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.1"), +assert_policy(install_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.2"), "installer default is not the reviewed version tag") -assert_policy(compose_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.1"), +assert_policy(compose_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.2"), "Compose default is not the reviewed version tag") assert_policy(!install_text.include?("ghcr.io/psimaker/vaultsync-notify:latest") && !compose_text.include?("ghcr.io/psimaker/vaultsync-notify:latest"), @@ -663,7 +669,7 @@ def resume_action(existing_digest, expected_digest, mutable:) end assert_policy(!security_resolve_step.nil?, "scheduled scan release resolver is missing") security_release_env = security_resolve_step.fetch("env", {}) -assert_policy(security_release_env.fetch("TARGET_RELEASE_TAG", nil) == "notify-v2.0.1" && +assert_policy(security_release_env.fetch("TARGET_RELEASE_TAG", nil) == "notify-v2.0.2" && security_release_env.fetch("FALLBACK_RELEASE_TAG", nil) == "notify-v2.0.0", "scheduled scan must use the reviewed target and last public fallback releases") assert_policy(security_text.include?("IMAGE-DIGESTS"), "scheduled scan must resolve the release digest") @@ -708,7 +714,7 @@ def resume_action(existing_digest, expected_digest, mutable:) base.merge(recovery_run_id: "29324314809"), base.merge(ref_type: "branch", ref_name: "main", recovery_run_id: "0"), base.merge(ref_type: "branch", ref_name: "main", recovery_run_id: "failed-run"), - base.merge(release_tag: "notify-v2.0.0"), + base.merge(release_tag: "notify-v2.0.1"), base.merge(confirmation: "publish"), base.merge(actor: "maintainer"), base.merge(triggering_actor: "maintainer"), diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 366d305..10c69de 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -615,13 +615,18 @@ jobs: attest-binaries: name: Attest Release Binaries if: >- + always() && github.event_name == 'workflow_dispatch' && github.ref_type == 'tag' && inputs.release_tag == github.ref_name && inputs.recovery_run_id == '' && inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && github.actor == github.repository_owner && - github.triggering_actor == github.repository_owner + github.triggering_actor == github.repository_owner && + needs.publish-safety-policy.result == 'success' && + needs.notify-guard.result == 'success' && + needs.publish-gate.result == 'success' && + needs.image-ready.result == 'success' needs: - publish-safety-policy - notify-guard diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2606e8a..02ce795 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -126,7 +126,7 @@ jobs: id: release-image env: GH_TOKEN: ${{ github.token }} - TARGET_RELEASE_TAG: notify-v2.0.1 + TARGET_RELEASE_TAG: notify-v2.0.2 FALLBACK_RELEASE_TAG: notify-v2.0.0 run: | set -euo pipefail diff --git a/docs/helper-publication-rollout.md b/docs/helper-publication-rollout.md index 92a52c8..8bf11e5 100644 --- a/docs/helper-publication-rollout.md +++ b/docs/helper-publication-rollout.md @@ -1,10 +1,17 @@ -# Helper 2.0.1 publication and helper-first rollout +# Helper 2.0.2 publication and helper-first rollout This document defines the owner-gated publication, compatibility, rollback, -monitoring, and recovery contract for `vaultsync-notify` 2.0.1. This patch -supersedes 2.0.0 because that release did not expose D022's pending transcript -fingerprint to the local operator; no 2.0.0 artifact or tag is changed. It does not by -itself claim that publication ran. The GitHub release, its +monitoring, and recovery contract for `vaultsync-notify` 2.0.2. This patch +supersedes 2.0.1 after tag-bound workflow run `29358780832` published only the +scanned and attested image index +`sha256:2c0f620fa1ce934ae75b358a60935460d244b19d4e98718728dd0338e0b24229`. +GitHub Actions then propagated the intentionally skipped recovery path past the +normal binary-attestation job, so no binaries, GitHub release, helper-first +rollout, or public verification were produced. The 2.0.1 tag and image remain +unchanged and are not completion evidence. Version 2.0.1 had superseded 2.0.0 +because 2.0.0 did not expose D022's pending transcript fingerprint to the local +operator; no 2.0.0 artifact or tag is changed. This document does not by itself +claim that 2.0.2 publication ran. The GitHub release, its `RELEASE-MANIFEST.json`, the exact workflow run, and the registry digest are the canonical post-publication evidence. @@ -17,9 +24,9 @@ upload, download, or roundtrip product evidence follows from publication. The reviewed source manifest is [`../notify/release.json`](../notify/release.json). It fixes: -- version `2.0.1` and tag `notify-v2.0.1`; +- version `2.0.2` and tag `notify-v2.0.2`; - image repository `ghcr.io/psimaker/vaultsync-notify`, with the only new tag - `2.0.1`; + `2.0.2`; - five expected binaries for Linux amd64/arm64, macOS amd64/arm64, and Windows amd64; - the previous public rollback tag, commit, and multi-platform image digest; @@ -53,13 +60,13 @@ repository security setting requires separate authorization. | Artifact | Required proof | |---|---| -| OCI index for Linux amd64 and arm64 | Exact index and platform SHA-256 digests; source/version labels; embedded `vaultsync-notify 2.0.1`; BuildKit provenance/SBOM plus GitHub repository-bound provenance and SPDX SBOM attestations. | +| OCI index for Linux amd64 and arm64 | Exact index and platform SHA-256 digests; source/version labels; embedded `vaultsync-notify 2.0.2`; BuildKit provenance/SBOM plus GitHub repository-bound provenance and SPDX SBOM attestations. | | Five static binaries | Rebuilt twice byte-identically from the tag commit, listed in `SHA256SUMS`, scanned, and covered by GitHub provenance and SPDX SBOM attestations. | | `SHA256SUMS` | Exact checksum for each binary; installers fail closed if this asset or a local SHA-256 implementation is unavailable. | | `SBOM.spdx.json` | SPDX 2.3 inventory for the release binary set. | | `IMAGE-DIGESTS` | Image repository, version tag, OCI index digest, and Linux amd64/arm64 manifest digests. | | `RELEASE-MANIFEST.json` | Release version/tag, exact source commit, image digest, rollback baseline, binary sizes/digests, SBOM digest, and expected asset set. The exact workflow run is recorded separately in rollout evidence. | -| `ROLLOUT-EVIDENCE.txt` | Exact old/new references and the supported-host upgrade, rollback, and forward-recovery results, including the observed legacy 1.8.0 endpoint-log boundary and the 2.0.1 sensitive-log assertion. | +| `ROLLOUT-EVIDENCE.txt` | Exact old/new references and the supported-host upgrade, rollback, and forward-recovery results, including the observed legacy 1.8.0 endpoint-log boundary and the 2.0.2 sensitive-log assertion. | The image runtime is a static scratch image built from a digest-pinned Go builder. Its CA bundle is copied from that same pinned builder. There is no @@ -81,8 +88,8 @@ Publication is staged in this order: release without replacing any asset. 4. On a fresh GitHub-hosted standard Linux runner using rootful Docker, execute the real explicit diagnostics installer against only digest references: - published 1.8.0 → candidate 2.0.1 → published 1.8.0 → the same candidate - 2.0.1. + published 1.8.0 → candidate 2.0.2 → published 1.8.0 → the same candidate + 2.0.2. 5. Require the old helper to keep diagnostics unavailable, the new helper to expose the TLS listener, the rollback to preserve credential-state bytes, and forward recovery to preserve both those bytes and the TLS SPKI pin. The @@ -107,14 +114,15 @@ Diagnostics is additive and opt-in; Trigger v1 and Relay v1 remain unchanged. | App | Helper | Relay | Result | |---|---|---|---| | Released old app | Published old helper 1.8.0 | Existing old Relay | Existing Trigger-v1 behavior only. | -| Released old app | Published helper 2.0.1, diagnostics unset | Existing old Relay | Byte-compatible Trigger-v1 behavior. No listener, credential, namespace, trust, mapping, or diagnostics artifact is created. | -| Released old app | Helper 2.0.1 explicitly configured | Existing old Relay | Helper capability exists locally, but the old app never calls it. Upload, download, and roundtrip remain unset. | +| Any app | Partial image-only helper 2.0.1 | Any Relay v1 | Aborted publication state: no GitHub release, binaries, helper-first rollout, or public verification. The immutable image is retained for audit but is not a supported helper release. | +| Released old app | Published helper 2.0.2, diagnostics unset | Existing old Relay | Byte-compatible Trigger-v1 behavior. No listener, credential, namespace, trust, mapping, or diagnostics artifact is created. | +| Released old app | Helper 2.0.2 explicitly configured | Existing old Relay | Helper capability exists locally, but the old app never calls it. Upload, download, and roundtrip remain unset. | | Future capable app | Published old helper 1.8.0 | Any supported Relay v1 | Honest capability unavailable. The app must not pair, create, trust, or transfer. | -| Future capable app | Helper 2.0.1, unconfigured/unpaired/unauthorized | Any supported Relay v1 | Honest unavailable/unsupported response; no fallback evidence and no automatic action. | -| Future capable app | Helper 2.0.1, exact explicit pairing and namespace authorization | Existing old Relay | The local D022–D024 helper contract may be used only after the explicit local transcript comparison. Relay remains outside upload/download/roundtrip correlation. | -| Future capable app | Helper 2.0.1 | Future new Relay | Same local diagnostics contract; Relay version does not strengthen sync evidence. | -| Any app | Helper 2.0.1 → 1.8.0 | Any Relay v1 | Diagnostics becomes unavailable. Credentials, namespace content, mappings, backups, versions, conflicts, and tombstones are not deleted or rewritten. The rollback also restores 1.8.0's legacy configured-endpoint startup log fields; operators must apply their existing log-access controls. | -| Any app | Helper 2.0.1 → 1.8.0 → exact 2.0.1 digest | Any Relay v1 | Forward recovery revalidates preserved state and requires current exact credentials/authorization. No operation resumes automatically. | +| Future capable app | Helper 2.0.2, unconfigured/unpaired/unauthorized | Any supported Relay v1 | Honest unavailable/unsupported response; no fallback evidence and no automatic action. | +| Future capable app | Helper 2.0.2, exact explicit pairing and namespace authorization | Existing old Relay | The local D022–D024 helper contract may be used only after the explicit local transcript comparison. Relay remains outside upload/download/roundtrip correlation. | +| Future capable app | Helper 2.0.2 | Future new Relay | Same local diagnostics contract; Relay version does not strengthen sync evidence. | +| Any app | Helper 2.0.2 → 1.8.0 | Any Relay v1 | Diagnostics becomes unavailable. Credentials, namespace content, mappings, backups, versions, conflicts, and tombstones are not deleted or rewritten. The rollback also restores 1.8.0's legacy configured-endpoint startup log fields; operators must apply their existing log-access controls. | +| Any app | Helper 2.0.2 → 1.8.0 → exact 2.0.2 digest | Any Relay v1 | Forward recovery revalidates preserved state and requires current exact credentials/authorization. No operation resumes automatically. | The five downloadable binaries do not expand diagnostics packaging support. Docker Host-Bind on a standard Linux host with rootful Docker remains the only @@ -133,7 +141,7 @@ Publication aborts before the GitHub release becomes public on any of: - missing/extra release asset, binary non-reproducibility, checksum mismatch, or image architecture/version/label mismatch; - failure of upgrade, rollback, forward recovery, state-byte preservation, - TLS-pin preservation, mount constraints, 2.0.1 sensitive-log exclusion, or + TLS-pin preservation, mount constraints, 2.0.2 sensitive-log exclusion, or exact installer use. The separately asserted legacy endpoint fields from the immutable 1.8.0 rollback image are a documented rollback boundary, not candidate evidence. @@ -155,12 +163,12 @@ or user-vault progress. Existing installs are not migrated, paired, restarted, or reconfigured by publication. The historical `latest` image tag is not moved. A user or operator -must explicitly rerun the installer, select the reviewed `2.0.1` version tag, +must explicitly rerun the installer, select the reviewed `2.0.2` version tag, or pull the documented digest. The installer re-resolves the version tag and runs the resulting local content ID; a network failure does not silently reuse a stale tag. -Without both diagnostics configuration paths, 2.0.1 behaves as the prior +Without both diagnostics configuration paths, 2.0.2 behaves as the prior Trigger-v1 helper and creates no diagnostics state. With explicit configuration, pairing, namespace enablement, and later app operations remain separate signed actions. No helper publication discovers Syncthing, changes its configuration, @@ -176,8 +184,8 @@ delivery, or global health. Emergency rollback also returns to 1.8.0's existing startup logging of configured endpoint values. It does not log the API key in this proof, but endpoint values can still be private deployment metadata. Restrict old-helper -log access and forward-recover to the exact 2.0.1 digest when the abort cause is -cleared; 2.0.1's rollout gate rejects those configured values in candidate +log access and forward-recover to the exact 2.0.2 digest when the abort cause is +cleared; 2.0.2's rollout gate rejects those configured values in candidate logs. ## Evidence boundary after helper rollout diff --git a/docs/relay-spec.md b/docs/relay-spec.md index 7c2af4e..34c4a0a 100644 --- a/docs/relay-spec.md +++ b/docs/relay-spec.md @@ -1,6 +1,6 @@ # VaultSync Cloud Relay — Specification -> **Status:** Cloud Relay 1.3.0 and the matching app support are implemented and locally verified, but the Relay production rollout and VaultSync 2.0 app release are still pending. Once deployed, Relay 1.3 provisioning requires a verified StoreKit signed transaction and gives exact pre-existing legacy registrations a bounded compatibility window through October 31, 2026. Helper publication state is determined only by the newest public `notify-v*` release and its exact manifest; `notify-v1.8.0` remains the fixed rollback baseline for helper 2.0.1. Even a published capable helper provides no app upload, download, or correlated-roundtrip evidence until the separate app milestones succeed. A Relay-observed signal proves only accepted Relay processing: not helper identity, APNs delivery, background start, local data progress, upload, download, or a roundtrip. Existing Relay v1 provisioning, trigger, and push contracts remain unchanged. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client. +> **Status:** Cloud Relay 1.3.0 and the matching app support are implemented and locally verified, but the Relay production rollout and VaultSync 2.0 app release are still pending. Once deployed, Relay 1.3 provisioning requires a verified StoreKit signed transaction and gives exact pre-existing legacy registrations a bounded compatibility window through October 31, 2026. Helper publication state is determined only by the newest public `notify-v*` release and its exact manifest; `notify-v1.8.0` remains the fixed rollback baseline for helper 2.0.2. Even a published capable helper provides no app upload, download, or correlated-roundtrip evidence until the separate app milestones succeed. A Relay-observed signal proves only accepted Relay processing: not helper identity, APNs delivery, background start, local data progress, upload, download, or a roundtrip. Existing Relay v1 provisioning, trigger, and push contracts remain unchanged. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client. ## Overview diff --git a/notify/README.md b/notify/README.md index 06ca532..f14bdd6 100644 --- a/notify/README.md +++ b/notify/README.md @@ -72,7 +72,7 @@ docker run -d --name vaultsync-notify --restart unless-stopped \ -v /PATH/TO/syncthing:/config:ro \ -e SYNCTHING_CONFIG=/config/config.xml \ -e RELAY_URL=https://relay.vaultsync.eu \ - ghcr.io/psimaker/vaultsync-notify:2.0.1 + ghcr.io/psimaker/vaultsync-notify:2.0.2 ``` Replace `/PATH/TO/syncthing` with your Syncthing config folder (often `~/.local/state/syncthing` or `~/.config/syncthing`). Permission error? Add `-u :` for the user that owns `config.xml`. @@ -224,7 +224,7 @@ Validates: Syncthing API reachable · API key valid · Device ID readable · rel **Runtime healthcheck** — the image's `HEALTHCHECK` runs `vaultsync-notify --healthcheck`, validating real readiness (Syncthing API, credentials, Device ID, relay health), not just process liveness. Peer state is deliberately excluded here: a legitimately offline peer must never flip the container to unhealthy. -**Version** — `vaultsync-notify --version` prints the installed helper version (needs no configuration). The installer shows old → new on upgrades. Docker installs pull the reviewed `2.0.1` tag and run only its resolved local content ID; failed pulls do not fall back to a stale tag. Binary installs select the newest published `notify-v*` release, require its `SHA256SUMS`, replace the binary, and restart the service. Future Docker upgrades require another reviewed version tag or an explicit `VAULTSYNC_NOTIFY_IMAGE` override. +**Version** — `vaultsync-notify --version` prints the installed helper version (needs no configuration). The installer shows old → new on upgrades. Docker installs pull the reviewed `2.0.2` tag and run only its resolved local content ID; failed pulls do not fall back to a stale tag. Binary installs select the newest published `notify-v*` release, require its `SHA256SUMS`, replace the binary, and restart the service. Future Docker upgrades require another reviewed version tag or an explicit `VAULTSYNC_NOTIFY_IMAGE` override. **Diagnostics pairing comparison** — after the app has authenticated the pending helper acceptance, run `diagnostics-docker.sh list` locally. The diff --git a/notify/docker-compose.yml b/notify/docker-compose.yml index 7f70052..c17e697 100644 --- a/notify/docker-compose.yml +++ b/notify/docker-compose.yml @@ -16,7 +16,7 @@ services: restart: unless-stopped vaultsync-notify: - image: ghcr.io/psimaker/vaultsync-notify:2.0.1 + image: ghcr.io/psimaker/vaultsync-notify:2.0.2 # Run as the same uid/gid that owns Syncthing's config.xml. config.xml is mode # 0600, so a mismatched uid cannot read the API key off the shared volume — even # read-only. The 1000:1000 default fits the OFFICIAL syncthing/syncthing image; diff --git a/notify/release.json b/notify/release.json index f49fc41..4bec556 100644 --- a/notify/release.json +++ b/notify/release.json @@ -1,9 +1,9 @@ { "format_version": 1, - "version": "2.0.1", - "tag": "notify-v2.0.1", + "version": "2.0.2", + "tag": "notify-v2.0.2", "image": "ghcr.io/psimaker/vaultsync-notify", - "version_image": "ghcr.io/psimaker/vaultsync-notify:2.0.1", + "version_image": "ghcr.io/psimaker/vaultsync-notify:2.0.2", "rollback": { "tag": "notify-v1.8.0", "commit": "e4f9e3088d7b7bc47943ff59db73de369c16c543", diff --git a/notify/scripts/install.sh b/notify/scripts/install.sh index 21caf1a..dc03685 100755 --- a/notify/scripts/install.sh +++ b/notify/scripts/install.sh @@ -35,7 +35,7 @@ set -eu RELAY_URL="${RELAY_URL:-https://relay.vaultsync.eu}" MODE="${VAULTSYNC_NOTIFY_MODE:-auto}" -IMAGE="${VAULTSYNC_NOTIFY_IMAGE:-ghcr.io/psimaker/vaultsync-notify:2.0.1}" +IMAGE="${VAULTSYNC_NOTIFY_IMAGE:-ghcr.io/psimaker/vaultsync-notify:2.0.2}" REPO="psimaker/vaultsync" CONTAINER_NAME="vaultsync-notify" DRY_RUN=0 diff --git a/notify/scripts/tests/install-dry-run-test.sh b/notify/scripts/tests/install-dry-run-test.sh index 9245713..3a535c4 100755 --- a/notify/scripts/tests/install-dry-run-test.sh +++ b/notify/scripts/tests/install-dry-run-test.sh @@ -117,8 +117,8 @@ grep "would run: docker run -d" "$OUT_DOCKER" | grep -q -- "-u $EXPECTED_OWNER " # silently keeps the old local version-tag target. grep -q 'would run: docker pull ' "$OUT_DOCKER" \ || fail "docker mode: no docker pull before start — a re-run would keep the old image (#87)" -grep -q 'would run: docker pull ghcr.io/psimaker/vaultsync-notify:2.0.1' "$OUT_DOCKER" \ - || fail "docker mode: default image is not the reviewed 2.0.1 tag" +grep -q 'would run: docker pull ghcr.io/psimaker/vaultsync-notify:2.0.2' "$OUT_DOCKER" \ + || fail "docker mode: default image is not the reviewed 2.0.2 tag" grep -q 'runtime_image=[$]new_image_id' "$INSTALL_SH" \ || fail "docker mode: the successful pull is not converted to an immutable local image ID" if grep -q 'continuing with the LOCAL image' "$INSTALL_SH"; then