Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e2c23c3
fix(security): exclude PVE priv credential files when BACKUP_PVE_ACL …
tis24dev Jun 11, 2026
6b90500
fix(security): exclude PBS access-control secrets when BACKUP_USER_CO…
tis24dev Jun 11, 2026
ac51302
fix(backup): make pre-archive optimizations restore-safe (#70 #71 #72)
tis24dev Jun 11, 2026
98d2072
fix(backup): surface PBS datastore discovery failures + cover legacy …
tis24dev Jun 11, 2026
aae3b0e
fix(backup): populate system_files manifest at collection-target gran…
tis24dev Jun 11, 2026
11f4a9d
fix(security): remove plaintext backup workspace on run completion in…
tis24dev Jun 11, 2026
a4dd2c8
fix(security): validate /tmp/proxsave root and contain temp-registry …
tis24dev Jun 11, 2026
1a96d50
fix(backup): prune staging workspace from CUSTOM_BACKUP_PATHS source …
tis24dev Jun 11, 2026
8dff582
fix(restore): make collected OS accounts, cron-periodic and LUKS keyf…
tis24dev Jun 11, 2026
edd21f4
fix(config): wire the documented SYSTEM_ROOT_PREFIX override end-to-e…
tis24dev Jun 11, 2026
1a4f7be
fix(backup): exclude .git and runtime dirs from script-repo snapshot …
tis24dev Jun 12, 2026
deb01fd
fix(restore): rebuild deduplicated files from the archive instead of …
tis24dev Jun 12, 2026
83af2f5
fix(backup): correct the reported compression ratio for the bytes rec…
tis24dev Jun 12, 2026
48b0c57
fix(security): derive passphrase AGE recipient with a per-installatio…
tis24dev Jun 14, 2026
31ae5d5
fix(restore): report PBS Clean 1:1 "with warnings" when a stale objec…
tis24dev Jun 14, 2026
b04ec63
fix(restore): surface a failed PVE firewall restart at the commit pro…
tis24dev Jun 14, 2026
72477ff
fix(backup): notify + emit metrics on early-init failures; skip benig…
tis24dev Jun 14, 2026
ae23ea4
fix(notify): redact notifier tokens/secret-URLs from logs and errors …
tis24dev Jun 14, 2026
3f0f1a1
fix(restore): refuse cross-category hardlinks during selective restor…
tis24dev Jun 15, 2026
43d07b6
fix(backup): fail closed on incomplete archives instead of shipping t…
tis24dev Jun 15, 2026
52e908f
docs(config): disambiguate bracketed kernel-style process allowlists
tis24dev Jun 17, 2026
2c5fc1f
deps(deps): bump the minor-updates group with 3 updates (#235)
dependabot[bot] Jun 18, 2026
5628ac9
ci(release): adopt immutable-tag-safe governed release pipeline
tis24dev Jun 20, 2026
549d08a
fix(orchestrator): rename identifiers to clear gosec G101 false posit…
tis24dev Jun 20, 2026
a8629cf
fix(restore,backup,checks): address CodeRabbit findings on the v0.25.…
tis24dev Jun 20, 2026
d222d7a
test(backup,orchestrator): address CodeRabbit test-quality nits on PR…
tis24dev Jun 20, 2026
f407fe5
fix(orchestrator): write the account DB files all-or-nothing (CodeRab…
tis24dev Jun 20, 2026
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
134 changes: 134 additions & 0 deletions .github/scripts/release-policy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env bash

# Shared helpers for the release pipeline (release-intake / release-guard /
# post-merge-release). This project derives its version purely from the git tag
# at build time via GoReleaser ldflags (internal/version.Version), so there is NO
# in-repo version file to bump and NO manifest assertions here.

# Keep this regex identical to the SemVer check in release.yml so the two never
# disagree (allows vX.Y.Z and prereleases like vX.Y.Z-rc1 / vX.Y.Z-beta1).
RELEASE_TAG_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
# Unprotected trigger tag that starts a release. It deliberately does NOT match
# the v* glob (release.yml / a tag-immutability ruleset), so it can be created and
# deleted freely; the real vX.Y.Z tag is CREATED ONCE on the squash commit by
# post-merge-release (a tag creation, which a tag-immutability ruleset allows).
PR_TAG_REGEX='^pr-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'

die() {
echo "::error::$*" >&2
exit 1
}

notice() {
echo "::notice::$*"
}

is_release_tag() {
[[ "${1:-}" =~ ${RELEASE_TAG_REGEX} ]]
}

validate_release_tag() {
local tag="${1:-}"

if ! is_release_tag "${tag}"; then
die "Invalid release tag '${tag}'. Allowed formats are vX.Y.Z and vX.Y.Z-rc1/-beta1."
fi
}

is_pr_tag() {
[[ "${1:-}" =~ ${PR_TAG_REGEX} ]]
}

# pr-vX.Y.Z -> vX.Y.Z (the release tag that will be CREATED at merge).
release_tag_from_pr_tag() {
local pr_tag="${1:-}"

if ! is_pr_tag "${pr_tag}"; then
die "Invalid trigger tag '${pr_tag}'. Expected pr-vX.Y.Z (or pr-vX.Y.Z-rc1/-beta1)."
fi
printf '%s\n' "${pr_tag#pr-}"
}

# Informational only: GoReleaser's `release.prerelease: auto` already marks
# prereleases from the -suffix, so nothing branches on this.
is_prerelease_tag() {
[[ "${1:-}" == *-* ]]
}

# Success if the given commit is contained in (ancestor of, or equal to)
# origin/main. The caller MUST `git fetch origin main` first.
tag_commit_on_main() {
git merge-base --is-ancestor "${1:-}" origin/main
}

extract_pr_marker() {
local marker="${1:-}"

python3 - "${marker}" <<'PY'
import os
import re
import sys

marker = sys.argv[1]
body = os.environ.get("PR_BODY", "")
pattern = rf"^<!-- {re.escape(marker)}: ([^<\n]+) -->$"
match = re.search(pattern, body, re.MULTILINE)
if not match:
sys.exit(1)
print(match.group(1).strip())
PY
}

delete_remote_tag() {
local tag="${1:-}"

git push origin ":refs/tags/${tag}" || true
}

# Existence probes that DISTINGUISH absent from error. A transient auth/network
# failure must never be silently read as "does not exist" (which would defeat the
# preflight / immutability gates). Echo: present|absent|error.

remote_tag_state() {
local tag="${1:-}"
local rc=0
# git ls-remote --exit-code: 0 = ref found, 2 = no matching ref, other = failure.
git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1 || rc=$?
case "${rc}" in
0) echo "present" ;;
2) echo "absent" ;;
*) echo "error" ;;
esac
}

remote_release_state() {
local tag="${1:-}"
local out
local rc=0
out="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1)" || rc=$?
if [[ "${rc}" -eq 0 ]]; then
echo "present"
elif printf '%s' "${out}" | grep -qi 'HTTP 404\|Not Found'; then
echo "absent"
else
echo "error"
fi
}

# Hard gates: die on "present" AND on "error" (fail closed). Used where the only
# acceptable state to proceed is a confirmed "absent".
assert_release_tag_absent() {
local tag="${1:-}"
case "$(remote_tag_state "${tag}")" in
present) die "Tag ${tag} already exists and is immutable." ;;
error) die "Could not determine whether tag ${tag} exists (git ls-remote failed); aborting." ;;
esac
}

assert_release_absent() {
local tag="${1:-}"
case "$(remote_release_state "${tag}")" in
present) die "Release ${tag} already exists." ;;
error) die "Could not determine whether release ${tag} exists (gh api failed); aborting." ;;
esac
}
116 changes: 0 additions & 116 deletions .github/workflows/autotag.yml

This file was deleted.

110 changes: 110 additions & 0 deletions .github/workflows/post-merge-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Post Merge Release

on:
pull_request:
branches:
- main
types:
- closed

permissions:
contents: write
pull-requests: read

concurrency:
group: post-merge-release
cancel-in-progress: false

env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}

jobs:
finalize-release:
name: finalize-release
if: >
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.event.pull_request.head.ref == 'dev' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_BOT_TOKEN || github.token }}

- name: Finalize release
shell: bash
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail
source .github/scripts/release-policy.sh

HAS_RELEASE_MARKER="false"
TAG=""

if TAG="$(extract_pr_marker release-tag)"; then
HAS_RELEASE_MARKER="true"
validate_release_tag "${TAG}"
else
notice "No release marker found; syncing dev to the squash commit without publishing a release."
fi

git fetch --force origin dev main

MAIN_SHA="$(git rev-parse origin/main)"
DEV_SHA="$(git rev-parse origin/dev)"

[[ "${MERGE_SHA}" == "${MAIN_SHA}" ]] || die "main (${MAIN_SHA}) does not point to the PR merge commit (${MERGE_SHA})."

PARENT_COUNT="$(git show -s --format=%P "${MERGE_SHA}" | wc -w | tr -d ' ')"
[[ "${PARENT_COUNT}" == "1" ]] || die "The PR was not squash-merged; refusing to create a release."

# Content gate that tolerates dev advancing during review: the squash
# commit on main must capture EXACTLY the current dev tree. We compare
# trees, not commit shas, so review fix commits (which rewrite history
# and change the head sha) are fine as long as the released content
# equals dev. If dev carries extra content the merge does not include,
# we refuse to clobber it and ask for a manual reconcile.
MERGE_TREE="$(git rev-parse "${MERGE_SHA}^{tree}")"
DEV_TREE="$(git rev-parse "origin/dev^{tree}")"
[[ "${MERGE_TREE}" == "${DEV_TREE}" ]] || die "origin/dev content differs from the merged release; refusing to overwrite dev. Reconcile dev with main manually."

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# Fast-forward dev onto the squash commit. This REPLACES the old
# sync-dev.yml (which did a destructive `reset --hard` + bare `--force`).
# Lease on the dev we just observed so a genuine concurrent push aborts
# instead of being silently lost. Runs for both release and maintenance
# (no-marker) PRs.
git push origin "${MERGE_SHA}:refs/heads/dev" --force-with-lease="refs/heads/dev:${DEV_SHA}"

if [[ "${HAS_RELEASE_MARKER}" != "true" ]]; then
notice "dev synchronized to ${MERGE_SHA}; no release marker was present."
exit 0
fi

# The vX.Y.Z tag is immutable (a tag-immutability ruleset blocks update +
# deletion), so it must NOT exist yet: we only ever CREATE it, once, here.
# Fail closed: a transient auth/network error aborts rather than being
# misread as "absent" (which would let us try to release a stale state).
assert_release_absent "${TAG}"
assert_release_tag_absent "${TAG}"

# Create the authoritative ANNOTATED tag ONCE on the squash commit and push
# it with a PLAIN (non-force) push = a tag CREATION, which the ruleset
# allows. We never run `git tag -f`, force-push, or delete a v* tag. Pushing
# the tag (via RELEASE_BOT_TOKEN, a PAT) re-triggers release.yml, whose gate
# now passes (tag is on main) and GoReleaser builds + signs + publishes the
# GitHub release.
git tag -a "${TAG}" -m "Release ${TAG}" "${MERGE_SHA}"
git push origin "refs/tags/${TAG}"

notice "Tag ${TAG} created on squash commit ${MERGE_SHA}; release.yml will now cut the release."
Loading
Loading