diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index 199cf4c69..ccd920724 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -37,7 +37,6 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} EVENT_REF_NAME: ${{ github.ref_name }} - BEFORE_SHA: ${{ github.event.before }} INPUT_DRY_RUN: ${{ inputs.dry-run }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: scripts/resolve-native-cli-release-target.sh >> "$GITHUB_OUTPUT" diff --git a/docs/release-recovery-runbook.md b/docs/release-recovery-runbook.md index 7de781eef..1743e869a 100644 --- a/docs/release-recovery-runbook.md +++ b/docs/release-recovery-runbook.md @@ -84,6 +84,23 @@ approve the `cli-release` environment if GitHub requests approval: gh run rerun --repo / --failed ``` +## Retrying after a cancelled or failed publish run + +`native-cli-publish.yml` and `dispatcher-publish.yml` only evaluate publish/ +release on push when HEAD's diff actually stamps the resolved version (the +release-please manifest/contract entry or the component's `CHANGELOG.md` +heading). Once a publish run is cancelled or fails, ordinary follow-up pushes +to main/v3-beta no longer retry it, because their HEAD commits do not stamp +that version. Recovery is limited to two paths: + +- (a) Rerun the original run, but only when the conditions in "When rerun + recovery is valid" above hold (its `headSha` is the intended release commit + and the workflow definition there is healthy). +- (b) Trigger the workflow manually via `workflow_dispatch`, which bypasses + the push gate entirely and falls back to the same state-based evaluation + (missing release / missing assets / fully published) the gate used before + this change. + ## Operational notes ### Preserved negative attestation specimen @@ -104,8 +121,10 @@ verification. Do not pin this version; use beta.49 or later. workflow concurrency group does not cancel it automatically, so it can block the newer run indefinitely. - Delete a broken release only after the version bump is merged. Deleting it - first leaves the historical version unresolved and can make later push builds - attempt to recreate it and fail. + first leaves the historical version unresolved; later push builds will not + attempt to recreate it on their own (see "Retrying after a cancelled or + failed publish run" above), so recovery still requires an explicit rerun or + `workflow_dispatch`. - Do not grant GitHub Actions tag-ruleset bypass permissions. That does not resolve the attestation invariant and expands bot authority unnecessarily. - A broken release may remain published while the roll-forward release is cut; diff --git a/docs/version-series-realignment.md b/docs/version-series-realignment.md index 57e1c3172..dffbb01e5 100644 --- a/docs/version-series-realignment.md +++ b/docs/version-series-realignment.md @@ -51,14 +51,26 @@ Two automations react differently to the realignment commit: fail with "no release-please commit found" until the release exists. - `.github/workflows/dispatcher-publish.yml` (`scripts/resolve-dispatcher-release-target.sh`) ignores commit subjects - entirely. On every push it derives the release tag from the - `dispatcherVersion` in the contract at HEAD and, when that release is - missing or lacks assets, builds the binaries and creates/publishes the - release with attestations at the pushed commit. This is what materializes - the realigned version as a real release. + entirely, but on push it only evaluates when HEAD's diff actually stamps + the resolved `dispatcherVersion` — a `+` line matching + `"dispatcherVersion": ""` in the contract or a + `## []` heading in `cli/dispatcher/CHANGELOG.md` + (`release_commit_updates_dispatcher_version`). A `fix:`-titled realignment + commit still stamps both the contract and the CHANGELOG, so it passes this + gate even though its subject doesn't look like a release commit. When the + gate passes and the resolved release is missing or lacks assets, the + workflow builds the binaries and creates/publishes the release with + attestations at the pushed commit. This is what materializes the realigned + version as a real release. -If the release-please workflow failed before dispatcher-publish finished, -re-run it via `workflow_dispatch` once the release exists with all assets. +If the realignment push doesn't stamp the version (so dispatcher-publish +skips it on push), or the resulting dispatcher-publish run is cancelled or +fails, retry dispatcher-publish via `workflow_dispatch`, which bypasses the +push gate and falls back to the state-based evaluation described above. + +If instead the release-please workflow failed before dispatcher-publish +finished, re-run it via `workflow_dispatch` once the release exists with all +assets. ## What not to touch diff --git a/scripts/resolve-dispatcher-release-target.sh b/scripts/resolve-dispatcher-release-target.sh index eb286f870..4f2cd1bbf 100755 --- a/scripts/resolve-dispatcher-release-target.sh +++ b/scripts/resolve-dispatcher-release-target.sh @@ -123,6 +123,24 @@ dispatcher_release_is_prerelease() { [ "$ref_name" = "v3-beta" ] } +release_commit_updates_dispatcher_version() { + commit_sha=$1 + version=$2 + expected_contract_entry="\"dispatcherVersion\": \"$version\"" + expected_changelog_heading="## [$version]" + + commit_diff=$(git show --format= "$commit_sha" -- "$DISPATCHER_CONTRACT_PATH" cli/dispatcher/CHANGELOG.md 2>/dev/null || true) + printf '%s\n' "$commit_diff" \ + | awk -v contract_entry="$expected_contract_entry" -v changelog_heading="$expected_changelog_heading" ' + substr($0, 1, 1) == "+" && (index($0, contract_entry) > 0 || index($0, changelog_heading) > 0) { + found = 1 + } + END { + exit found ? 0 : 1 + } + ' +} + DISPATCHER_CONTRACT_PATH=cli/dispatcher/dispatchercontract/dispatcher-contract.json VERSION=$(jq -r '.dispatcherVersion' "$DISPATCHER_CONTRACT_PATH") @@ -163,6 +181,7 @@ if [ "$EVENT_NAME" = "push" ]; then fi TARGET_SHA=$(dispatcher_release_target_sha "$RELEASE_TAG") +BUILD_SHA=$(git rev-parse HEAD) IS_PRERELEASE=false if dispatcher_release_is_prerelease "$VERSION" "$EVENT_REF_NAME"; then IS_PRERELEASE=true @@ -171,6 +190,10 @@ fi if [ "$CAN_EVALUATE_DISPATCHER_RELEASE" != "true" ]; then SHOULD_PUBLISH=false SHOULD_RELEASE=false +elif [ "$EVENT_NAME" = "push" ] && ! release_commit_updates_dispatcher_version "$BUILD_SHA" "$VERSION"; then + echo "HEAD commit $BUILD_SHA does not stamp dispatcher version $VERSION; skipping dispatcher publish. Retry an incomplete release with workflow_dispatch." >&2 + SHOULD_PUBLISH=false + SHOULD_RELEASE=false elif release_is_published_with_dispatcher_assets "$RELEASE_TAG"; then SHOULD_PUBLISH=false SHOULD_RELEASE=false diff --git a/scripts/resolve-native-cli-release-target.sh b/scripts/resolve-native-cli-release-target.sh index 2aa6e86ac..777a7a6d9 100755 --- a/scripts/resolve-native-cli-release-target.sh +++ b/scripts/resolve-native-cli-release-target.sh @@ -9,21 +9,6 @@ EVENT_REF_NAME=${EVENT_REF_NAME:-} INPUT_RELEASE_TAG=${INPUT_RELEASE_TAG:-} INPUT_DRY_RUN=${INPUT_DRY_RUN:-false} RELEASE_DATA="" -CLI_RELEASE_INPUT_PATHS=" -cli/.go-version -cli/go.work -cli/go.work.sum -layout-contract.json -cli/common -cli/project-runner/cmd -cli/project-runner/go.mod -cli/project-runner/go.sum -cli/project-runner/internal -scripts/build-go-cli.sh -scripts/go-cli-toolchain.sh -scripts/package-go-cli.sh -scripts/verify-native-cli-release-assets.sh -" is_semver_version() { version=$1 @@ -118,33 +103,6 @@ release_is_published() { [ "$is_draft" = "false" ] } -latest_cli_asset_release_tag() { - excluded_tag=$1 - release_list=$(gh release list --limit 100 --json tagName,isDraft) - release_tags=$(printf '%s\n' "$release_list" | jq -r '.[] | select(.isDraft == false) | .tagName') - for release_tag in $release_tags; do - if [ "$release_tag" = "$excluded_tag" ]; then - continue - fi - - if release_has_all_cli_assets "$release_tag"; then - printf '%s\n' "$release_tag" - return - fi - done -} - -cli_release_inputs_changed() { - base_ref=$1 - head_ref=$2 - - if ! git diff --quiet "$base_ref" "$head_ref" -- $CLI_RELEASE_INPUT_PATHS; then - return 0 - fi - - return 1 -} - release_commit_updates_cli_version() { commit_sha=$1 version=$2 @@ -230,29 +188,19 @@ RELEASE_TARGET_SHA=$BUILD_SHA if [ "$CAN_EVALUATE_CLI_RELEASE" != "true" ]; then SHOULD_PUBLISH=false SHOULD_RELEASE=false -elif release_is_published "$RELEASE_TAG"; then - SHOULD_RELEASE=false -else - SHOULD_RELEASE=true -fi - -if [ "$CAN_EVALUATE_CLI_RELEASE" != "true" ]; then +elif [ "$EVENT_NAME" = "push" ] && ! release_commit_updates_cli_version "$BUILD_SHA" "$VERSION"; then + echo "HEAD commit $BUILD_SHA does not stamp project runner version $VERSION; skipping native CLI publish. Retry an incomplete release with workflow_dispatch." >&2 SHOULD_PUBLISH=false + SHOULD_RELEASE=false elif release_is_published_with_cli_assets "$RELEASE_TAG"; then SHOULD_PUBLISH=false + SHOULD_RELEASE=false else - PREVIOUS_CLI_RELEASE_TAG=$(latest_cli_asset_release_tag "$RELEASE_TAG") - if [ -z "$PREVIOUS_CLI_RELEASE_TAG" ]; then - echo "No previous project runner asset release found; publishing native project runner assets." >&2 - SHOULD_PUBLISH=true - elif release_commit_updates_cli_version "$BUILD_SHA" "$VERSION"; then - echo "Project runner release metadata changed in $BUILD_SHA; publishing native project runner assets." >&2 - SHOULD_PUBLISH=true - elif cli_release_inputs_changed "$PREVIOUS_CLI_RELEASE_TAG" "$TARGET_SHA"; then - echo "Project runner release inputs changed since $PREVIOUS_CLI_RELEASE_TAG; publishing native project runner assets." >&2 - SHOULD_PUBLISH=true + SHOULD_PUBLISH=true + if release_is_published "$RELEASE_TAG"; then + SHOULD_RELEASE=false else - echo "Project runner release inputs are unchanged since $PREVIOUS_CLI_RELEASE_TAG; skipping native CLI publish." >&2 + SHOULD_RELEASE=true fi fi diff --git a/scripts/test-resolve-dispatcher-release-target.sh b/scripts/test-resolve-dispatcher-release-target.sh index 612eb3297..b0736a413 100755 --- a/scripts/test-resolve-dispatcher-release-target.sh +++ b/scripts/test-resolve-dispatcher-release-target.sh @@ -33,6 +33,23 @@ if [ "$1" = "rev-list" ] && [ "$2" = "-n" ] && [ "$3" = "1" ]; then exit 1 fi +if [ "$1" = "show" ]; then + if [ "${DISPATCHER_BUILD_COMMIT_STAMPS:-false}" != "true" ]; then + exit 0 + fi + case "${DISPATCHER_STAMP_KIND:-contract}" in + contract) + printf '%s\n' 'diff --git a/cli/dispatcher/dispatchercontract/dispatcher-contract.json b/cli/dispatcher/dispatchercontract/dispatcher-contract.json' + printf '+ "dispatcherVersion": "%s"\n' "$CURRENT_VERSION" + ;; + changelog) + printf '%s\n' 'diff --git a/cli/dispatcher/CHANGELOG.md b/cli/dispatcher/CHANGELOG.md' + printf '+## [%s]\n' "$CURRENT_VERSION" + ;; + esac + exit 0 +fi + echo "unexpected git command: $*" >&2 exit 1 MOCK_GIT @@ -81,27 +98,37 @@ write_contract() { '}' > cli/dispatcher/dispatchercontract/dispatcher-contract.json } +# Runs the resolver with mocked git/gh and returns stdout; stderr is captured to +# "$TMP_DIR/$name/stderr.txt" for tests that need to assert skip messages. run_case() { name=$1 release_state=$2 has_assets=$3 tag_sha=${4:-} ref_name=${5:-v3-beta} + event_name=${6:-push} + build_commit_stamps=${7:-true} + stamp_kind=${8:-contract} + version=${9:-3.0.0} work_dir="$TMP_DIR/$name" mkdir -p "$work_dir" ( cd "$work_dir" - write_contract "3.0.0" + write_contract "$version" mock_bin="$work_dir/bin" write_mock_commands "$mock_bin" PATH="$mock_bin:$ORIGINAL_PATH" \ - EVENT_NAME=push \ + EVENT_NAME="$event_name" \ EVENT_REF_NAME="$ref_name" \ DISPATCHER_RELEASE_STATE="$release_state" \ DISPATCHER_RELEASE_HAS_ASSETS="$has_assets" \ DISPATCHER_TAG_SHA_VALUE="$tag_sha" \ - "$SCRIPT" + DISPATCHER_BUILD_COMMIT_STAMPS="$build_commit_stamps" \ + DISPATCHER_STAMP_KIND="$stamp_kind" \ + CURRENT_VERSION="$version" \ + "$SCRIPT" > output.txt 2> stderr.txt ) + cat "$work_dir/output.txt" } assert_contains() { @@ -114,6 +141,18 @@ assert_contains() { fi } +assert_file_contains() { + file=$1 + expected=$2 + if ! grep -F -- "$expected" "$file" >/dev/null; then + echo "Expected $file to contain: $expected" >&2 + echo "Actual content:" >&2 + cat "$file" >&2 + exit 1 + fi +} + +# Verifies a push with a stamping commit publishes and releases when no dispatcher release exists yet. test_missing_release_publishes() { output=$(run_case missing-release missing false) assert_contains "$output" "publish=true" @@ -122,18 +161,21 @@ test_missing_release_publishes() { assert_contains "$output" "prerelease=true" } +# Verifies a push with a stamping commit skips when the dispatcher release is already fully published. test_complete_release_skips() { output=$(run_case complete-release published true) assert_contains "$output" "publish=false" assert_contains "$output" "release=false" } +# Verifies a push with a stamping commit uploads remaining assets onto a published release missing assets. test_published_release_with_missing_assets_uploads_assets() { output=$(run_case missing-assets published false) assert_contains "$output" "publish=true" assert_contains "$output" "release=false" } +# Verifies the existing release tag SHA is reported when following up on a published release missing assets. test_published_release_with_missing_assets_uses_existing_tag_sha() { output=$(run_case missing-assets-followup published false release-sha) assert_contains "$output" "publish=true" @@ -141,6 +183,7 @@ test_published_release_with_missing_assets_uses_existing_tag_sha() { assert_contains "$output" "sha=release-sha" } +# Verifies a push with a stamping commit on main is not treated as a prerelease. test_main_branch_release_is_not_prerelease() { output=$(run_case main-missing-release missing false "" main) assert_contains "$output" "publish=true" @@ -148,8 +191,33 @@ test_main_branch_release_is_not_prerelease() { assert_contains "$output" "prerelease=false" } +# Verifies a push without a stamping commit skips publish/release and points to workflow_dispatch for retries. +test_push_unstamped_skips_and_reports_workflow_dispatch() { + output=$(run_case push-unstamped missing false "" v3-beta push false) + assert_contains "$output" "publish=false" + assert_contains "$output" "release=false" + assert_file_contains "$TMP_DIR/push-unstamped/stderr.txt" "does not stamp dispatcher version" + assert_file_contains "$TMP_DIR/push-unstamped/stderr.txt" "workflow_dispatch" +} + +# Verifies a changelog-only stamp (version-series-realignment commits) still publishes on push. +test_push_changelog_only_stamp_publishes() { + output=$(run_case push-changelog-only missing false "" v3-beta push true changelog) + assert_contains "$output" "publish=true" +} + +# Verifies workflow_dispatch bypasses the push gate and publishes via state-based evaluation when no release exists. +test_dispatch_unstamped_missing_release_publishes() { + output=$(run_case dispatch-missing missing false "" v3-beta workflow_dispatch false) + assert_contains "$output" "publish=true" + assert_contains "$output" "release=true" +} + test_missing_release_publishes test_complete_release_skips test_published_release_with_missing_assets_uploads_assets test_published_release_with_missing_assets_uses_existing_tag_sha test_main_branch_release_is_not_prerelease +test_push_unstamped_skips_and_reports_workflow_dispatch +test_push_changelog_only_stamp_publishes +test_dispatch_unstamped_missing_release_publishes diff --git a/scripts/test-resolve-native-cli-release-target.sh b/scripts/test-resolve-native-cli-release-target.sh index fcbfbd8d3..c56def9d5 100755 --- a/scripts/test-resolve-native-cli-release-target.sh +++ b/scripts/test-resolve-native-cli-release-target.sh @@ -20,7 +20,7 @@ write_mock_commands() { #!/bin/sh set -eu -emit_cli_release_diff() { +emit_full_stamp() { version=$1 printf '%s\n' 'diff --git a/.release-please-manifest.json b/.release-please-manifest.json' printf '+ "cli/project-runner": "%s"\n' "$version" @@ -28,22 +28,28 @@ emit_cli_release_diff() { printf '+## [%s]\n' "$version" } +emit_changelog_only_stamp() { + version=$1 + printf '%s\n' 'diff --git a/cli/project-runner/CHANGELOG.md b/cli/project-runner/CHANGELOG.md' + printf '+## [%s]\n' "$version" +} + case "$1" in - diff) - if [ "$CLI_SOURCE_CHANGED" = "true" ] || [ "$CONTRACT_CHANGED" = "true" ] || [ "$CLI_REQUIREMENT_CHANGED" = "true" ]; then - exit 1 - fi - exit 0 - ;; rev-parse) printf '%s\n' "${BUILD_SHA_VALUE:-target-sha}" ;; show) - commit_sha=$3 - if [ "$commit_sha" = "${BUILD_SHA_VALUE:-target-sha}" ] && [ "${BUILD_COMMIT_UPDATES_CLI:-false}" = "true" ]; then - emit_cli_release_diff "$CURRENT_VERSION" - exit 0 - fi + case "${STAMP_KIND:-none}" in + manifest) + emit_full_stamp "$CURRENT_VERSION" + ;; + changelog) + emit_changelog_only_stamp "$CURRENT_VERSION" + ;; + none) + : + ;; + esac exit 0 ;; *) @@ -66,51 +72,31 @@ asset_json() { printf '[]' } -release_json() { - state=$1 - has_assets=$2 - if [ "$state" = "missing" ]; then +if [ "$1" = "release" ] && [ "$2" = "view" ]; then + tag=$3 + if [ "$tag" != "uloop-project-runner-v$CURRENT_VERSION" ]; then echo "release not found" >&2 exit 1 fi - if [ "$state" = "error" ]; then + if [ "$CURRENT_RELEASE_STATE" = "missing" ]; then + echo "release not found" >&2 + exit 1 + fi + + if [ "$CURRENT_RELEASE_STATE" = "error" ]; then echo "gh auth failed" >&2 exit 1 fi is_draft=false - if [ "$state" = "draft" ]; then + if [ "$CURRENT_RELEASE_STATE" = "draft" ]; then is_draft=true fi printf '{"isDraft":%s,"assets":' "$is_draft" - asset_json "$has_assets" + asset_json "$CURRENT_RELEASE_HAS_ASSETS" printf '}\n' -} - -if [ "$1" = "release" ] && [ "$2" = "view" ]; then - tag=$3 - if [ "$tag" = "uloop-project-runner-v$CURRENT_VERSION" ]; then - release_json "$CURRENT_RELEASE_STATE" "$CURRENT_RELEASE_HAS_ASSETS" - exit 0 - fi - - if [ -n "$PREVIOUS_RELEASE_TAG" ] && [ "$tag" = "$PREVIOUS_RELEASE_TAG" ]; then - release_json published "$PREVIOUS_RELEASE_HAS_ASSETS" - exit 0 - fi - - exit 1 -fi - -if [ "$1" = "release" ] && [ "$2" = "list" ]; then - if [ -n "$PREVIOUS_RELEASE_TAG" ]; then - printf '[{"tagName":"%s","isDraft":false}]\n' "$PREVIOUS_RELEASE_TAG" - exit 0 - fi - - printf '[]\n' exit 0 fi @@ -154,15 +140,6 @@ assert_line_equals() { fi } -assert_script_contains() { - expected=$1 - - if ! grep -F "$expected" "$SCRIPT" >/dev/null; then - echo "Expected $SCRIPT to contain: $expected" >&2 - exit 1 - fi -} - run_success_case() { name=$1 current_version=$2 @@ -170,16 +147,10 @@ run_success_case() { branch_name=$4 current_release_state=$5 current_release_has_assets=$6 - previous_release_tag=$7 - previous_release_has_assets=$8 - cli_source_changed=$9 - contract_changed=${10} - cli_requirement_changed=${11} - expected_publish=${12} - expected_release=${13} - expected_sha=${14:-target-sha} - build_sha_value=${15:-target-sha} - build_commit_updates_cli=${16:-false} + stamp_kind=$7 + expected_publish=$8 + expected_release=$9 + build_sha_value=${10:-target-sha} work_dir="$TMP_DIR/$name" mock_bin="$work_dir/bin" @@ -193,17 +164,11 @@ run_success_case() { CURRENT_VERSION="$current_version" \ CURRENT_RELEASE_STATE="$current_release_state" \ CURRENT_RELEASE_HAS_ASSETS="$current_release_has_assets" \ + STAMP_KIND="$stamp_kind" \ BUILD_SHA_VALUE="$build_sha_value" \ - BUILD_COMMIT_UPDATES_CLI="$build_commit_updates_cli" \ - PREVIOUS_RELEASE_TAG="$previous_release_tag" \ - PREVIOUS_RELEASE_HAS_ASSETS="$previous_release_has_assets" \ - CLI_SOURCE_CHANGED="$cli_source_changed" \ - CONTRACT_CHANGED="$contract_changed" \ - CLI_REQUIREMENT_CHANGED="$cli_requirement_changed" \ EVENT_NAME="$event_name" \ EVENT_REF_NAME="$branch_name" \ - BEFORE_SHA=before \ - INPUT_RELEASE_TAG= \ + INPUT_RELEASE_TAG='' \ INPUT_DRY_RUN=false \ "$SCRIPT" > output.txt 2> stderr.txt @@ -211,7 +176,7 @@ run_success_case() { assert_line_equals output.txt "release=$expected_release" assert_line_equals output.txt "tag=uloop-project-runner-v$current_version" assert_line_equals output.txt "version=$current_version" - assert_line_equals output.txt "sha=$expected_sha" + assert_line_equals output.txt "sha=$build_sha_value" assert_line_equals output.txt "build_sha=$build_sha_value" assert_line_equals output.txt "dry_run=false" ) @@ -225,6 +190,7 @@ run_failure_case() { expected_error=$5 current_release_state=${6:-missing} input_release_tag=${7:-} + stamp_kind=${8:-manifest} work_dir="$TMP_DIR/$name" mock_bin="$work_dir/bin" @@ -239,15 +205,10 @@ run_failure_case() { CURRENT_VERSION="$current_version" \ CURRENT_RELEASE_STATE="$current_release_state" \ CURRENT_RELEASE_HAS_ASSETS=false \ + STAMP_KIND="$stamp_kind" \ BUILD_SHA_VALUE=target-sha \ - PREVIOUS_RELEASE_TAG=uloop-project-runner-v3.0.0-beta.1 \ - PREVIOUS_RELEASE_HAS_ASSETS=true \ - CLI_SOURCE_CHANGED=false \ - CONTRACT_CHANGED=false \ - CLI_REQUIREMENT_CHANGED=false \ EVENT_NAME="$event_name" \ EVENT_REF_NAME="$branch_name" \ - BEFORE_SHA=before \ INPUT_RELEASE_TAG="$input_release_tag" \ INPUT_DRY_RUN=false \ "$SCRIPT" > output.txt 2> stderr.txt @@ -263,49 +224,73 @@ run_failure_case() { ) } -# Verifies already published complete Project runner assets are not rebuilt. -test_complete_current_release_skips() { - run_success_case current-complete 3.0.0-beta.2 push v3-beta published true uloop-project-runner-v3.0.0-beta.1 true true false false false false +# Verifies push with a stamping commit publishes and releases when no release exists yet. +test_push_stamped_missing_release_publishes() { + run_success_case push-stamped-missing 3.0.0-beta.3 push v3-beta missing false manifest true true +} + +# Verifies push with a stamping commit publishes remaining assets onto a published release missing assets. +test_push_stamped_published_missing_assets_publishes() { + run_success_case push-stamped-published-missing-assets 3.0.0-beta.3 push v3-beta published false manifest true false } -# Verifies package-only version changes do not publish Project runner assets. -test_package_version_change_without_cli_change_skips() { - run_success_case package-only 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true false false false false true +# Verifies push with a stamping commit skips when the release is already fully published with all assets. +test_push_stamped_fully_published_skips() { + run_success_case push-stamped-fully-published 3.0.0-beta.3 push v3-beta published true manifest false false } -# Verifies CLI source changes publish assets on the current release tag. -test_cli_change_publishes() { - run_success_case cli-change 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true +# Verifies push without a stamping commit skips publish/release and points to workflow_dispatch for retries. +test_push_unstamped_skips_and_reports_workflow_dispatch() { + work_dir="$TMP_DIR/push-unstamped" + mock_bin="$work_dir/bin" + mkdir -p "$work_dir" + write_mock_commands "$mock_bin" + + ( + cd "$work_dir" + write_manifest 3.0.0-beta.3 + PATH="$mock_bin:$ORIGINAL_PATH" \ + CURRENT_VERSION=3.0.0-beta.3 \ + CURRENT_RELEASE_STATE=missing \ + CURRENT_RELEASE_HAS_ASSETS=false \ + STAMP_KIND=none \ + BUILD_SHA_VALUE=target-sha \ + EVENT_NAME=push \ + EVENT_REF_NAME=v3-beta \ + INPUT_RELEASE_TAG='' \ + INPUT_DRY_RUN=false \ + "$SCRIPT" > output.txt 2> stderr.txt + + assert_line_equals output.txt "publish=false" + assert_line_equals output.txt "release=false" + assert_contains stderr.txt "does not stamp project runner version" + assert_contains stderr.txt "workflow_dispatch" + ) } -# Verifies missing Project runner assets can be uploaded to an already published package release. -test_published_current_release_can_receive_cli_assets() { - run_success_case published-missing-assets 3.0.0-beta.3 push v3-beta published false uloop-project-runner-v3.0.0-beta.1 true true false false true false +# Verifies a changelog-only stamp (version-series-realignment commits) still publishes on push. +test_push_changelog_only_stamp_publishes() { + run_success_case push-changelog-only 3.0.0-beta.3 push v3-beta missing false changelog true true } -# Verifies non-version CLI contract changes publish assets. -test_cli_contract_change_publishes() { - run_success_case contract-change 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true false true false true true +# Verifies workflow_dispatch bypasses the push gate and publishes/releases via state-based evaluation when no release exists. +test_dispatch_unstamped_missing_release_publishes() { + run_success_case dispatch-missing 3.0.0-beta.3 workflow_dispatch v3-beta missing false none true true } -# Verifies CLI requirement version bumps publish assets for that required tag. -test_cli_requirement_change_publishes() { - run_success_case cli-requirement-change 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true false false true true true +# Verifies workflow_dispatch state-based evaluation still uploads missing assets onto a published release. +test_dispatch_unstamped_published_missing_assets_publishes() { + run_success_case dispatch-missing-assets 3.0.0-beta.3 workflow_dispatch v3-beta published false none true false } -# Verifies Project runner release metadata-only release commits still publish native Project runner assets. -test_cli_release_metadata_change_publishes() { - run_success_case cli-release-metadata-change 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true false false false true true target-sha target-sha true +# Verifies workflow_dispatch state-based evaluation skips a fully published release. +test_dispatch_unstamped_fully_published_skips() { + run_success_case dispatch-fully-published 3.0.0-beta.3 workflow_dispatch v3-beta published true none false false } # Verifies publishing always targets the approved event-head commit. test_release_target_is_event_head() { - run_success_case event-head-target 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true build-sha build-sha -} - -# Verifies the first Project runner asset release is published when no previous asset tag exists. -test_missing_previous_cli_release_publishes() { - run_success_case bootstrap 3.0.0-beta.0 push v3-beta missing false "" false false false false true true + run_success_case event-head-target 3.0.0-beta.2 push v3-beta missing false manifest true true build-sha } # Verifies main refuses prerelease versions. @@ -320,7 +305,7 @@ test_v3_beta_stable_fails() { # Verifies release lookup failures other than not-found are not treated as missing releases. test_release_lookup_error_fails() { - run_failure_case release-lookup-error 3.0.0-beta.3 push v3-beta "gh auth failed" error + run_failure_case release-lookup-error 3.0.0-beta.3 push v3-beta "gh auth failed" error "" manifest } # Verifies explicit project runner tags must use a full SemVer suffix. @@ -335,19 +320,18 @@ test_invalid_release_tag_numeric_prerelease_fails() { # Verifies prerelease identifiers cannot be empty. test_invalid_release_tag_empty_prerelease_identifier_fails() { - run_failure_case invalid-release-tag-empty-prerelease 3.0.0-beta.3 push v3-beta "Invalid release tag: uloop-project-runner-v3.0.0-alpha..1" missing uloop-project-runner-v3.0.0-alpha..1 + run_failure_case invalid-release-tag-empty-prerelease-identifier 3.0.0-beta.3 push v3-beta "Invalid release tag: uloop-project-runner-v3.0.0-alpha..1" missing uloop-project-runner-v3.0.0-alpha..1 } -assert_script_contains "cli/project-runner/go.mod" -test_complete_current_release_skips -test_package_version_change_without_cli_change_skips -test_cli_change_publishes -test_published_current_release_can_receive_cli_assets -test_cli_contract_change_publishes -test_cli_requirement_change_publishes -test_cli_release_metadata_change_publishes +test_push_stamped_missing_release_publishes +test_push_stamped_published_missing_assets_publishes +test_push_stamped_fully_published_skips +test_push_unstamped_skips_and_reports_workflow_dispatch +test_push_changelog_only_stamp_publishes +test_dispatch_unstamped_missing_release_publishes +test_dispatch_unstamped_published_missing_assets_publishes +test_dispatch_unstamped_fully_published_skips test_release_target_is_event_head -test_missing_previous_cli_release_publishes test_main_prerelease_fails test_v3_beta_stable_fails test_release_lookup_error_fails