Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions .github/scripts/notify-publish-safety.rb
Original file line number Diff line number Diff line change
Expand Up @@ -616,10 +616,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.0", "release version must remain 2.0.0")
assert_policy(spec.fetch("tag") == "notify-v2.0.0", "release tag must remain notify-v2.0.0")
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("image") == "ghcr.io/psimaker/vaultsync-notify", "image repository changed")
assert_policy(spec.fetch("version_image") == "ghcr.io/psimaker/vaultsync-notify:2.0.0",
assert_policy(spec.fetch("version_image") == "ghcr.io/psimaker/vaultsync-notify:2.0.1",
"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")
Expand All @@ -639,9 +639,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.0"),
assert_policy(install_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.1"),
"installer default is not the reviewed version tag")
assert_policy(compose_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.0"),
assert_policy(compose_text.include?("ghcr.io/psimaker/vaultsync-notify:2.0.1"),
"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"),
Expand All @@ -656,7 +656,16 @@ def resume_action(existing_digest, expected_digest, mutable:)
install_text.include?("non-canonical checksum"),
"binary installer no longer fails closed on checksum prerequisites")

security_text = flattened_step_text(security.fetch("jobs").fetch("image-scan"))
security_image_scan = security.fetch("jobs").fetch("image-scan")
security_text = flattened_step_text(security_image_scan)
security_resolve_step = steps(security_image_scan).find do |step|
step["name"] == "Resolve the published helper digest"
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" &&
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")
assert_policy(security_text.include?("vaultsync-notify@${{ steps.release-image.outputs.digest }}"),
"scheduled scan must use the exact digest")
Expand All @@ -668,6 +677,13 @@ def resume_action(existing_digest, expected_digest, mutable:)
security_text.include?("^sha256:[0-9a-f]{64}$") &&
security_text.include?("printf 'digest=%s\\n'"),
"scheduled scan must export exactly one canonical image index digest")
assert_policy(security_text.include?("releases/tags/${TARGET_RELEASE_TAG}") &&
security_text.include?("HTTP 404") &&
security_text.include?("release_tag=$FALLBACK_RELEASE_TAG") &&
security_text.include?(".draft == false") &&
security_text.include?(".prerelease == false") &&
security_text.include?("releases/tags/${release_tag}"),
"scheduled scan must fall back only while the reviewed release is not public")

ci_notify_steps = steps(ci.fetch("jobs").fetch("notify-tests"))
assert_policy(ci_notify_steps.any? { |step| step["run"] == "ruby .github/scripts/notify-publish-safety.rb" },
Expand All @@ -692,7 +708,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.1"),
base.merge(release_tag: "notify-v2.0.0"),
base.merge(confirmation: "publish"),
base.merge(actor: "maintainer"),
base.merge(triggering_actor: "maintainer"),
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1428,7 +1428,7 @@ jobs:

Helper-first rollout

The real explicit installer passed published 1.8.0 -> 2.0.0 -> 1.8.0 -> the same 2.0.0 digest on a standard Linux rootful-Docker runner. Credential-state bytes and the TLS SPKI pin survived rollback and forward recovery. The proof also records the known 1.8.0 endpoint-log behavior and requires 2.0.0 to emit none of the configured test credential, identifier, path, or URL values. See ROLLOUT-EVIDENCE.txt.
The real explicit installer passed published 1.8.0 -> ${RELEASE_VERSION} -> 1.8.0 -> the same ${RELEASE_VERSION} digest on a standard Linux rootful-Docker runner. Credential-state bytes and the TLS SPKI pin survived rollback and forward recovery. The proof also records the known 1.8.0 endpoint-log behavior and requires ${RELEASE_VERSION} to emit none of the configured test credential, identifier, path, or URL values. See ROLLOUT-EVIDENCE.txt.

Compatibility and limits

Expand Down
37 changes: 35 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,44 @@ jobs:
id: release-image
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: notify-v2.0.0
TARGET_RELEASE_TAG: notify-v2.0.1
FALLBACK_RELEASE_TAG: notify-v2.0.0
run: |
set -euo pipefail
mkdir -p /tmp/vaultsync-notify-release
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \

target_json=/tmp/vaultsync-notify-release/target-release.json
target_error=/tmp/vaultsync-notify-release/target-release.error
if gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/${TARGET_RELEASE_TAG}" \
> "$target_json" 2> "$target_error"; then
if jq -e --arg tag "$TARGET_RELEASE_TAG" \
'.tag_name == $tag and .draft == false and .prerelease == false' \
"$target_json" > /dev/null; then
release_tag=$TARGET_RELEASE_TAG
elif jq -e '.draft == true or .prerelease == true' \
"$target_json" > /dev/null; then
release_tag=$FALLBACK_RELEASE_TAG
else
echo "Target helper release metadata is not canonical" >&2
exit 1
fi
elif grep -Fq 'HTTP 404' "$target_error"; then
release_tag=$FALLBACK_RELEASE_TAG
else
echo "Target helper release lookup failed" >&2
exit 1
fi

selected_json=/tmp/vaultsync-notify-release/selected-release.json
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" \
> "$selected_json"
jq -e --arg tag "$release_tag" \
'.tag_name == $tag and .draft == false and .prerelease == false' \
"$selected_json" > /dev/null
echo "Scanning immutable helper release ${release_tag}"

gh release download "$release_tag" --repo "$GITHUB_REPOSITORY" \
--pattern IMAGE-DIGESTS --dir /tmp/vaultsync-notify-release
mapfile -t digests < <(
sed -n 's/^index_digest=//p' /tmp/vaultsync-notify-release/IMAGE-DIGESTS
Expand Down
42 changes: 22 additions & 20 deletions docs/helper-publication-rollout.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Helper 2.0.0 publication and helper-first rollout
# Helper 2.0.1 publication and helper-first rollout

This document defines the owner-gated publication, compatibility, rollback,
monitoring, and recovery contract for `vaultsync-notify` 2.0.0. It does not by
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
`RELEASE-MANIFEST.json`, the exact workflow run, and the registry digest are the
canonical post-publication evidence.
Expand All @@ -15,9 +17,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.0` and tag `notify-v2.0.0`;
- version `2.0.1` and tag `notify-v2.0.1`;
- image repository `ghcr.io/psimaker/vaultsync-notify`, with the only new tag
`2.0.0`;
`2.0.1`;
- five expected binaries for Linux amd64/arm64, macOS amd64/arm64, and Windows
amd64;
- the previous public rollback tag, commit, and multi-platform image digest;
Expand Down Expand Up @@ -51,13 +53,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.0`; 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.1`; 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.0 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.1 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
Expand All @@ -79,8 +81,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.0 → published 1.8.0 → the same candidate
2.0.0.
published 1.8.0 → candidate 2.0.1 → published 1.8.0 → the same candidate
2.0.1.
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
Expand All @@ -105,14 +107,14 @@ 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.0, 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.0 explicitly configured | Existing old Relay | Helper capability exists locally, but the old app never calls it. Upload, download, and roundtrip remain unset. |
| 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. |
| 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.0, unconfigured/unpaired/unauthorized | Any supported Relay v1 | Honest unavailable/unsupported response; no fallback evidence and no automatic action. |
| Future capable app | Helper 2.0.0, exact explicit pairing and namespace authorization | Existing old Relay | The local D022–D024 helper contract may be used. Relay remains outside upload/download/roundtrip correlation. |
| Future capable app | Helper 2.0.0 | Future new Relay | Same local diagnostics contract; Relay version does not strengthen sync evidence. |
| Any app | Helper 2.0.0 → 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.0 → 1.8.0 → exact 2.0.0 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.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. |

The five downloadable binaries do not expand diagnostics packaging support.
Docker Host-Bind on a standard Linux host with rootful Docker remains the only
Expand All @@ -131,7 +133,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.0 sensitive-log exclusion, or
TLS-pin preservation, mount constraints, 2.0.1 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.
Expand All @@ -153,12 +155,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.0` version tag,
must explicitly rerun the installer, select the reviewed `2.0.1` 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.0 behaves as the prior
Without both diagnostics configuration paths, 2.0.1 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,
Expand All @@ -174,8 +176,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.0 digest when the abort cause is
cleared; 2.0.0's rollout gate rejects those configured values in candidate
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
logs.

## Evidence boundary after helper rollout
Expand Down
10 changes: 8 additions & 2 deletions docs/helper-runtime-packaging-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,14 @@ set either diagnostics environment variable and do not activate the runtime.
and separate private state directory, then starts the capable helper with no
namespace mount.
3. Run `diagnostics-docker.sh pair`. The one-time QR value is emitted only to
that terminal. Pairing remains pending until every D022 signed step and the
explicit app fingerprint comparison complete.
that terminal. After the app has authenticated the type-`2` acceptance, run
`diagnostics-docker.sh list` in the same operator-controlled environment.
The exact pending row includes `transcript=<12 uppercase hex characters>`;
compare it with the app before approving type `3`. The transcript value is
emitted only by this explicit local command and must not be redirected into
service logs or support bundles. Pairing remains pending until every D022
signed step and this exact comparison complete. Active rows deliberately no
longer expose a transcript value.
4. After the app sends a signed D023 enablement request, run
`diagnostics-docker.sh enable` with the exact canonical folder host path and
explicit supported-host confirmation. The command displays the exact
Expand Down
Loading
Loading