Skip to content

Release v1.4.4

Release v1.4.4 #28

Workflow file for this run

name: Release
run-name: Release ${{ github.ref_name }}
"on":
push:
tags:
- "v*"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
prepare-candidate:
name: Build and inventory unsigned candidate
runs-on: macos-15
timeout-minutes: 60
permissions:
actions: read
contents: read
env:
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Require immutable signed-tag workflow context
run: |
set -euo pipefail
test "${GITHUB_EVENT_NAME}" = "push"
test "${GITHUB_RUN_ATTEMPT}" = "1"
test "${GITHUB_REF_TYPE}" = "tag"
test "${GITHUB_REF_NAME}" = "${RELEASE_TAG}"
test "${GITHUB_REF}" = "refs/tags/${RELEASE_TAG}"
[[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Check out signed release tag without credentials
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: refs/tags/${{ github.ref_name }}
fetch-depth: 0
persist-credentials: false
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Configure SwiftPM build path
run: echo "SWIFT_BUILD_PATH=${RUNNER_TEMP}/vifty-release-swiftpm-build" >> "${GITHUB_ENV}"
- name: Validate candidate version and signed tag
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
VERSION="${TAG#v}"
BUNDLE_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)"
if [[ "${TAG}" == "${VERSION}" ]]; then
echo "Release tag must start with v, e.g. v1.3.3" >&2
exit 1
fi
if [[ "${VERSION}" != "${BUNDLE_VERSION}" ]]; then
echo "Release version ${VERSION} does not match CFBundleShortVersionString ${BUNDLE_VERSION}" >&2
exit 1
fi
git fetch --no-tags origin "${GITHUB_SHA}"
TRUSTED_SIGNERS="${RUNNER_TEMP}/vifty-release-signers.allowed"
PARENT_SIGNERS="${RUNNER_TEMP}/vifty-release-parent-signers.allowed"
git show "${GITHUB_SHA}:.github/release-signers.allowed" > "${TRUSTED_SIGNERS}"
git show "${GITHUB_SHA}^:.github/release-signers.allowed" > "${PARENT_SIGNERS}"
cmp -s "${TRUSTED_SIGNERS}" "${PARENT_SIGNERS}"
chmod 444 "${TRUSTED_SIGNERS}"
TAG_OBJECT="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{tag}")"
test "$(git rev-parse --verify "${TAG_OBJECT}^{commit}")" = "${GITHUB_SHA}"
git -c gpg.format=ssh \
-c gpg.ssh.program=/usr/bin/ssh-keygen \
-c gpg.ssh.allowedSignersFile="${TRUSTED_SIGNERS}" \
verify-tag "${TAG_OBJECT}"
scripts/check-release-prep-diff.sh \
--root "${GITHUB_WORKSPACE}" \
--commit "${GITHUB_SHA}"
scripts/check-release-manifest.sh \
--publication-version "${VERSION}" \
--base-ref "${GITHUB_SHA}^" \
--require-base
echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
- name: Verify remote ancestry and exact-commit CI provenance
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
RELEASE_GH_TOKEN="${GH_TOKEN:?}"
unset GH_TOKEN GITHUB_TOKEN
GH_TOKEN="${RELEASE_GH_TOKEN}" scripts/check-release-provenance.sh \
--tag "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--main-ref "${GITHUB_SHA}" \
--allowed-signers "${RUNNER_TEMP}/vifty-release-signers.allowed" \
--require-current-governance-freshness \
--json > "${RUNNER_TEMP}/vifty-release-admission-provenance.json"
ruby -rjson -e '
provenance = JSON.parse(File.read(ARGV.fetch(0)))
actor = provenance.dig("administratorGovernanceEvidence", "authenticatedActor")
abort("signed governance actor does not match tag-push actor") unless
actor.is_a?(Hash) &&
actor["id"].to_s == ARGV.fetch(1) &&
actor["login"] == ARGV.fetch(2)
' "${RUNNER_TEMP}/vifty-release-admission-provenance.json" \
"${GITHUB_ACTOR_ID}" "${GITHUB_ACTOR}"
chmod 444 "${RUNNER_TEMP}/vifty-release-admission-provenance.json"
unset RELEASE_GH_TOKEN
- name: Validate release metadata and workflow contract
run: |
scripts/validate-release-metadata.sh --mode developer-id
scripts/render-release-facts.sh --check
ruby scripts/check-workflow-contract.rb
- name: Lint workflows with pinned actionlint
run: scripts/run-actionlint.sh
- name: Run full read-only build and test gate
run: make verify-full SWIFT_BUILD_PATH="${SWIFT_BUILD_PATH}"
- name: Assemble ad-hoc candidate with release TeamID policy
run: |
set -euo pipefail
TEAM_ID="$(ruby -rjson -e 'print JSON.parse(File.read(ARGV[0])).dig("releasePolicy", "developerTeamID")' .github/release-manifest.json)"
make app \
CONFIGURATION=release \
SWIFT_BUILD_PATH="${SWIFT_BUILD_PATH}" \
SIGNING_IDENTITY="-" \
VIFTY_XPC_ALLOWED_TEAM_ID="${TEAM_ID}"
codesign --verify --deep --strict .build/Vifty.app
- name: Inventory unsigned candidate
run: |
set -euo pipefail
INPUT_DIR=".build/release-input"
CANDIDATE_APP=".build/release-candidate/Vifty.app"
rm -rf "${INPUT_DIR}"
mkdir -p "${INPUT_DIR}"
ditto .build/Vifty.app "${INPUT_DIR}/Vifty.app"
cp "${RUNNER_TEMP}/vifty-release-admission-provenance.json" \
"${INPUT_DIR}/release-admission-provenance.json"
(
cd "${INPUT_DIR}"
ditto -c -k --keepParent --norsrc --noextattr --noqtn --noacl \
Vifty.app Vifty-unsigned-candidate.zip
/usr/bin/ruby "${GITHUB_WORKSPACE}/scripts/release-candidate-inventory.rb" create \
--app Vifty.app \
--archive Vifty-unsigned-candidate.zip \
--supplemental release-admission-provenance.json \
--output candidate-inventory.json
rm -rf Vifty.app
)
- name: Upload hash-inventoried unsigned candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: vifty-candidate-${{ github.run_id }}
path: |
.build/release-input/Vifty-unsigned-candidate.zip
.build/release-input/candidate-inventory.json
.build/release-input/release-admission-provenance.json
if-no-files-found: error
retention-days: 1
overwrite: true
sign-notarize:
name: Sign and notarize inventoried candidate
needs: prepare-candidate
if: ${{ github.run_attempt == 1 }}
runs-on: macos-15
timeout-minutes: 25
environment: release
permissions:
actions: read
contents: read
env:
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Check out trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.sha }}
path: .build/trusted-release-source
fetch-depth: 0
persist-credentials: false
- name: Inventory trusted release tooling
run: |
set -euo pipefail
TRUSTED_ROOT=".build/trusted-release-source"
test "$(git -C "${TRUSTED_ROOT}" rev-parse HEAD)" = "${GITHUB_SHA}"
(
cd "${TRUSTED_ROOT}"
trusted_status="$(git status --porcelain=v1 --untracked-files=all)"
test -z "${trusted_status}"
git ls-files -z | xargs -0 shasum -a 256 \
> "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
test -s "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
)
chmod 444 "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
- name: Verify release environment protection
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
RELEASE_GH_TOKEN="${GH_TOKEN:?}"
unset GH_TOKEN GITHUB_TOKEN
TRUSTED_ROOT="${GITHUB_WORKSPACE}/.build/trusted-release-source"
(
cd "${TRUSTED_ROOT}"
trusted_status="$(git status --porcelain=v1 --untracked-files=all)"
test -z "${trusted_status}"
shasum -a 256 -c "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
)
VIFTY_WORKFLOW_CONTRACT_ROOT="${TRUSTED_ROOT}" \
ruby "${TRUSTED_ROOT}/scripts/check-workflow-contract.rb"
(
cd "${TRUSTED_ROOT}"
GH_TOKEN="${RELEASE_GH_TOKEN}" \
"${TRUSTED_ROOT}/scripts/check-release-environment.sh" \
--repo "${GITHUB_REPOSITORY}" \
--environment release \
--branch main \
--workflow-public \
--expected-branch-sha "${GITHUB_SHA}" \
--output "${RUNNER_TEMP}/vifty-release-environment-readback.json"
)
unset RELEASE_GH_TOKEN
- name: Download inventoried candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: vifty-candidate-${{ github.run_id }}
path: .build/release-input
- name: Verify candidate and trusted tool inventories before secret-consuming steps
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
RELEASE_GH_TOKEN="${GH_TOKEN:?}"
unset GH_TOKEN GITHUB_TOKEN
TRUSTED_ROOT="${GITHUB_WORKSPACE}/.build/trusted-release-source"
(
cd "${TRUSTED_ROOT}"
trusted_status="$(git status --porcelain=v1 --untracked-files=all)"
test -z "${trusted_status}"
shasum -a 256 -c "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
)
INPUT_DIR="${GITHUB_WORKSPACE}/.build/release-input"
EXTRACT_DIR="${GITHUB_WORKSPACE}/.build/release-candidate"
rm -rf "${EXTRACT_DIR}"
mkdir -p "${EXTRACT_DIR}"
/usr/bin/ruby "${TRUSTED_ROOT}/scripts/release-candidate-inventory.rb" extract \
--handoff-dir "${INPUT_DIR}" \
--inventory "${INPUT_DIR}/candidate-inventory.json" \
--extract-to "${EXTRACT_DIR}"
"${TRUSTED_ROOT}/scripts/check-release-prep-diff.sh" \
--root "${TRUSTED_ROOT}" \
--commit "${GITHUB_SHA}"
VIFTY_RELEASE_MANIFEST_ROOT="${TRUSTED_ROOT}" \
VIFTY_RELEASE_MANIFEST_BASE_REF="${GITHUB_SHA}^" \
VIFTY_REQUIRE_RELEASE_MANIFEST_BASE=1 \
VIFTY_RELEASE_SOURCE_REPOSITORY_ROOT="${TRUSTED_ROOT}" \
bash "${TRUSTED_ROOT}/scripts/check-release-manifest.sh" \
--publication-version "${RELEASE_TAG#v}" \
--require-base
git -C "${TRUSTED_ROOT}" fetch --force origin \
"refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
GH_TOKEN="${RELEASE_GH_TOKEN}" \
VIFTY_RELEASE_PROVENANCE_ROOT="${TRUSTED_ROOT}" \
VIFTY_RELEASE_MANIFEST_BASE_REF="${GITHUB_SHA}^" \
VIFTY_REQUIRE_RELEASE_MANIFEST_BASE=1 \
VIFTY_RELEASE_SOURCE_REPOSITORY_ROOT="${TRUSTED_ROOT}" \
"${TRUSTED_ROOT}/scripts/check-release-provenance.sh" \
--tag "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--main-ref "${GITHUB_SHA}" \
--trusted-workflow-ref "${GITHUB_SHA}" \
--manifest "${TRUSTED_ROOT}/.github/release-manifest.json" \
--allowed-signers "${TRUSTED_ROOT}/.github/release-signers.allowed" \
--json
unset RELEASE_GH_TOKEN
if /usr/bin/plutil -convert json -o - -- "${EXTRACT_DIR}/Vifty.app/Contents/Library/LaunchDaemons/tech.reidar.vifty.daemon.plist" | ruby -rjson -e '
data = JSON.parse(STDIN.read)
keys = Hash(data["EnvironmentVariables"]).keys.grep(/\AVIFTY_XPC_ADHOC_/)
exit(keys.empty? ? 1 : 0)
'; then
echo "Release candidate must not contain VIFTY_XPC_ADHOC_* development keys" >&2
exit 1
fi
- name: Require signing and notarization secrets
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
DEVELOPER_ID_APPLICATION_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_IDENTITY }}
DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }}
DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
for name in \
APPLE_TEAM_ID \
APPLE_ID \
APPLE_APP_SPECIFIC_PASSWORD \
DEVELOPER_ID_APPLICATION_IDENTITY \
DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 \
DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD
do
if [[ -z "${!name}" ]]; then
echo "Missing required release secret: ${name}" >&2
exit 1
fi
done
MANIFEST_TEAM_ID="$(ruby -rjson -e 'print JSON.parse(File.read(ARGV[0])).dig("releasePolicy", "developerTeamID")' .build/trusted-release-source/.github/release-manifest.json)"
test "${APPLE_TEAM_ID}" = "${MANIFEST_TEAM_ID}"
- name: Import Developer ID certificate
env:
CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
umask 077
KEYCHAIN_PASSWORD="$(uuidgen)"
KEYCHAIN_PATH="${RUNNER_TEMP}/vifty-release.keychain-db"
CERTIFICATE_PATH="${RUNNER_TEMP}/developer-id-application.p12"
echo "${CERTIFICATE_BASE64}" | base64 --decode > "${CERTIFICATE_PATH}"
chmod 600 "${CERTIFICATE_PATH}"
security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}"
chmod 600 "${KEYCHAIN_PATH}"
security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}"
security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}"
security import "${CERTIFICATE_PATH}" -k "${KEYCHAIN_PATH}" -P "${CERTIFICATE_PASSWORD}" -T /usr/bin/codesign -T /usr/bin/security
security list-keychains -d user -s "${KEYCHAIN_PATH}" $(security list-keychains -d user | tr -d '"')
security set-key-partition-list -S apple-tool:,apple: -s -k "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}"
echo "KEYCHAIN_PATH=${KEYCHAIN_PATH}" >> "${GITHUB_ENV}"
echo "CERTIFICATE_PATH=${CERTIFICATE_PATH}" >> "${GITHUB_ENV}"
- name: Revalidate trusted tooling and sign existing candidate
env:
SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
TRUSTED_ROOT="${GITHUB_WORKSPACE}/.build/trusted-release-source"
(
cd "${TRUSTED_ROOT}"
trusted_status="$(git status --porcelain=v1 --untracked-files=all)"
test -z "${trusted_status}"
shasum -a 256 -c "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
)
CANDIDATE_APP="${GITHUB_WORKSPACE}/.build/release-candidate/Vifty.app"
/usr/bin/ruby "${TRUSTED_ROOT}/scripts/release-candidate-inventory.rb" verify-tree \
--app "${CANDIDATE_APP}" \
--inventory "${GITHUB_WORKSPACE}/.build/release-input/candidate-inventory.json"
bash "${TRUSTED_ROOT}/scripts/sign-release-candidate.sh" \
--app "${CANDIDATE_APP}" \
--manifest "${TRUSTED_ROOT}/.github/release-manifest.json" \
--entitlements "${TRUSTED_ROOT}/Resources/Vifty.entitlements" \
--identity "${SIGNING_IDENTITY}" \
--team-id "${APPLE_TEAM_ID}" \
--keychain "${KEYCHAIN_PATH}"
- name: Notarize signed candidate
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
CANDIDATE_APP=".build/release-candidate/Vifty.app"
NOTARY_ZIP="${RUNNER_TEMP}/Vifty-${VERSION}-notary.zip"
ditto -c -k --keepParent --norsrc --noextattr --noqtn --noacl \
"${CANDIDATE_APP}" "${NOTARY_ZIP}"
xcrun notarytool submit "${NOTARY_ZIP}" \
--apple-id "${APPLE_ID}" \
--team-id "${APPLE_TEAM_ID}" \
--password "${APPLE_APP_SPECIFIC_PASSWORD}" \
--wait
xcrun stapler staple "${CANDIDATE_APP}"
xcrun stapler validate "${CANDIDATE_APP}"
/usr/sbin/spctl --assess --type execute --verbose "${CANDIDATE_APP}"
- name: Create and verify release assets with trusted tools
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
RELEASE_GH_TOKEN="${GH_TOKEN:?}"
unset GH_TOKEN GITHUB_TOKEN
INPUT_DIR=".build/release-input"
OUTPUT_DIR=".build/release-output"
CANDIDATE_APP=".build/release-candidate/Vifty.app"
VERSION="${RELEASE_TAG#v}"
ZIP_PATH="${OUTPUT_DIR}/Vifty-v${VERSION}.zip"
CHECKSUM_PATH="${ZIP_PATH}.sha256"
SUMMARY_PATH="${OUTPUT_DIR}/Vifty-v${VERSION}-artifact-summary.json"
RELEASE_CHECKLIST_PATH="${OUTPUT_DIR}/Vifty-v${VERSION}-release-checklist.md"
PUBLICATION_CONTRACT_PATH="${OUTPUT_DIR}/release-publication-contract.json"
ENVIRONMENT_EVIDENCE_SOURCE="${RUNNER_TEMP}/vifty-release-environment-readback.json"
ENVIRONMENT_EVIDENCE_PATH="${OUTPUT_DIR}/release-environment-readback.json"
PROVENANCE_PATH="${RUNNER_TEMP}/vifty-release-provenance-final.json"
RULESET_EVIDENCE_PATH="${RUNNER_TEMP}/vifty-release-tag-ruleset.json"
GOVERNANCE_EVIDENCE_PATH="${OUTPUT_DIR}/administrator-governance-evidence.json"
ADMISSION_PROVENANCE_SOURCE="${INPUT_DIR}/release-admission-provenance.json"
ADMISSION_PROVENANCE_PATH="${OUTPUT_DIR}/release-admission-provenance.json"
mkdir -p "${OUTPUT_DIR}"
test -f "${ENVIRONMENT_EVIDENCE_SOURCE}"
cp "${ENVIRONMENT_EVIDENCE_SOURCE}" "${ENVIRONMENT_EVIDENCE_PATH}"
test -f "${ADMISSION_PROVENANCE_SOURCE}"
EXPECTED_ADMISSION_SHA="$(ruby -rjson -e '
inventory = JSON.parse(File.read(ARGV.fetch(0)))
supplemental = inventory.fetch("supplementalFiles")
abort("candidate inventory must bind one admission provenance file") unless
supplemental.is_a?(Array) && supplemental.length == 1 &&
supplemental.first["path"] == "release-admission-provenance.json"
print supplemental.first.fetch("sha256")
' "${INPUT_DIR}/candidate-inventory.json")"
test "${EXPECTED_ADMISSION_SHA}" = "$(shasum -a 256 "${ADMISSION_PROVENANCE_SOURCE}" | awk '{print $1}')"
cp "${ADMISSION_PROVENANCE_SOURCE}" "${ADMISSION_PROVENANCE_PATH}"
chmod 444 "${ENVIRONMENT_EVIDENCE_PATH}"
chmod 444 "${ADMISSION_PROVENANCE_PATH}"
write_public_tag_rule_coverage_evidence() {
local expected_ruleset_id="$1"
local expected_ruleset_updated_at="$2"
local rulesets_path="${RUNNER_TEMP}/vifty-release-rulesets-final.json"
local details_path
builtin printf 'Authorization: Bearer %s\n' "${RELEASE_GH_TOKEN}" |
/usr/bin/curl --disable --fail --silent --show-error \
--header @- \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
-H 'Cache-Control: no-cache' \
-H 'Pragma: no-cache' \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100" \
> "${rulesets_path}"
ruby -rjson -e '
rulesets = JSON.parse(File.read(ARGV.fetch(0)))
abort("public ruleset listing must be an array smaller than the requested page") unless rulesets.is_a?(Array) && rulesets.length < 100
expected_id = Integer(ARGV.fetch(1), 10)
matches = rulesets.select do |ruleset|
next unless ruleset["target"] == "tag" && ruleset["enforcement"] == "active"
ruleset["id"] == expected_id
end
abort("signed administrator ruleset ID is not one unique active public tag ruleset") unless matches.length == 1
' "${rulesets_path}" "${expected_ruleset_id}"
details_path="${RUNNER_TEMP}/vifty-release-ruleset-final-${expected_ruleset_id}.json"
builtin printf 'Authorization: Bearer %s\n' "${RELEASE_GH_TOKEN}" |
/usr/bin/curl --disable --fail --silent --show-error \
--header @- \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
-H 'Cache-Control: no-cache' \
-H 'Pragma: no-cache' \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/rulesets/${expected_ruleset_id}" \
> "${details_path}"
ruby -rjson -rtime -e '
ruleset = JSON.parse(File.read(ARGV.fetch(0)))
expected_id = Integer(ARGV.fetch(1), 10)
expected_updated_at = ARGV.fetch(2)
tag = ARGV.fetch(3)
repository = ARGV.fetch(4)
output_path = ARGV.fetch(5)
full_ref = "refs/tags/#{tag}"
ref_name = ruleset.dig("conditions", "ref_name")
includes = ref_name.is_a?(Hash) ? ref_name["include"] : nil
excludes = ref_name.is_a?(Hash) ? ref_name["exclude"] : nil
rule_types = Array(ruleset["rules"]).map { |rule| rule["type"] }.compact.uniq.sort
matched_includes = includes == ["refs/tags/v*"] ? includes : []
matched_excludes = excludes == [] ? [] : Array(excludes)
raw_updated_at = ruleset["updated_at"]
abort("public ruleset updated_at must be an exact ISO-8601 timestamp with timezone") unless
raw_updated_at.is_a?(String) &&
raw_updated_at.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})\z/)
begin
live_updated_at = Time.iso8601(raw_updated_at).utc.iso8601(9)
rescue ArgumentError
abort("public ruleset updated_at must be a valid ISO-8601 timestamp with timezone")
end
valid = ruleset["id"] == expected_id &&
ruleset["name"].is_a?(String) && !ruleset["name"].empty? &&
ruleset["target"] == "tag" && ruleset["enforcement"] == "active" &&
live_updated_at == expected_updated_at &&
ruleset["current_user_can_bypass"] == "never" &&
includes == ["refs/tags/v*"] && matched_includes == ["refs/tags/v*"] &&
excludes == [] && matched_excludes.empty? &&
rule_types.include?("update") && rule_types.include?("deletion")
abort("signed administrator ruleset no longer has public update/deletion coverage") unless valid
evidence = {
"schemaVersion" => 3,
"repository" => repository,
"releaseTag" => tag,
"releaseRef" => full_ref,
"rulesetID" => expected_id,
"rulesetName" => ruleset.fetch("name"),
"rulesetUpdatedAt" => live_updated_at,
"currentUserCanBypass" => ruleset.fetch("current_user_can_bypass"),
"target" => "tag",
"enforcement" => "active",
"matchedIncludePatterns" => matched_includes.sort,
"matchedExcludePatterns" => [],
"ruleTypes" => rule_types,
"bypassActorsVerified" => false,
"updateRulePresent" => true,
"deletionRulePresent" => true,
"publicRuleCoverageVerified" => true,
"verified" => true,
"readOnly" => true
}
File.write(output_path, JSON.pretty_generate(evidence) + "\n")
' "${details_path}" "${expected_ruleset_id}" "${expected_ruleset_updated_at}" "${RELEASE_TAG}" "${GITHUB_REPOSITORY}" "${RULESET_EVIDENCE_PATH}"
chmod 444 "${RULESET_EVIDENCE_PATH}"
}
TRUSTED_ROOT="${GITHUB_WORKSPACE}/.build/trusted-release-source"
(
cd "${TRUSTED_ROOT}"
trusted_status="$(git status --porcelain=v1 --untracked-files=all)"
test -z "${trusted_status}"
shasum -a 256 -c "${RUNNER_TEMP}/vifty-trusted-release-tools.sha256"
)
git -C "${TRUSTED_ROOT}" fetch --force origin \
"refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
GH_TOKEN="${RELEASE_GH_TOKEN}" \
VIFTY_RELEASE_PROVENANCE_ROOT="${TRUSTED_ROOT}" \
VIFTY_RELEASE_MANIFEST_BASE_REF="${GITHUB_SHA}^" \
VIFTY_REQUIRE_RELEASE_MANIFEST_BASE=1 \
VIFTY_RELEASE_SOURCE_REPOSITORY_ROOT="${TRUSTED_ROOT}" \
"${TRUSTED_ROOT}/scripts/check-release-provenance.sh" \
--tag "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--main-ref "${GITHUB_SHA}" \
--trusted-workflow-ref "${GITHUB_SHA}" \
--manifest "${TRUSTED_ROOT}/.github/release-manifest.json" \
--allowed-signers "${TRUSTED_ROOT}/.github/release-signers.allowed" \
--governance-evidence-output "${GOVERNANCE_EVIDENCE_PATH}" \
--json > "${PROVENANCE_PATH}"
chmod 444 "${PROVENANCE_PATH}" "${GOVERNANCE_EVIDENCE_PATH}"
SIGNED_RULESET_FACTS="$(ruby -rjson -rdigest -e '
provenance = JSON.parse(File.read(ARGV.fetch(0)))
validation = provenance.fetch("administratorGovernanceValidation")
evidence = provenance.fetch("administratorGovernanceEvidence")
abort("signed administrator governance provenance is incomplete") unless
provenance["schemaVersion"] == 3 &&
provenance["dataSource"] == "github-api-live" &&
provenance["liveRemoteTagReadback"] == true &&
provenance["liveSourceCIReadback"] == true &&
validation["status"] == "passed" &&
validation["evidenceSHA256"].is_a?(String) &&
validation["evidenceSHA256"] == Digest::SHA256.file(ARGV.fetch(1)).hexdigest &&
evidence.dig("tagRulesetEvidence", "rulesetID") == validation["rulesetID"] &&
evidence.dig("tagRulesetEvidence", "rulesetUpdatedAt") == validation["rulesetUpdatedAt"] &&
validation["currentUserCanBypass"] == "never" &&
evidence.dig("tagRulesetEvidence", "currentUserCanBypass") == "never"
print [validation.fetch("rulesetID"), validation.fetch("rulesetUpdatedAt")].join("\t")
' "${PROVENANCE_PATH}" "${GOVERNANCE_EVIDENCE_PATH}")"
IFS=$'\t' read -r SIGNED_RULESET_ID SIGNED_RULESET_UPDATED_AT <<< "${SIGNED_RULESET_FACTS}"
write_public_tag_rule_coverage_evidence "${SIGNED_RULESET_ID}" "${SIGNED_RULESET_UPDATED_AT}"
ditto -c -k --keepParent --norsrc --noextattr --noqtn --noacl \
"${CANDIDATE_APP}" "${ZIP_PATH}"
shasum -a 256 "${ZIP_PATH}" | tee "${CHECKSUM_PATH}"
EXPECTED_SHA="$(awk '{print $1}' "${CHECKSUM_PATH}")"
VIFTY_RELEASE_ARTIFACT_ROOT="${TRUSTED_ROOT}" \
VIFTY_RELEASE_MANIFEST_BASE_REF="${GITHUB_SHA}^" \
VIFTY_REQUIRE_RELEASE_MANIFEST_BASE=1 \
VIFTY_RELEASE_SOURCE_REPOSITORY_ROOT="${TRUSTED_ROOT}" \
bash "${TRUSTED_ROOT}/scripts/verify-release-artifact.sh" \
--release-version "${VERSION}" \
--artifact "${ZIP_PATH}" \
--expected-sha "${EXPECTED_SHA}" \
--team-id "${APPLE_TEAM_ID}" \
--summary "${SUMMARY_PATH}"
VIFTY_RELEASE_METADATA_ROOT="${TRUSTED_ROOT}" \
VIFTY_RELEASE_MANIFEST_BASE_REF="${GITHUB_SHA}^" \
VIFTY_REQUIRE_RELEASE_MANIFEST_BASE=1 \
VIFTY_RELEASE_SOURCE_REPOSITORY_ROOT="${TRUSTED_ROOT}" \
bash "${TRUSTED_ROOT}/scripts/write-release-checklist.sh" \
--version "${VERSION}" \
--output "${RELEASE_CHECKLIST_PATH}"
ruby -rjson -rdigest -e '
manifest_path, provenance_path, ruleset_path, environment_path, version, workflow_sha, artifact_sha, output_path, governance_path, admission_path = ARGV
manifest = JSON.parse(File.read(manifest_path))
provenance = JSON.parse(File.read(provenance_path))
ruleset_evidence = JSON.parse(File.read(ruleset_path))
environment_evidence = JSON.parse(File.read(environment_path))
governance_evidence = JSON.parse(File.read(governance_path))
governance_validation = provenance["administratorGovernanceValidation"]
admission = JSON.parse(File.read(admission_path))
admission_validation = admission["administratorGovernanceValidation"]
candidate = manifest["candidate"]
abort("trusted manifest candidate does not match publication version") unless candidate && candidate["version"] == version
release_tag = candidate.fetch("tag")
oid = /\A[0-9a-f]{40}\z/
provenance_valid = provenance["schemaVersion"] == 3 &&
provenance["status"] == "passed" &&
provenance["dataSource"] == "github-api-live" &&
provenance["liveRemoteTagReadback"] == true &&
provenance["liveSourceCIReadback"] == true &&
provenance["tag"] == release_tag &&
provenance["version"] == version &&
provenance["build"] == candidate.fetch("build") &&
provenance["checkoutCommitSHA"] == workflow_sha &&
provenance["mainRef"] == workflow_sha &&
provenance["tagObjectSHA"].to_s.match?(oid) &&
provenance["tagCommitSHA"].to_s.match?(oid) &&
provenance["signatureVerified"] == true &&
provenance["administratorGovernanceEvidence"] == governance_evidence &&
provenance["readOnly"] == true
abort("final signed-tag provenance does not bind the publication candidate") unless provenance_valid
governance_sha = Digest::SHA256.file(governance_path).hexdigest
governance_valid = governance_validation.is_a?(Hash) &&
governance_validation["schemaVersion"] == 1 &&
governance_validation["status"] == "passed" &&
governance_validation["repository"] == ENV.fetch("GITHUB_REPOSITORY") &&
governance_validation["releaseTag"] == release_tag &&
governance_validation["releaseCommitSHA"] == provenance["tagCommitSHA"] &&
governance_validation["rulesetID"].is_a?(Integer) && governance_validation["rulesetID"].positive? &&
governance_validation["rulesetUpdatedAt"].is_a?(String) &&
governance_validation["currentUserCanBypass"] == "never" &&
governance_validation["evidenceAgeSeconds"].is_a?(Integer) &&
governance_validation["evidenceAgeSeconds"].between?(0, 900) &&
governance_validation["evidenceSHA256"] == governance_sha &&
governance_validation["governanceToolSHA256"] == governance_evidence.dig("governanceTool", "sha256") &&
governance_validation["readOnly"] == true &&
governance_evidence["evidenceScope"] == "administrator-pretag" &&
governance_evidence["expectedMainSHA"] == provenance["tagCommitSHA"] &&
governance_evidence.dig("releaseEnvironmentEvidence", "evidenceScope") == "administrator-full" &&
governance_evidence.dig("releaseEnvironmentEvidence", "requiredBranchCommitSHA") == provenance["tagCommitSHA"] &&
governance_evidence.dig("tagRulesetEvidence", "rulesetID") == governance_validation["rulesetID"] &&
governance_evidence.dig("tagRulesetEvidence", "rulesetUpdatedAt") == governance_validation["rulesetUpdatedAt"] &&
governance_evidence.dig("tagRulesetEvidence", "currentUserCanBypass") == "never" &&
governance_evidence.dig("tagRulesetEvidence", "bypassActorsVerified") == true &&
governance_evidence.dig("tagRulesetEvidence", "bypassActors") == [] &&
governance_evidence.dig("releaseSecrets", "environmentShadowNames") == [] &&
governance_evidence["readOnly"] == true
abort("signed administrator governance evidence is incomplete or not bound to the release tag") unless governance_valid
admission_valid = admission["schemaVersion"] == 3 &&
admission["status"] == "passed" &&
admission["dataSource"] == "github-api-live" &&
admission["liveRemoteTagReadback"] == true &&
admission["liveSourceCIReadback"] == true &&
admission["tag"] == release_tag &&
admission["tagObjectSHA"] == provenance["tagObjectSHA"] &&
admission["tagCommitSHA"] == provenance["tagCommitSHA"] &&
admission["checkoutCommitSHA"] == workflow_sha &&
admission["mainRef"] == workflow_sha &&
admission["signatureVerified"] == true &&
admission["administratorGovernanceEvidence"] == governance_evidence &&
admission_validation.is_a?(Hash) &&
admission_validation["status"] == "passed" &&
admission_validation["currentFreshnessVerified"] == true &&
admission_validation["validatedAt"].is_a?(String) &&
admission_validation["currentEvidenceAgeSeconds"].is_a?(Integer) &&
admission_validation["currentEvidenceAgeSeconds"].between?(0, 900) &&
admission_validation["evidenceSHA256"] == governance_sha &&
admission["readOnly"] == true
abort("release admission did not bind current-fresh governance to this exact tag/workflow commit") unless admission_valid
ruleset_valid = ruleset_evidence["schemaVersion"] == 3 &&
ruleset_evidence["repository"] == ENV.fetch("GITHUB_REPOSITORY") &&
ruleset_evidence["releaseTag"] == release_tag &&
ruleset_evidence["releaseRef"] == "refs/tags/#{release_tag}" &&
ruleset_evidence["rulesetID"].is_a?(Integer) && ruleset_evidence["rulesetID"].positive? &&
ruleset_evidence["rulesetID"] == governance_validation["rulesetID"] &&
ruleset_evidence["rulesetName"].is_a?(String) && !ruleset_evidence["rulesetName"].empty? &&
ruleset_evidence["rulesetUpdatedAt"] == governance_validation["rulesetUpdatedAt"] &&
ruleset_evidence["currentUserCanBypass"] == "never" &&
ruleset_evidence["target"] == "tag" &&
ruleset_evidence["enforcement"] == "active" &&
Array(ruleset_evidence["matchedIncludePatterns"]).any? &&
ruleset_evidence["matchedExcludePatterns"] == [] &&
ruleset_evidence["bypassActorsVerified"] == false &&
Array(ruleset_evidence["ruleTypes"]).include?("update") &&
Array(ruleset_evidence["ruleTypes"]).include?("deletion") &&
ruleset_evidence["updateRulePresent"] == true &&
ruleset_evidence["deletionRulePresent"] == true &&
ruleset_evidence["publicRuleCoverageVerified"] == true &&
ruleset_evidence["verified"] == true &&
ruleset_evidence["readOnly"] == true
abort("public tag-rule coverage evidence is incomplete or does not bind the publication tag") unless ruleset_valid
expected_public_branch_protection = {
"statusCheckEnforcementLevel" => "everyone",
"requiredStatusCheck" => {
"context" => "SwiftPM checks",
"appID" => 15_368
}
}
expected_release_tag_deployment_policy = {
"policyCount" => 1,
"branchPolicyCount" => 0,
"tagPolicyCount" => 1,
"requiredTagPattern" => "v*",
"policies" => [{"type" => "tag", "name" => "v*"}]
}
environment_valid = environment_evidence["schemaVersion"] == 5 &&
environment_evidence["status"] == "passed" &&
environment_evidence["releaseAuthorized"] == true &&
environment_evidence["dataSource"] == "github-api-live" &&
environment_evidence["evidenceScope"] == "workflow-public" &&
environment_evidence["privilegedSettingsVerified"] == false &&
environment_evidence["environment"] == "release" &&
environment_evidence["releaseGovernanceMode"] == "solo-maintainer" &&
environment_evidence["requiredReviewerGate"] == false &&
environment_evidence["requiredReviewers"] == [] &&
environment_evidence["preventSelfReview"] == false &&
environment_evidence["administratorsCanBypass"] == false &&
environment_evidence["deploymentBranchPolicy"] == {
"protected_branches" => false,
"custom_branch_policies" => true
} &&
environment_evidence["releaseTagDeploymentPolicy"] == expected_release_tag_deployment_policy &&
environment_evidence["requiredBranch"] == "main" &&
environment_evidence["requiredBranchCommitSHA"] == workflow_sha &&
environment_evidence["requiredBranchProtected"] == true &&
environment_evidence["requiredBranchProtection"] == expected_public_branch_protection &&
environment_evidence["operatorOnlyChecks"] == [
"pull-request-required-zero-approvals-no-bypass",
"conversation-resolution",
"force-push-disabled",
"deletion-disabled"
] &&
environment_evidence["readOnly"] == true
abort("public release governance evidence is incomplete or overclaims privileged settings") unless environment_valid
load File.expand_path("../scripts/lib/release_artifact_contract.rb", File.dirname(manifest_path))
repository = File.expand_path("..", File.dirname(manifest_path))
snapshot_errors = []
tagged_snapshot = ViftyReleaseArtifactContract.tagged_manifest_snapshot(
repository,
provenance.fetch("tagCommitSHA"),
snapshot_errors
)
abort(snapshot_errors.join("; ")) unless tagged_snapshot
tagged_manifest, tagged_manifest_bytes = tagged_snapshot
tagged_selected = ViftyReleaseArtifactContract.selected_release(
tagged_manifest,
version,
snapshot_errors
)
abort(snapshot_errors.join("; ")) unless tagged_selected
tagged_kind, tagged_candidate = tagged_selected
abort("signed tag must select the candidate release manifest entry") unless tagged_kind == "candidate"
identity_fields = %w[version build tag artifact checksumAsset artifactSummary releaseChecklist]
unless identity_fields.all? { |field| candidate[field] == tagged_candidate[field] }
abort("trusted current candidate does not preserve the signed-tag release identity")
end
product = tagged_manifest.fetch("product")
policy = tagged_manifest.fetch("releasePolicy")
architectures = product.fetch("architectures").sort
sha_resolution = ViftyReleaseArtifactContract.resolve_expected_sha(
current_kind: "candidate",
current_sha: candidate["sha256"],
tagged_kind: tagged_kind,
tagged_sha: tagged_candidate["sha256"],
override: artifact_sha
)
contract = {
"schemaVersion" => 3,
"schemaID" => "https://vifty.local/contracts/release-publication-v3.json",
"workflowSourceSHA" => workflow_sha,
"releaseManifestSHA256" => Digest::SHA256.hexdigest(tagged_manifest_bytes),
"releaseManifestSchemaVersion" => tagged_manifest.fetch("schemaVersion"),
"releaseVersion" => tagged_candidate.fetch("version"),
"releaseTag" => tagged_candidate.fetch("tag"),
"releaseSourceCommit" => provenance.fetch("tagCommitSHA"),
"releaseManifestEntryKind" => "candidate",
"tagObjectSHA" => provenance.fetch("tagObjectSHA"),
"tagCommitSHA" => provenance.fetch("tagCommitSHA"),
"tagSignatureVerified" => true,
"administratorGovernanceVerified" => true,
"administratorGovernanceEvidence" => {
"fileName" => File.basename(governance_path),
"sha256" => governance_sha,
"validation" => governance_validation,
"readOnly" => true
},
"releaseAdmissionProvenance" => {
"fileName" => File.basename(admission_path),
"sha256" => Digest::SHA256.file(admission_path).hexdigest,
"currentFreshnessVerified" => true,
"validatedAt" => admission_validation.fetch("validatedAt"),
"currentEvidenceAgeSeconds" => admission_validation.fetch("currentEvidenceAgeSeconds"),
"tagObjectSHA" => admission.fetch("tagObjectSHA"),
"tagCommitSHA" => admission.fetch("tagCommitSHA"),
"evidenceSHA256" => admission_validation.fetch("evidenceSHA256"),
"readOnly" => true
},
"publicTagRuleCoverageVerified" => true,
"protectedTagRulesetEvidence" => ruleset_evidence,
"releaseEnvironmentEvidence" => {
"fileName" => File.basename(environment_path),
"sha256" => Digest::SHA256.file(environment_path).hexdigest,
"schemaVersion" => environment_evidence.fetch("schemaVersion"),
"environment" => environment_evidence.fetch("environment"),
"requiredBranch" => environment_evidence.fetch("requiredBranch"),
"status" => environment_evidence.fetch("status"),
"readOnly" => true
},
"bundleBuild" => tagged_candidate.fetch("build"),
"teamID" => policy.fetch("developerTeamID"),
"runtimeIdentifiers" => {
"app" => product.fetch("bundleID"),
"daemon" => product.fetch("daemonID"),
"helper" => product.fetch("helperID"),
"ctl" => product.fetch("ctlID")
},
"launchDaemonLabel" => product.fetch("daemonID"),
"machServiceName" => product.fetch("daemonID"),
"architectures" => %w[expected app helper daemon ctl].to_h { |key| [key, architectures] },
"assets" => {
"artifact" => tagged_candidate.fetch("artifact"),
"checksum" => tagged_candidate.fetch("checksumAsset"),
"summary" => tagged_candidate.fetch("artifactSummary"),
"checklist" => tagged_candidate.fetch("releaseChecklist")
},
"artifactSHA256" => artifact_sha,
"expectedSHASource" => sha_resolution.fetch(:source),
"summarySchemaID" => "https://vifty.local/schemas/release-artifact-summary.schema.json",
"requiredChecks" => ViftyReleaseArtifactContract::REQUIRED_CHECK_NAMES,
"checklist" => {
"title" => "# Vifty #{version} Release Checklist",
"verifiedHeading" => "## Verified By The Release Workflow",
"followUpHeading" => "## Required Post-Publication Follow-Up"
},
"readOnly" => true
}
File.write(output_path, JSON.pretty_generate(contract) + "\n")
' "${TRUSTED_ROOT}/.github/release-manifest.json" "${PROVENANCE_PATH}" "${RULESET_EVIDENCE_PATH}" "${ENVIRONMENT_EVIDENCE_PATH}" "${VERSION}" "${GITHUB_SHA}" "${EXPECTED_SHA}" "${PUBLICATION_CONTRACT_PATH}" "${GOVERNANCE_EVIDENCE_PATH}" "${ADMISSION_PROVENANCE_PATH}"
- name: Remove signing material
if: always()
run: |
set -euo pipefail
KEYCHAIN_PATH="${RUNNER_TEMP}/vifty-release.keychain-db"
CERTIFICATE_PATH="${RUNNER_TEMP}/developer-id-application.p12"
if [[ -f "${KEYCHAIN_PATH}" ]]; then
security delete-keychain "${KEYCHAIN_PATH}" || rm -f "${KEYCHAIN_PATH}"
fi
rm -f "${CERTIFICATE_PATH}" "${RUNNER_TEMP}/Vifty-${RELEASE_TAG#v}-notary.zip"
- name: Upload verified release assets for publication
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: vifty-release-${{ github.run_id }}
path: .build/release-output
if-no-files-found: error
retention-days: 90
overwrite: true
publish:
name: Publish verified GitHub release
needs: sign-notarize
if: ${{ github.run_attempt == 1 }}
runs-on: macos-15
timeout-minutes: 10
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Download verified release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: vifty-release-${{ github.run_id }}
path: .build/release-assets
- name: Recheck downloaded asset identity
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
ZIP_PATH=".build/release-assets/Vifty-v${VERSION}.zip"
CHECKSUM_PATH="${ZIP_PATH}.sha256"
SUMMARY_PATH=".build/release-assets/Vifty-v${VERSION}-artifact-summary.json"
RELEASE_CHECKLIST_PATH=".build/release-assets/Vifty-v${VERSION}-release-checklist.md"
PUBLICATION_CONTRACT_PATH=".build/release-assets/release-publication-contract.json"
ENVIRONMENT_EVIDENCE_PATH=".build/release-assets/release-environment-readback.json"
GOVERNANCE_EVIDENCE_PATH=".build/release-assets/administrator-governance-evidence.json"
ADMISSION_PROVENANCE_PATH=".build/release-assets/release-admission-provenance.json"
test -f "${ZIP_PATH}"
test -f "${CHECKSUM_PATH}"
test -f "${SUMMARY_PATH}"
test -f "${RELEASE_CHECKLIST_PATH}"
test -f "${PUBLICATION_CONTRACT_PATH}"
test -f "${ENVIRONMENT_EVIDENCE_PATH}"
test -f "${GOVERNANCE_EVIDENCE_PATH}"
test -f "${ADMISSION_PROVENANCE_PATH}"
EXPECTED_SHA="$(ruby -e '
line = File.readlines(ARGV.fetch(0), chomp: true).reject(&:empty?)
abort("checksum sidecar must contain exactly one non-empty line") unless line.length == 1
match = line.first.match(/\A([0-9a-f]{64}) (.+)\z/)
abort("checksum sidecar must use canonical shasum output") unless match
abort("checksum sidecar artifact name mismatch") unless File.basename(match[2]) == File.basename(ARGV.fetch(1))
print match[1]
' "${CHECKSUM_PATH}" "${ZIP_PATH}")"
test "$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" = "${EXPECTED_SHA}"
ruby -rjson -rdigest -rtime -e '
data = JSON.parse(File.read(ARGV.fetch(0)))
contract = JSON.parse(File.read(ARGV.fetch(4)))
environment_evidence = JSON.parse(File.read(ARGV.fetch(10)))
governance_evidence = JSON.parse(File.read(ARGV.fetch(11)))
admission = JSON.parse(File.read(ARGV.fetch(12)))
version = ARGV.fetch(1)
artifact = File.basename(ARGV.fetch(2))
sha = ARGV.fetch(3)
oid = /\A[0-9a-f]{40}\z/
ruleset_evidence = contract["protectedTagRulesetEvidence"]
governance_contract = contract["administratorGovernanceEvidence"]
governance_validation = governance_contract.is_a?(Hash) ? governance_contract["validation"] : nil
governance_sha = Digest::SHA256.file(ARGV.fetch(11)).hexdigest
admission_contract = contract["releaseAdmissionProvenance"]
admission_validation = admission["administratorGovernanceValidation"]
admission_sha = Digest::SHA256.file(ARGV.fetch(12)).hexdigest
governance_valid = governance_contract.is_a?(Hash) && governance_validation.is_a?(Hash) &&
governance_contract["fileName"] == File.basename(ARGV.fetch(11)) &&
governance_contract["sha256"] == governance_sha &&
governance_contract["readOnly"] == true &&
governance_validation["schemaVersion"] == 1 &&
governance_validation["status"] == "passed" &&
governance_validation["repository"] == ARGV.fetch(9) &&
governance_validation["releaseTag"] == ARGV.fetch(8) &&
governance_validation["releaseCommitSHA"] == contract["tagCommitSHA"] &&
governance_validation["rulesetID"].is_a?(Integer) && governance_validation["rulesetID"].positive? &&
governance_validation["rulesetUpdatedAt"].is_a?(String) &&
governance_validation["currentUserCanBypass"] == "never" &&
governance_validation["evidenceAgeSeconds"].is_a?(Integer) &&
governance_validation["evidenceAgeSeconds"].between?(0, 900) &&
governance_validation["evidenceSHA256"] == governance_sha &&
governance_validation["governanceToolSHA256"] == governance_evidence.dig("governanceTool", "sha256") &&
governance_validation["readOnly"] == true &&
governance_evidence["evidenceScope"] == "administrator-pretag" &&
governance_evidence["releaseTag"] == ARGV.fetch(8) &&
governance_evidence["expectedMainSHA"] == contract["tagCommitSHA"] &&
governance_evidence.dig("releaseEnvironmentEvidence", "evidenceScope") == "administrator-full" &&
governance_evidence.dig("releaseEnvironmentEvidence", "requiredBranchCommitSHA") == contract["tagCommitSHA"] &&
governance_evidence.dig("tagRulesetEvidence", "rulesetID") == governance_validation["rulesetID"] &&
governance_evidence.dig("tagRulesetEvidence", "rulesetUpdatedAt") == governance_validation["rulesetUpdatedAt"] &&
governance_evidence.dig("tagRulesetEvidence", "currentUserCanBypass") == "never" &&
governance_evidence.dig("tagRulesetEvidence", "bypassActorsVerified") == true &&
governance_evidence.dig("tagRulesetEvidence", "bypassActors") == [] &&
governance_evidence.dig("releaseSecrets", "environmentShadowNames") == [] &&
governance_evidence["readOnly"] == true
ruleset_valid = ruleset_evidence.is_a?(Hash) &&
ruleset_evidence["schemaVersion"] == 3 &&
ruleset_evidence["repository"] == ARGV.fetch(9) &&
ruleset_evidence["releaseTag"] == ARGV.fetch(8) &&
ruleset_evidence["releaseRef"] == "refs/tags/#{ARGV.fetch(8)}" &&
ruleset_evidence["rulesetID"].is_a?(Integer) && ruleset_evidence["rulesetID"].positive? &&
ruleset_evidence["rulesetID"] == governance_validation["rulesetID"] &&
ruleset_evidence["rulesetName"].is_a?(String) && !ruleset_evidence["rulesetName"].empty? &&
ruleset_evidence["rulesetUpdatedAt"] == governance_validation["rulesetUpdatedAt"] &&
ruleset_evidence["currentUserCanBypass"] == "never" &&
ruleset_evidence["target"] == "tag" &&
ruleset_evidence["enforcement"] == "active" &&
Array(ruleset_evidence["matchedIncludePatterns"]).any? &&
ruleset_evidence["matchedExcludePatterns"] == [] &&
ruleset_evidence["bypassActorsVerified"] == false &&
Array(ruleset_evidence["ruleTypes"]).include?("update") &&
Array(ruleset_evidence["ruleTypes"]).include?("deletion") &&
ruleset_evidence["updateRulePresent"] == true &&
ruleset_evidence["deletionRulePresent"] == true &&
ruleset_evidence["publicRuleCoverageVerified"] == true &&
ruleset_evidence["verified"] == true &&
ruleset_evidence["readOnly"] == true
environment_contract = contract["releaseEnvironmentEvidence"]
environment_sha = Digest::SHA256.file(ARGV.fetch(10)).hexdigest
abort("environment evidence SHA-256 mismatch") unless
environment_contract.is_a?(Hash) && environment_contract["sha256"] == environment_sha
expected_public_branch_protection = {
"statusCheckEnforcementLevel" => "everyone",
"requiredStatusCheck" => {
"context" => "SwiftPM checks",
"appID" => 15_368
}
}
environment_valid = environment_contract["fileName"] == File.basename(ARGV.fetch(10)) &&
environment_contract["schemaVersion"] == 5 &&
environment_contract["environment"] == "release" &&
environment_contract["requiredBranch"] == "main" &&
environment_contract["status"] == "passed" &&
environment_contract["readOnly"] == true &&
environment_evidence["schemaVersion"] == 5 &&
environment_evidence["status"] == "passed" &&
environment_evidence["releaseAuthorized"] == true &&
environment_evidence["dataSource"] == "github-api-live" &&
environment_evidence["evidenceScope"] == "workflow-public" &&
environment_evidence["privilegedSettingsVerified"] == false &&
environment_evidence["environment"] == "release" &&
environment_evidence["releaseGovernanceMode"] == "solo-maintainer" &&
environment_evidence["requiredReviewerGate"] == false &&
environment_evidence["requiredReviewers"] == [] &&
environment_evidence["preventSelfReview"] == false &&
environment_evidence["administratorsCanBypass"] == false &&
environment_evidence["deploymentBranchPolicy"] == {
"protected_branches" => false,
"custom_branch_policies" => true
} &&
environment_evidence["releaseTagDeploymentPolicy"] == {
"policyCount" => 1,
"branchPolicyCount" => 0,
"tagPolicyCount" => 1,
"requiredTagPattern" => "v*",
"policies" => [{"type" => "tag", "name" => "v*"}]
} &&
environment_evidence["requiredBranch"] == "main" &&
environment_evidence["requiredBranchCommitSHA"] == ARGV.fetch(5) &&
environment_evidence["requiredBranchProtected"] == true &&
environment_evidence["requiredBranchProtection"] == expected_public_branch_protection &&
environment_evidence["operatorOnlyChecks"] == [
"pull-request-required-zero-approvals-no-bypass",
"conversation-resolution",
"force-push-disabled",
"deletion-disabled"
] &&
environment_evidence["readOnly"] == true
admission_valid = admission_contract.is_a?(Hash) && admission_validation.is_a?(Hash) &&
admission_contract["fileName"] == File.basename(ARGV.fetch(12)) &&
admission_contract["sha256"] == admission_sha &&
admission_contract["currentFreshnessVerified"] == true &&
admission_contract["validatedAt"] == admission_validation["validatedAt"] &&
admission_contract["currentEvidenceAgeSeconds"] == admission_validation["currentEvidenceAgeSeconds"] &&
admission_contract["tagObjectSHA"] == contract["tagObjectSHA"] &&
admission_contract["tagCommitSHA"] == contract["tagCommitSHA"] &&
admission_contract["evidenceSHA256"] == governance_sha &&
admission_contract["readOnly"] == true &&
admission["schemaVersion"] == 3 && admission["status"] == "passed" &&
admission["dataSource"] == "github-api-live" &&
admission["liveRemoteTagReadback"] == true &&
admission["liveSourceCIReadback"] == true &&
admission["tag"] == ARGV.fetch(8) &&
admission["tagObjectSHA"] == contract["tagObjectSHA"] &&
admission["tagCommitSHA"] == contract["tagCommitSHA"] &&
admission["checkoutCommitSHA"] == ARGV.fetch(5) &&
admission["mainRef"] == ARGV.fetch(5) &&
admission["signatureVerified"] == true &&
admission["administratorGovernanceEvidence"] == governance_evidence &&
admission_validation["status"] == "passed" &&
admission_validation["currentFreshnessVerified"] == true &&
admission_validation["currentEvidenceAgeSeconds"].is_a?(Integer) &&
admission_validation["currentEvidenceAgeSeconds"].between?(0, 900) &&
admission_validation["evidenceSHA256"] == governance_sha &&
admission["readOnly"] == true
contract_valid = contract["schemaVersion"] == 3 &&
contract["schemaID"] == "https://vifty.local/contracts/release-publication-v3.json" &&
contract["workflowSourceSHA"] == ARGV.fetch(5) &&
contract["releaseVersion"] == version &&
contract["releaseTag"] == ARGV.fetch(8) &&
contract["releaseTag"] == "v#{version}" &&
contract["releaseSourceCommit"] == contract["tagCommitSHA"] &&
contract["releaseManifestEntryKind"] == "candidate" &&
contract["tagObjectSHA"].to_s.match?(oid) &&
contract["tagCommitSHA"].to_s.match?(oid) &&
contract["tagSignatureVerified"] == true &&
contract["administratorGovernanceVerified"] == true &&
governance_valid &&
admission_valid &&
contract["publicTagRuleCoverageVerified"] == true &&
ruleset_valid &&
environment_valid &&
contract["artifactSHA256"] == sha &&
contract["readOnly"] == true &&
contract.dig("assets", "artifact") == artifact &&
contract.dig("assets", "checksum") == File.basename(ARGV.fetch(6)) &&
contract.dig("assets", "summary") == File.basename(ARGV.fetch(0)) &&
contract.dig("assets", "checklist") == File.basename(ARGV.fetch(7))
abort("trusted publication contract does not bind this workflow/assets") unless contract_valid
expected_checks = contract.fetch("requiredChecks")
checks = data["checks"]
checks_valid = checks.is_a?(Array) &&
checks.map { |check| check["name"] }.sort == expected_checks.sort &&
checks.map { |check| check["name"] }.uniq.length == checks.length &&
checks.all? do |check|
check["status"] == "passed" &&
check["scope"] == "release-trust" &&
check["note"].is_a?(String) && !check["note"].empty?
end
valid = data["schemaVersion"] == 2 &&
data["schemaID"] == contract.fetch("summarySchemaID") &&
data["status"] == "passed" &&
data["bundleVersion"] == version &&
data["bundleBuild"] == contract.fetch("bundleBuild") &&
data["bundleIdentifier"] == contract.dig("runtimeIdentifiers", "app") &&
data["releaseVersion"] == version &&
data["releaseTag"] == contract.fetch("releaseTag") &&
data["releaseSourceCommit"] == contract.fetch("tagCommitSHA") &&
data["releaseManifestEntryKind"] == "candidate" &&
data["releaseManifestSchemaVersion"] == contract.fetch("releaseManifestSchemaVersion") &&
data["releaseManifestSHA256"] == contract.fetch("releaseManifestSHA256") &&
data["runtimeIdentifiers"] == contract.fetch("runtimeIdentifiers") &&
data["launchDaemonLabel"] == contract.fetch("launchDaemonLabel") &&
data["machServiceName"] == contract.fetch("machServiceName") &&
data["architectures"] == contract.fetch("architectures") &&
data["expectedArtifactName"] == artifact &&
data["expectedSHA"] == sha &&
data["actualSHA"] == sha &&
data["expectedSHASource"] == contract.fetch("expectedSHASource") &&
data["expectedTeamID"] == contract.fetch("teamID") &&
data["requiredTeamID"] == contract.fetch("teamID") &&
data["signatureChecksSkipped"] == false &&
data["notarizationChecksSkipped"] == false &&
checks_valid
abort("release verifier summary does not satisfy the full v2 publication contract") unless valid
' "${SUMMARY_PATH}" "${VERSION}" "${ZIP_PATH}" "${EXPECTED_SHA}" "${PUBLICATION_CONTRACT_PATH}" "${GITHUB_SHA}" "${CHECKSUM_PATH}" "${RELEASE_CHECKLIST_PATH}" "${RELEASE_TAG}" "${GITHUB_REPOSITORY}" "${ENVIRONMENT_EVIDENCE_PATH}" "${GOVERNANCE_EVIDENCE_PATH}" "${ADMISSION_PROVENANCE_PATH}"
ruby -rjson -e '
text = File.read(ARGV.fetch(0))
version = ARGV.fetch(1)
contract = JSON.parse(File.read(ARGV.fetch(2)))
abort("checklist contract release version mismatch") unless contract["releaseVersion"] == version
expected_title = contract.dig("checklist", "title")
abort("release checklist title mismatch") unless text.lines.first&.chomp == expected_title
verified_heading = contract.dig("checklist", "verifiedHeading")
follow_up_heading = contract.dig("checklist", "followUpHeading")
abort("release checklist is missing required sections") unless text.include?(verified_heading) && text.include?(follow_up_heading)
verified = text.split(verified_heading, 2).fetch(1).split(follow_up_heading, 2).fetch(0)
verified_items = verified.lines.grep(/\A- \[[ x]\] /)
abort("release checklist verified section is incomplete") if verified_items.empty? || verified_items.any? { |line| !line.start_with?("- [x] ") }
follow_up = text.split(follow_up_heading, 2).fetch(1)
follow_up_items = follow_up.lines.grep(/\A- \[[ x]\] /)
abort("release checklist follow-up section is incomplete") if follow_up_items.empty? || follow_up_items.any? { |line| !line.start_with?("- [ ] ") }
' "${RELEASE_CHECKLIST_PATH}" "${VERSION}" "${PUBLICATION_CONTRACT_PATH}"
echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
echo "ZIP_PATH=${ZIP_PATH}" >> "${GITHUB_ENV}"
echo "CHECKSUM_PATH=${CHECKSUM_PATH}" >> "${GITHUB_ENV}"
echo "SUMMARY_PATH=${SUMMARY_PATH}" >> "${GITHUB_ENV}"
echo "RELEASE_CHECKLIST_PATH=${RELEASE_CHECKLIST_PATH}" >> "${GITHUB_ENV}"
ruby -rjson -e '
contract = JSON.parse(File.read(ARGV.fetch(0)))
puts "TAG_OBJECT_SHA=#{contract.fetch("tagObjectSHA")}"
puts "TAG_COMMIT_SHA=#{contract.fetch("tagCommitSHA")}"
puts "PROTECTED_TAG_RULESET_ID=#{contract.fetch("protectedTagRulesetEvidence").fetch("rulesetID")}"
puts "PROTECTED_TAG_RULESET_UPDATED_AT=#{contract.fetch("protectedTagRulesetEvidence").fetch("rulesetUpdatedAt")}"
' "${PUBLICATION_CONTRACT_PATH}" >> "${GITHUB_ENV}"
unset RELEASE_GH_TOKEN
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
RELEASE_GH_TOKEN="${GH_TOKEN:?}"
unset GH_TOKEN GITHUB_TOKEN
release_gh() {
GH_TOKEN="${RELEASE_GH_TOKEN}" gh "$@"
}
verify_remote_tag_identity() {
local expected_tag_object="$1"
local expected_tag_commit="$2"
local ref_facts ref_type ref_sha tag_facts current_type current_sha
local depth=0
ref_facts="$(release_gh api --hostname github.com \
"repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" \
--jq '.object.type + "\t" + .object.sha')"
IFS=$'\t' read -r ref_type ref_sha <<< "${ref_facts}"
if [[ "${ref_type}" != "tag" || "${ref_sha}" != "${expected_tag_object}" ]]; then
echo "Remote ${RELEASE_TAG} no longer matches verified annotated tag object ${expected_tag_object}." >&2
return 1
fi
current_type="${ref_type}"
current_sha="${ref_sha}"
while [[ "${current_type}" == "tag" ]]; do
depth=$((depth + 1))
if [[ "${depth}" -gt 8 ]]; then
echo "Remote ${RELEASE_TAG} tag chain exceeds the reviewed peel depth." >&2
return 1
fi
tag_facts="$(release_gh api --hostname github.com \
"repos/${GITHUB_REPOSITORY}/git/tags/${current_sha}" \
--jq '.object.type + "\t" + .object.sha')"
IFS=$'\t' read -r current_type current_sha <<< "${tag_facts}"
done
if [[ "${current_type}" != "commit" || "${current_sha}" != "${expected_tag_commit}" ]]; then
echo "Remote ${RELEASE_TAG} no longer peels to verified commit ${expected_tag_commit}." >&2
return 1
fi
}
verify_immutable_tag_ruleset() {
local expected_ruleset_id="${1:-}"
local expected_ruleset_updated_at="${2:-}"
local rulesets_path="${RUNNER_TEMP}/vifty-release-rulesets.json"
local details_path ruleset_ids ruleset_id
local -a matching_ruleset_ids=()
builtin printf 'Authorization: Bearer %s\n' "${RELEASE_GH_TOKEN}" |
/usr/bin/curl --disable --fail --silent --show-error \
--header @- \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
-H 'Cache-Control: no-cache' \
-H 'Pragma: no-cache' \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100" \
> "${rulesets_path}"
ruleset_ids="$(ruby -rjson -e '
rulesets = JSON.parse(File.read(ARGV.fetch(0)))
abort("public ruleset listing must be an array smaller than the requested page") unless rulesets.is_a?(Array) && rulesets.length < 100
ids = rulesets.map do |ruleset|
next unless ruleset["target"] == "tag" && ruleset["enforcement"] == "active"
id = ruleset["id"]
abort("active tag ruleset is missing an integer id") unless id.is_a?(Integer) && id.positive?
id
end.compact
puts ids.uniq.sort
' "${rulesets_path}")"
while IFS= read -r ruleset_id; do
[[ -n "${ruleset_id}" ]] || continue
details_path="${RUNNER_TEMP}/vifty-release-ruleset-${ruleset_id}.json"
builtin printf 'Authorization: Bearer %s\n' "${RELEASE_GH_TOKEN}" |
/usr/bin/curl --disable --fail --silent --show-error \
--header @- \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
-H 'Cache-Control: no-cache' \
-H 'Pragma: no-cache' \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}" \
> "${details_path}"
if ruby -rjson -rtime -e '
ruleset = JSON.parse(File.read(ARGV.fetch(0)))
tag = ARGV.fetch(1)
expected_id = ARGV.fetch(2)
expected_updated_at = ARGV.fetch(3)
full_ref = "refs/tags/#{tag}"
ref_name = ruleset.dig("conditions", "ref_name")
includes = ref_name.is_a?(Hash) ? ref_name["include"] : nil
excludes = ref_name.is_a?(Hash) ? ref_name["exclude"] : nil
rule_types = Array(ruleset["rules"]).map { |rule| rule["type"] }.compact
raw_updated_at = ruleset["updated_at"]
exit 1 unless
raw_updated_at.is_a?(String) &&
raw_updated_at.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})\z/)
begin
live_updated_at = Time.iso8601(raw_updated_at).utc.iso8601(9)
rescue ArgumentError
exit 1
end
valid = ruleset["target"] == "tag" &&
ruleset["enforcement"] == "active" &&
(expected_id.empty? || ruleset["id"] == Integer(expected_id, 10)) &&
(expected_updated_at.empty? || live_updated_at == expected_updated_at) &&
ruleset["current_user_can_bypass"] == "never" &&
includes == ["refs/tags/v*"] &&
excludes == [] &&
rule_types.include?("update") && rule_types.include?("deletion")
exit(valid ? 0 : 1)
' "${details_path}" "${RELEASE_TAG}" "${expected_ruleset_id}" "${expected_ruleset_updated_at}"; then
matching_ruleset_ids+=("${ruleset_id}")
fi
done <<< "${ruleset_ids}"
if [[ -n "${expected_ruleset_id}" ]]; then
for ruleset_id in "${matching_ruleset_ids[@]}"; do
if [[ "${ruleset_id}" == "${expected_ruleset_id}" ]]; then
printf '%s\n' "${ruleset_id}"
return 0
fi
done
echo "The recorded public update/deletion tag ruleset ${expected_ruleset_id} no longer covers ${RELEASE_TAG}." >&2
return 1
fi
if [[ "${#matching_ruleset_ids[@]}" -eq 0 ]]; then
echo "No publicly visible active tag ruleset contains both update and deletion rules for ${RELEASE_TAG}." >&2
return 1
fi
printf '%s\n' "${matching_ruleset_ids[0]}"
}
query_release_by_tag() {
local destination="$1"
local pages_path="${destination}.pages"
release_gh api --hostname github.com --paginate --slurp \
-H 'Cache-Control: no-cache' \
-H 'Pragma: no-cache' \
"repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
> "${pages_path}" || return 1
ruby -rjson -e '
pages = JSON.parse(File.read(ARGV.fetch(0)))
exit 2 unless pages.is_a?(Array) && pages.all? { |page| page.is_a?(Array) }
matches = pages.flatten(1).select { |release| release["tag_name"] == ARGV.fetch(1) }
exit 3 if matches.empty?
exit 4 unless matches.length == 1
File.write(ARGV.fetch(2), JSON.generate(matches.fetch(0)) + "\n")
' "${pages_path}" "${RELEASE_TAG}" "${destination}"
}
query_release_by_id() {
local release_id="$1"
local destination="$2"
release_gh api --hostname github.com "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > "${destination}"
}
capture_created_release_id() {
local state_path="$1"
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
repository = ARGV.fetch(1)
tag = ARGV.fetch(2)
id = release["id"]
api_base = id.is_a?(Integer) && id.positive? ?
"https://api.github.com/repos/#{repository}/releases/#{id}" : nil
upload_url = id.is_a?(Integer) && id.positive? ?
"https://uploads.github.com/repos/#{repository}/releases/#{id}/assets{?name,label}" : nil
valid = id.is_a?(Integer) && id.positive? &&
release["url"] == api_base &&
release["assets_url"] == "#{api_base}/assets" &&
release["upload_url"] == upload_url &&
release["tag_name"] == tag
abort("creation response does not prove immutable repository-scoped ID/tag identity") unless valid
print id
' "${state_path}" "${GITHUB_REPOSITORY}" "${RELEASE_TAG}"
}
capture_owned_draft_release_id() {
local state_path="$1"
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
id = release["id"]
marker = ARGV.fetch(3)
body = release["body"]
marker_valid = body.is_a?(String) &&
body.scan(marker).length == 1 &&
body.lines.map(&:strip).include?(marker)
valid = id.is_a?(Integer) && id.positive? &&
release["tag_name"] == ARGV.fetch(1) &&
release["draft"] == true &&
release["name"] == ARGV.fetch(2) &&
marker_valid &&
Array(release["assets"]).empty?
abort("draft response does not prove immutable ID/tag/title/marker ownership") unless valid
print id
' "${state_path}" "${RELEASE_TAG}" "${DRAFT_TITLE}" "${RELEASE_MARKER}"
}
discover_owned_release_by_tag_for_containment() {
local destination="$1"
if query_release_by_tag "${destination}"; then
local discovered_release_id
if ! discovered_release_id="$(capture_created_release_id "${destination}")" ||
! verify_release_owned_for_containment "${destination}" "${discovered_release_id}"; then
echo "The by-tag release does not carry this run's exact repository-scoped ID, title, and ownership marker; refusing mutation." >&2
return 5
fi
return 0
else
return $?
fi
}
wait_for_owned_release_by_tag_for_containment() {
local destination="$1"
local deadline=$((SECONDS + 60))
local discovery_status
while true; do
if discover_owned_release_by_tag_for_containment "${destination}"; then
return 0
else
discovery_status=$?
fi
if [[ "${discovery_status}" -ne 3 ]]; then
return 1
fi
if (( SECONDS >= deadline )); then
echo "Containment could not prove post-creation absence or exact marker ownership for ${RELEASE_TAG} within 60 seconds." >&2
return 1
fi
/bin/sleep 2
done
}
verify_release_state() {
local state_path="$1"
local release_id="$2"
local expected_draft="$3"
local expected_title="$4"
local expected_body_path="$5"
local expected_assets_path="$6"
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
expected_id = Integer(ARGV.fetch(1), 10)
expected_draft = ARGV.fetch(2) == "true"
expected_title = ARGV.fetch(3)
expected_tag = ARGV.fetch(4)
expected_body = File.binread(ARGV.fetch(5))
expected_assets = JSON.parse(File.read(ARGV.fetch(6)))
abort("expected asset identity file must be an array") unless expected_assets.is_a?(Array)
select_identity = lambda do |asset|
{
"id" => asset["id"],
"name" => asset["name"],
"size" => asset["size"],
"state" => asset["state"],
"digest" => asset["digest"]
}
end
actual_assets = Array(release["assets"]).map { |asset| select_identity.call(asset) }
expected_assets = expected_assets.map { |asset| select_identity.call(asset) }
actual_assets.sort_by! { |asset| asset.fetch("id") }
expected_assets.sort_by! { |asset| asset.fetch("id") }
abort("release immutable-id readback mismatch") unless release["id"] == expected_id
abort("release tag readback mismatch") unless release["tag_name"] == expected_tag
abort("release draft-state readback mismatch") unless release["draft"] == expected_draft
abort("release prerelease-state readback mismatch") unless release["prerelease"] == false
abort("release title readback mismatch") unless release["name"] == expected_title
abort("release body readback mismatch") unless release["body"].is_a?(String) && release["body"].b == expected_body
abort("release asset immutable identity/digest readback mismatch") unless actual_assets == expected_assets
' "${state_path}" "${release_id}" "${expected_draft}" "${expected_title}" \
"${RELEASE_TAG}" "${expected_body_path}" "${expected_assets_path}"
}
query_release_by_id_for_convergence() {
local release_id="$1"
local destination="$2"
local temporary_path="${destination}.tmp"
local http_status
rm -f "${temporary_path}"
if ! http_status="$(
builtin printf 'Authorization: Bearer %s\n' "${RELEASE_GH_TOKEN}" |
/usr/bin/curl --disable --silent --show-error \
--header @- \
--header 'Accept: application/vnd.github+json' \
--header 'X-GitHub-Api-Version: 2022-11-28' \
--header 'Cache-Control: no-cache' \
--header 'Pragma: no-cache' \
--output "${temporary_path}" \
--write-out '%{http_code}' \
--url "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
)"; then
rm -f "${temporary_path}"
echo "Release ID ${release_id} convergence readback failed before an HTTP status was available." >&2
return 1
fi
case "${http_status}" in
200)
mv "${temporary_path}" "${destination}"
return 0
;;
404|429|5??)
rm -f "${temporary_path}"
return 75
;;
*)
rm -f "${temporary_path}"
echo "Release ID ${release_id} convergence readback returned non-retryable HTTP ${http_status}." >&2
return 1
;;
esac
}
verify_release_convergence_identity() {
local state_path="$1"
local release_id="$2"
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
marker = ARGV.fetch(3)
body = release["body"]
marker_valid = body.is_a?(String) &&
body.scan(marker).length == 1 &&
body.lines.map(&:strip).include?(marker)
valid = release["id"] == Integer(ARGV.fetch(1), 10) &&
release["tag_name"] == ARGV.fetch(2) &&
marker_valid
exit(valid ? 0 : 1)
' "${state_path}" "${release_id}" "${RELEASE_TAG}" "${RELEASE_MARKER}"
}
wait_for_release_state_by_id() {
local destination="$1"
local release_id="$2"
local expected_draft="$3"
local expected_title="$4"
local expected_body_path="$5"
local expected_assets_path="$6"
local deadline=$((SECONDS + 60))
local query_status
while true; do
if query_release_by_id_for_convergence "${release_id}" "${destination}"; then
if ! verify_release_convergence_identity "${destination}" "${release_id}"; then
echo "Release ID ${release_id} convergence readback changed immutable ID, tag, or ownership marker." >&2
return 1
fi
if verify_release_state "${destination}" "${release_id}" "${expected_draft}" \
"${expected_title}" "${expected_body_path}" "${expected_assets_path}" 2>/dev/null; then
return 0
fi
query_status=76
else
query_status=$?
if [[ "${query_status}" -ne 75 ]]; then
return 1
fi
fi
if (( SECONDS >= deadline )); then
echo "Release ID ${release_id} did not converge to the exact expected state within 60 seconds." >&2
return 1
fi
/bin/sleep 2
done
}
verify_release_owned_for_containment() {
local state_path="$1"
local release_id="$2"
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
marker = ARGV.fetch(3)
body = release["body"]
marker_valid = body.is_a?(String) &&
body.scan(marker).length == 1 &&
body.lines.map(&:strip).include?(marker)
valid = release["id"] == Integer(ARGV.fetch(1), 10) &&
release["tag_name"] == ARGV.fetch(2) &&
[ARGV.fetch(4), ARGV.fetch(5)].include?(release["name"]) &&
marker_valid
exit(valid ? 0 : 1)
' "${state_path}" "${release_id}" "${RELEASE_TAG}" "${RELEASE_MARKER}" \
"${DRAFT_TITLE}" "${FINAL_TITLE}"
}
verify_release_contained() {
local state_path="$1"
local release_id="$2"
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
marker = ARGV.fetch(3)
body = release["body"]
marker_valid = body.is_a?(String) &&
body.scan(marker).length == 1 &&
body.lines.map(&:strip).include?(marker)
valid = release["id"] == Integer(ARGV.fetch(1), 10) &&
release["tag_name"] == ARGV.fetch(2) &&
release["draft"] == true &&
[ARGV.fetch(4), ARGV.fetch(5)].include?(release["name"]) &&
marker_valid
abort("release containment ownership/draft readback failed") unless valid
' "${state_path}" "${release_id}" "${RELEASE_TAG}" "${RELEASE_MARKER}" \
"${DRAFT_TITLE}" "${FINAL_TITLE}"
}
wait_for_release_owned_for_containment() {
local destination="$1"
local release_id="$2"
local deadline=$((SECONDS + 60))
local query_status
while true; do
if query_release_by_id_for_convergence "${release_id}" "${destination}"; then
if verify_release_owned_for_containment "${destination}" "${release_id}"; then
return 0
fi
echo "Containment readback for release ID ${release_id} changed immutable ID, tag, title, or ownership marker." >&2
return 1
else
query_status=$?
if [[ "${query_status}" -ne 75 ]]; then
return 1
fi
fi
if (( SECONDS >= deadline )); then
echo "Containment could not prove immutable-ID ownership for release ID ${release_id} within 60 seconds; no mutation was attempted." >&2
return 1
fi
/bin/sleep 2
done
}
wait_for_release_contained_by_id() {
local destination="$1"
local release_id="$2"
local containment_deadline=$((SECONDS + 60))
local query_status
while true; do
if query_release_by_id_for_convergence "${release_id}" "${destination}"; then
if ! verify_release_owned_for_containment "${destination}" "${release_id}"; then
echo "Containment readback for release ID ${release_id} changed immutable ID, tag, title, or ownership marker." >&2
return 1
fi
if verify_release_contained "${destination}" "${release_id}" 2>/dev/null; then
return 0
fi
query_status=76
else
query_status=$?
if [[ "${query_status}" -ne 75 ]]; then
return 1
fi
fi
if (( SECONDS >= containment_deadline )); then
echo "Release ID ${release_id} did not converge to verified draft containment within 60 seconds." >&2
return 1
fi
/bin/sleep 2
done
}
upload_release_asset_by_id() {
local asset_path="$1"
local asset_label="$2"
local asset_name query upload_status query_status sequence
local upload_response upload_state
asset_name="$(basename "${asset_path}")"
sequence="$(ruby -rjson -e 'data = JSON.parse(File.read(ARGV.fetch(0))); abort unless data.is_a?(Array); print data.length' "${EXPECTED_ASSETS_PATH}")"
upload_response="${RUNNER_TEMP}/vifty-release-upload-${sequence}-response.json"
upload_state="${RUNNER_TEMP}/vifty-release-upload-${sequence}-state.json"
query="$(ruby -ruri -e '
print URI.encode_www_form("name" => ARGV.fetch(0), "label" => ARGV.fetch(1))
' "${asset_name}" "${asset_label}")"
if builtin printf 'Authorization: Bearer %s\n' "${RELEASE_GH_TOKEN}" |
/usr/bin/curl --disable --fail-with-body --silent --show-error \
--header @- \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--header "Content-Type: application/octet-stream" \
--request POST \
--data-binary "@${asset_path}" \
--url "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?${query}" \
> "${upload_response}"; then
upload_status=0
else
upload_status=$?
fi
if [[ "${upload_status}" -eq 0 ]]; then
if ! ruby -rjson -rdigest -e '
response_path, asset_path, expected_assets_path = ARGV
response = JSON.parse(File.read(response_path))
expected = JSON.parse(File.read(expected_assets_path))
abort("expected asset identity file must be an array") unless expected.is_a?(Array)
name = File.basename(asset_path)
size = File.size(asset_path)
digest = "sha256:#{Digest::SHA256.file(asset_path).hexdigest}"
id = response["id"]
valid = id.is_a?(Integer) && id.positive? &&
response["name"] == name && response["size"] == size &&
response["state"] == "uploaded" && response["digest"] == digest
abort("upload response does not bind immutable asset ID/name/size/state/digest") unless valid
abort("upload response reused an asset ID or name") if expected.any? { |item| item["id"] == id || item["name"] == name }
expected << {
"id" => id,
"name" => name,
"size" => size,
"state" => "uploaded",
"digest" => digest
}
File.write(expected_assets_path, JSON.pretty_generate(expected) + "\n")
' "${upload_response}" "${asset_path}" "${EXPECTED_ASSETS_PATH}"; then
upload_status=1
fi
fi
if wait_for_release_state_by_id "${upload_state}" "${RELEASE_ID}" true "${DRAFT_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}"; then
query_status=0
else
query_status=$?
fi
if [[ "${upload_status}" -ne 0 ]] || [[ "${query_status}" -ne 0 ]] || \
! verify_release_state "${upload_state}" "${RELEASE_ID}" true "${DRAFT_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}"; then
echo "Asset upload for ${asset_name} was ambiguous or its immutable-ID post-state mismatched." >&2
return 1
fi
}
contain_release_by_id() {
local mutation_status query_status
local containment_response="${RUNNER_TEMP}/vifty-release-containment-response.json"
local containment_state="${RUNNER_TEMP}/vifty-release-containment-state.json"
local ownership_state="${RUNNER_TEMP}/vifty-release-containment-ownership.json"
local discovered_state="${RUNNER_TEMP}/vifty-release-containment-discovery.json"
if [[ -z "${RELEASE_ID}" ]]; then
if wait_for_owned_release_by_tag_for_containment "${discovered_state}"; then
RELEASE_ID="$(capture_created_release_id "${discovered_state}")" || return 1
else
echo "Containment could not prove marker ownership for a unique ${RELEASE_TAG} release; no mutation was attempted." >&2
return 1
fi
fi
if ! wait_for_release_owned_for_containment "${ownership_state}" "${RELEASE_ID}"; then
echo "Containment refused to mutate release ID ${RELEASE_ID}: exact tag/title/marker ownership was not proved." >&2
return 1
fi
if release_gh api --hostname github.com --method PATCH \
"repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=true > "${containment_response}"; then
mutation_status=0
else
mutation_status=$?
fi
if wait_for_release_contained_by_id "${containment_state}" "${RELEASE_ID}"; then
query_status=0
else
query_status=$?
fi
if [[ "${query_status}" -ne 0 ]] || \
! verify_release_contained "${containment_state}" "${RELEASE_ID}"; then
echo "HARD FAILURE: draft containment could not be verified for release ID ${RELEASE_ID}." >&2
return 1
fi
if [[ "${mutation_status}" -ne 0 ]]; then
echo "Re-draft mutation returned ${mutation_status}, but immutable-ID readback verified draft containment." >&2
else
echo "Immutable-ID readback verified draft containment for release ID ${RELEASE_ID}." >&2
fi
}
containment_guard() {
local original_status=$?
trap - EXIT
trap '' INT TERM
if [[ "${CONTAINMENT_REQUIRED}" == "1" ]]; then
if ! contain_release_by_id; then
echo "HARD FAILURE: publication failed and release containment was not verified." >&2
exit 97
fi
fi
exit "${original_status}"
}
verify_remote_tag_identity "${TAG_OBJECT_SHA}" "${TAG_COMMIT_SHA}"
RULESET_ID="$(verify_immutable_tag_ruleset "${PROTECTED_TAG_RULESET_ID}" "${PROTECTED_TAG_RULESET_UPDATED_AT}")"
PRECREATE_STATE="${RUNNER_TEMP}/vifty-release-precreate-state.json"
if query_release_by_tag "${PRECREATE_STATE}"; then
echo "A GitHub release already exists for ${RELEASE_TAG}; refusing to mutate it." >&2
exit 1
else
PRECREATE_STATUS=$?
if [[ "${PRECREATE_STATUS}" -ne 3 ]]; then
echo "Could not prove that ${RELEASE_TAG} has no existing GitHub release." >&2
exit 1
fi
fi
CREATE_NONCE="$(/usr/bin/openssl rand -hex 32)"
[[ "${CREATE_NONCE}" =~ ^[0-9a-f]{64}$ ]]
RELEASE_MARKER="<!-- vifty-release-owner:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}:${CREATE_NONCE} -->"
DRAFT_TITLE="Vifty ${VERSION} [draft ${CREATE_NONCE}]"
FINAL_TITLE="Vifty ${VERSION}"
CREATE_PAYLOAD="${RUNNER_TEMP}/vifty-release-create.json"
CREATE_RESPONSE="${RUNNER_TEMP}/vifty-release-create-response.json"
EXPECTED_BODY_PATH="${RUNNER_TEMP}/vifty-release-expected-body.txt"
EXPECTED_ASSETS_PATH="${RUNNER_TEMP}/vifty-release-expected-assets.json"
printf '[]\n' > "${EXPECTED_ASSETS_PATH}"
ruby -rjson -e '
checklist_path, output_path, tag, title, marker = ARGV
body = File.read(checklist_path).rstrip + "\n\n" + marker + "\n"
payload = {
"tag_name" => tag,
"name" => title,
"body" => body,
"draft" => true,
"prerelease" => false,
"generate_release_notes" => true
}
File.write(output_path, JSON.generate(payload) + "\n")
' "${RELEASE_CHECKLIST_PATH}" "${CREATE_PAYLOAD}" "${RELEASE_TAG}" "${DRAFT_TITLE}" "${RELEASE_MARKER}"
RELEASE_ID=""
CONTAINMENT_REQUIRED=1
trap containment_guard EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if release_gh api --hostname github.com --method POST \
"repos/${GITHUB_REPOSITORY}/releases" \
--input "${CREATE_PAYLOAD}" > "${CREATE_RESPONSE}"; then
CREATE_STATUS=0
else
CREATE_STATUS=$?
fi
CREATE_RESPONSE_STATUS=1
if [[ -s "${CREATE_RESPONSE}" ]]; then
if RELEASE_ID="$(capture_created_release_id "${CREATE_RESPONSE}")"; then
if capture_owned_draft_release_id "${CREATE_RESPONSE}" > /dev/null && \
ruby -rjson -e '
release = JSON.parse(File.read(ARGV.fetch(0)))
payload = JSON.parse(File.read(ARGV.fetch(1)))
body = release["body"]
abort("draft creation response is missing its exact body") unless body.is_a?(String)
submitted_body = payload["body"]
abort("draft creation payload is missing its exact body") unless submitted_body.is_a?(String)
abort("draft creation response does not preserve the submitted body prefix") unless
body.b.start_with?(submitted_body.b)
abort("draft creation response unexpectedly marked prerelease") unless release["prerelease"] == false
File.binwrite(ARGV.fetch(2), body.b)
' "${CREATE_RESPONSE}" "${CREATE_PAYLOAD}" "${EXPECTED_BODY_PATH}"; then
CREATE_RESPONSE_STATUS=0
fi
fi
fi
CREATED_STATE="${RUNNER_TEMP}/vifty-release-created-state.json"
if [[ "${CREATE_STATUS}" -eq 0 ]] && \
[[ "${CREATE_RESPONSE_STATUS}" -eq 0 ]] && \
[[ -n "${RELEASE_ID}" ]]; then
if wait_for_release_state_by_id "${CREATED_STATE}" "${RELEASE_ID}" true "${DRAFT_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}"; then
CREATE_QUERY_STATUS=0
else
CREATE_QUERY_STATUS=$?
fi
else
CREATE_QUERY_STATUS=1
fi
if [[ "${CREATE_STATUS}" -ne 0 ]] || \
[[ "${CREATE_RESPONSE_STATUS}" -ne 0 ]] || \
[[ "${CREATE_QUERY_STATUS}" -ne 0 ]] || \
[[ -z "${RELEASE_ID}" ]] || \
! verify_release_state "${CREATED_STATE}" "${RELEASE_ID}" true "${DRAFT_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}"; then
echo "REST draft creation was ambiguous or its direct immutable-ID/marker post-state mismatched; enforcing owned-only containment." >&2
exit 1
fi
if ! upload_release_asset_by_id "${ZIP_PATH}" "Vifty ${VERSION} notarized app"; then
exit 1
fi
if ! upload_release_asset_by_id "${CHECKSUM_PATH}" "Vifty ${VERSION} SHA-256 checksum"; then
exit 1
fi
if ! upload_release_asset_by_id "${SUMMARY_PATH}" "Vifty ${VERSION} release artifact verification summary"; then
exit 1
fi
if ! upload_release_asset_by_id "${RELEASE_CHECKLIST_PATH}" "Vifty ${VERSION} release checklist"; then
exit 1
fi
if ! verify_remote_tag_identity "${TAG_OBJECT_SHA}" "${TAG_COMMIT_SHA}" || \
! verify_immutable_tag_ruleset "${RULESET_ID}" "${PROTECTED_TAG_RULESET_UPDATED_AT}" > /dev/null; then
echo "Tag identity or immutable ruleset changed before promotion; enforcing containment." >&2
exit 1
fi
PREPROMOTION_STATE="${RUNNER_TEMP}/vifty-release-prepromotion-state.json"
if ! query_release_by_id "${RELEASE_ID}" "${PREPROMOTION_STATE}" || \
! verify_release_state "${PREPROMOTION_STATE}" "${RELEASE_ID}" true "${DRAFT_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}"; then
echo "Draft body or immutable asset identity changed before promotion; enforcing containment." >&2
exit 1
fi
PROMOTION_RESPONSE="${RUNNER_TEMP}/vifty-release-promotion-response.json"
if release_gh api --hostname github.com --method PATCH \
"repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-f name="${FINAL_TITLE}" \
-F draft=false \
-F prerelease=false > "${PROMOTION_RESPONSE}"; then
PROMOTION_STATUS=0
else
PROMOTION_STATUS=$?
fi
PUBLISHED_STATE="${RUNNER_TEMP}/vifty-release-published-state.json"
if wait_for_release_state_by_id "${PUBLISHED_STATE}" "${RELEASE_ID}" false "${FINAL_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}"; then
PUBLISHED_QUERY_STATUS=0
else
PUBLISHED_QUERY_STATUS=$?
fi
if [[ "${PROMOTION_STATUS}" -ne 0 ]] || \
[[ "${PUBLISHED_QUERY_STATUS}" -ne 0 ]] || \
! verify_release_state "${PUBLISHED_STATE}" "${RELEASE_ID}" false "${FINAL_TITLE}" \
"${EXPECTED_BODY_PATH}" "${EXPECTED_ASSETS_PATH}" || \
! verify_remote_tag_identity "${TAG_OBJECT_SHA}" "${TAG_COMMIT_SHA}" || \
! verify_immutable_tag_ruleset "${RULESET_ID}" "${PROTECTED_TAG_RULESET_UPDATED_AT}" > /dev/null; then
echo "Promotion was ambiguous or its immutable-ID/tag/ruleset post-state mismatched; enforcing containment." >&2
exit 1
fi
CONTAINMENT_REQUIRED=0
trap - EXIT INT TERM
unset RELEASE_GH_TOKEN
echo "Published verified GitHub release ID ${RELEASE_ID} for ${RELEASE_TAG}."