diff --git a/.github/workflows/native-github-release.yml b/.github/workflows/native-github-release.yml index 38a355abd..76bec56bb 100644 --- a/.github/workflows/native-github-release.yml +++ b/.github/workflows/native-github-release.yml @@ -13,8 +13,10 @@ name: Native GitHub Release # The Release is titled with the plain version. A candidate is a prerelease; # a final release is Latest only when the live latest alias names its index # in both registries. The job holds the rolling-alias lock, so no alias -# moves between that read and the write. An existing Release is left as it -# is. +# moves between that read and the write. A final release's body is the +# identity table plus docs/releases/.md from the tag commit (#1111). +# An existing Release is left as it is; repair_legacy_notes only replaces an +# exact identity-only body from the earlier writer with the tagged notes. on: workflow_dispatch: inputs: @@ -35,6 +37,11 @@ on: description: 'Final releases require the exact qualification manifest used for alias promotion; candidates leave empty' required: false type: string + repair_legacy_notes: + description: 'Replace an existing identity-only body (written before #1111) with the tagged notes; nothing else changes' + required: false + type: boolean + default: false permissions: contents: read @@ -82,4 +89,5 @@ jobs: RELEASE_RUN_ID: ${{ inputs.release_run_id }} RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} PROMOTION_EVIDENCE: ${{ inputs.promotion_evidence }} + REPAIR_LEGACY_NOTES: ${{ inputs.repair_legacy_notes }} run: bash scripts/ci/native-release-github-release.sh diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 0e7d4307c..600f018ff 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -292,11 +292,24 @@ commit=0123456789abcdef0123456789abcdef01234567 cat > "$scratch/gh" <<'FAKE' #!/usr/bin/env bash case "$1 $2" in - "release view") [[ -n "${EXISTING:-}" ]] || exit 1; printf '%s\n' "$EXISTING"; exit 0 ;; - "release create") + "release view") + [[ -n "${EXISTING:-}" ]] || exit 1 + if [[ -s "$GH_NOTES" && "${EDIT_STICKS:-yes}" == yes ]] && grep -q -- '^release edit ' "$GH_LOG"; then + jq -c --rawfile body "$GH_NOTES" '.body = $body' <<<"$EXISTING" + else printf '%s\n' "$EXISTING"; fi + exit 0 ;; + "release create"|"release edit") printf '%s\n' "$*" >> "$GH_LOG" while [[ $# -gt 0 && "$1" != --notes ]]; do shift; done printf '%s' "${2:-}" > "$GH_NOTES"; exit 0 ;; + "api -H") + [[ "$3" == 'Accept: application/vnd.github.raw' && "$4" == "repos/example/server/contents/docs/releases/"*".md?ref=$COMMIT" ]] || exit 1 + [[ "${TAGGED_NOTES-default}" != missing ]] || exit 1 + version=${4#repos/example/server/contents/docs/releases/}; version=${version%%.md\?*} + if [[ "${TAGGED_NOTES-default}" == default ]]; then + printf '# Durable Workflow Server %s (native)\n\nSee the [upgrade](3.0.0.md#upgrade), the\n[chart guide](../../k8s/UPGRADING.md), [this](#changes) and [PR 1](https://github.com/example/server/pull/1).\n' "$version" + else printf '%s\n' "$TAGGED_NOTES"; fi + exit 0 ;; "attestation verify") printf '%s\n' "$*" >> "$GH_ATTEST_LOG" [[ "${ATTEST:-ok}" == ok ]] || exit 1 @@ -308,7 +321,9 @@ case "$path" in repos/example/server/git/ref/tags/*) printf '%s\n' "${TAG_OBJECT:-commit $COMMIT}" ;; repos/example/server/git/tags/*) printf 'commit %s\n' "$COMMIT" ;; repos/example/server/compare/*) printf '%s\n' "${COMPARE:-ahead}" ;; - repos/example/server/releases/latest) printf '%s\n' "${LATEST_TAG:-}" ;; + repos/example/server/releases/latest) + if [[ -n "${LATEST_AFTER_EDIT:-}" ]] && grep -q -- '^release edit ' "$GH_LOG"; then printf '%s\n' "$LATEST_AFTER_EDIT" + else printf '%s\n' "${LATEST_TAG:-}"; fi ;; repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT ${RUN_ATTEMPT:-1}}" ;; *) exit 1 ;; esac @@ -345,11 +360,15 @@ chmod +x "$scratch/python3" newer_index=sha256:$(printf '%064d' 0 | tr 0 b) gh_release() { # release tag, latest state [, VAR=value ...] -> the Latest choice local tag=$1 state=$2; shift 2 - : > "$scratch/gh_calls"; : > "$scratch/gh_attest" + : > "$scratch/gh_calls"; : > "$scratch/gh_attest"; : > "$scratch/gh_notes" env -i PATH="$scratch:$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" GH_NOTES="$scratch/gh_notes" \ DOCKER="$scratch/release-docker" "${images[@]}" GITHUB_REPOSITORY=example/server \ RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RELEASE_RUN_ATTEMPT=1 PROMOTION_EVIDENCE=synthetic-selected-evidence RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } + if grep -q -- '^release edit ' "$scratch/gh_calls"; then + grep -c -- '^release ' "$scratch/gh_calls" | grep -qx 1 || { echo FAILED; return; } + printf 'edit %s\n' "$(grep -oE -- '--latest=(true|false)$' "$scratch/gh_calls")"; return + fi grep -q -- '^release create ' "$scratch/gh_calls" || { echo none; return; } grep -q -- '--verify-tag --title ' "$scratch/gh_calls" || { echo FAILED; return; } tr '\n' ' ' < "$scratch/gh_calls" | grep -oE -- '--prerelease|--latest(=false)?( |$)' | tr -d ' ' | paste -sd' ' - @@ -396,6 +415,41 @@ want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" L want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false true "$notes_final")")" "$failed" want_release "existing final the alias owns but not Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" want_release "existing backport marked Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "$failed" +# A final Release carries docs/releases/.md from the tag commit +# (Server #1111), with relative links resolved against the immutable tag. +base=https://github.com/example/server/blob/3.1.0 +grep -qx '# Durable Workflow Server 3.1.0 (native)' <<<"$notes_final" || fail "a final Release must carry its tagged notes." +[[ "$(head -n1 <<<"$notes_final")" == 'Durable Workflow Server 3.1.0.' ]] || fail "the identity table must come first." +for link in "]($base/docs/releases/3.0.0.md#upgrade)" "]($base/k8s/UPGRADING.md)" "](#changes)" "](https://github.com/example/server/pull/1)"; do + grep -qF -- "$link" <<<"$notes_final" || fail "tagged notes must contain the link $link." +done +! grep -q '# Durable Workflow Server' <<<"$notes_candidate" || fail "a candidate keeps the identity table alone." +gh_release native-3.0.2 "$ours" >/dev/null +! grep -q '# Durable Workflow Server' "$scratch/gh_notes" || fail "a historical native- tag keeps the identity table alone." +want_release "final without tagged notes" "$(gh_release 3.1.0 "$ours" TAGGED_NOTES=missing)" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "Release was created without its tagged notes." +want_release "tagged notes for another version" "$(gh_release 3.1.0 "$ours" TAGGED_NOTES='# Durable Workflow Server 3.0.9 (native)')" "$failed" +want_release "tagged notes linking outside the repository" "$(gh_release 3.1.0 "$ours" TAGGED_NOTES=$'# Durable Workflow Server 3.1.0 (native)\n\n[x](../../../x.md)')" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "Release was created with a link outside the repository." +# REPAIR_LEGACY_NOTES replaces only an exact identity-only body, keeps +# Latest where it stands, and reads the result back. +legacy_final=${notes_final%%$'\n\n# Durable Workflow Server'*} +legacy_backport=${notes_backport%%$'\n\n# Durable Workflow Server'*} +want_release "legacy body, no repair asked" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$legacy_final")")" "$failed" +want_release "legacy body repaired, Latest kept" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$legacy_final")")" "edit --latest=true" +[[ "$(cat "$scratch/gh_notes")" == "$notes_final" ]] || fail "the repair must publish exactly the tagged notes." +want_release "legacy backport repaired, not Latest" "$(gh_release 3.0.2 "$theirs" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 EXISTING="$(existing 3.0.2 3.0.2 false false "$legacy_backport")")" "edit --latest=false" +want_release "older release repaired while a newer one stays Latest" "$(gh_release 3.0.2 "$theirs" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 LATEST_AFTER_EDIT=3.1.0 EXISTING="$(existing 3.0.2 3.0.2 false false "$legacy_backport")")" "edit --latest=false" +want_release "repair that moves Latest" "$(gh_release 3.0.2 "$theirs" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 LATEST_AFTER_EDIT=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$legacy_backport")")" "$failed" +want_release "complete body with repair asked" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "none" +want_release "repair of other notes" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$legacy_final extra")")" "$failed" +want_release "repair with another title" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 native-3.1.0 false false "$legacy_final")")" "$failed" +want_release "repair of a draft" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 true false "$legacy_final")")" "$failed" +want_release "repair where Latest is wrong" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$legacy_final")")" "$failed" +[[ ! -s "$scratch/gh_notes" ]] || fail "a refused repair must not edit the Release." +want_release "repair that does not read back" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true EDIT_STICKS=no LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$legacy_final")")" "$failed" +want_release "repair without tagged notes" "$(gh_release 3.1.0 "$ours" REPAIR_LEGACY_NOTES=true TAGGED_NOTES=missing LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$legacy_final")")" "$failed" +want_release "candidate has nothing to repair" "$(gh_release 3.1.0-rc.1 "$ours" REPAIR_LEGACY_NOTES=true LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0-rc.1 3.1.0-rc.1 false true "$notes_candidate")")" "none" # The bindings come first: an existing Release does not excuse a failed one. want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 379441c3d..27ec5fec9 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -17,11 +17,13 @@ # across this step, so no alias moves between the read and the write. A # release candidate is a prerelease and never Latest. An existing Release # that records exactly this verified release is left unchanged, so a rerun -# cannot move Latest either; any other existing Release is refused. Any -# failed read stops the step before it writes. +# cannot move Latest either; any other existing Release is refused, except +# that REPAIR_LEGACY_NOTES=true replaces an exact identity-only body with the +# tagged notes. Any failed read stops the step before it writes. # # Env: RELEASE_TAG, RELEASE_RUN_ID, GITHUB_REPOSITORY, GHCR_IMAGE, -# DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker commands. +# DOCKERHUB_IMAGE, optional REPAIR_LEGACY_NOTES. GH and DOCKER override the +# gh and docker commands. set -euo pipefail source scripts/ci/native-registry-absence.sh fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } @@ -59,25 +61,72 @@ if [[ "$version" != *-* ]]; then done fi -notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `%s` and `%s` |\n| Release run | https://github.com/%s/actions/runs/%s |\n' \ +identity=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `%s` and `%s` |\n| Release run | https://github.com/%s/actions/runs/%s |\n' \ "$version" "$tag" "$commit" "$index" "$DOCKERHUB_IMAGE" "$GHCR_IMAGE" "$repo" "$run_id") +# A final release publishes its tagged notes (Server #1111): the identity +# table, then docs/releases/.md read from the verified tag commit, +# never from later main. Relative links resolve against the immutable tag so +# they work on the Release page. Candidates and the frozen historical +# native-X.Y.Z tags keep the identity table alone. Missing or mismatched +# notes refuse before any write. +notes=$identity +if [[ "$version" != *-* && "$tag" != native-* ]]; then + tagged=$("$gh_bin" api -H 'Accept: application/vnd.github.raw' \ + "repos/$repo/contents/docs/releases/$version.md?ref=$commit") \ + || fail "cannot read docs/releases/$version.md at $commit." + [[ "$(head -n1 <<<"$tagged")" == "# Durable Workflow Server $version (native)" ]] \ + || fail "docs/releases/$version.md at $commit does not start with its release heading." + # shellcheck disable=SC2016 # Perl, not shell, expands these. + tagged=$(BASE="https://github.com/$repo/blob/$tag" perl -pe ' + sub resolve { my @out; for (split m{/}, "docs/releases/$_[0]") { + if ($_ eq "..") { return undef unless @out; pop @out } elsif ($_ ne "." && $_ ne "") { push @out, $_ } } + return join "/", @out } + s{\]\((?![A-Za-z][A-Za-z0-9+.-]*:|#|/)([^)\s]+)\)}{ my $p = resolve($1); + defined $p ? "]($ENV{BASE}/$p)" : die "link $1 leaves the repository\n" }ge' <<<"$tagged") \ + || fail "docs/releases/$version.md at $commit has a link outside the repository." + notes=$(printf '%s\n\n%s\n' "$identity" "$tagged") +fi # An existing Release is accepted only when it is exactly the Release this # step would publish: published (not a draft), tag, plain title, candidate # state and these exact notes, and Latest exactly when the live alias says -# so, read under the same lock. It is never edited, so a rerun cannot move -# Latest; any other Release stops the step. +# so, read under the same lock. Any other Release stops the step. +# +# REPAIR_LEGACY_NOTES=true is the one edit: when everything else matches and +# the body is exactly the identity table an earlier writer published without +# notes, only the body is replaced with these notes and read back. Latest is +# passed as it already stands, so the repair cannot move it. if existing=$("$gh_bin" release view "$tag" --repo "$repo" --json tagName,name,isDraft,isPrerelease,body 2>/dev/null); then candidate=false [[ "$version" == *-* ]] && candidate=true - jq -e --arg tag "$tag" --arg title "$version" --argjson candidate "$candidate" --arg notes "$notes" ' - .tagName == $tag and .name == $title and .isDraft == false and .isPrerelease == $candidate - and ((.body // "") | sub("\\s+$"; "")) == ($notes | sub("\\s+$"; ""))' \ - <<<"$existing" >/dev/null \ - || fail "Release $tag exists but is not exactly this verified release (draft, tag, title, candidate state or notes)." + matches() { # expected body + jq -e --arg tag "$tag" --arg title "$version" --argjson candidate "$candidate" --arg notes "$1" ' + .tagName == $tag and .name == $title and .isDraft == false and .isPrerelease == $candidate + and ((.body // "") | gsub("\r"; "") | sub("\\s+$"; "")) == ($notes | sub("\\s+$"; ""))' \ + <<<"$existing" >/dev/null + } + repair=false + if ! matches "$notes"; then + if [[ "${REPAIR_LEGACY_NOTES:-}" == true && "$notes" != "$identity" ]] && matches "$identity"; then + repair=true + else + fail "Release $tag exists but is not exactly this verified release (draft, tag, title, candidate state or notes)." + fi + fi current=$("$gh_bin" api "repos/$repo/releases/latest" --jq .tag_name) || fail "cannot read the Latest Release." if [[ "$latest" == true && "$current" != "$tag" ]] || [[ "$latest" != true && "$current" == "$tag" ]]; then fail "Release $tag exists, but Latest is $current where the latest alias calls for $([[ "$latest" == true ]] && echo "$tag" || echo "another release")." fi + if [[ "$repair" == true ]]; then + "$gh_bin" release edit "$tag" --repo "$repo" --notes "$notes" "--latest=$latest" >/dev/null \ + || fail "cannot replace the legacy notes of Release $tag." + existing=$("$gh_bin" release view "$tag" --repo "$repo" --json tagName,name,isDraft,isPrerelease,body) \ + || fail "cannot read Release $tag back." + matches "$notes" || fail "Release $tag did not read back with its tagged notes." + after=$("$gh_bin" api "repos/$repo/releases/latest" --jq .tag_name) || fail "cannot read the Latest Release back." + [[ "$after" == "$current" ]] || fail "repairing $tag moved Latest from $current to $after." + printf 'Release %s now carries its tagged notes; Latest is still %s.\n' "$tag" "$current" + exit 0 + fi printf 'Release %s is exactly this verified release; left unchanged.\n' "$tag" exit 0 fi