From fef13fefd1cce9d6938b695b4101feda3bbc5818 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 1 May 2026 21:09:09 +0300 Subject: [PATCH] Fix/release untagged guard (#399) * fix(release): prevent untagged fallback from tauri publish Set explicit github-script output for release_id and validate it before build jobs. This prevents tauri-action from creating untagged releases when release id wiring breaks and keeps assets attached to the intended app-v tag release. * fix(release): add source-only promote mode and harden release binding Add a source_only dispatch option to promote workflows and propagate it into channel publish so maintainers can skip platform builds and verify-nix when needed. Also validate release/tag/commit binding to prevent untagged fallback and ensure source archives stay aligned with the intended app-v tag. --- .github/workflows/next.yml | 16 +-- .github/workflows/promote-main-to-next.yml | 24 +++++ .github/workflows/promote-next-to-release.yml | 24 +++++ .github/workflows/release.yml | 15 +-- .../workflows/reusable-channel-publish.yml | 97 ++++++++++++++++--- 5 files changed, 150 insertions(+), 26 deletions(-) diff --git a/.github/workflows/next.yml b/.github/workflows/next.yml index 4a775c14..eeaa2f0a 100644 --- a/.github/workflows/next.yml +++ b/.github/workflows/next.yml @@ -1,8 +1,5 @@ name: Next Channel -# Pushes made with the default GITHUB_TOKEN from another workflow (e.g. Promote main to next) -# do not trigger push workflows — GitHub blocks recursive runs. Use workflow_run below. -# # workflow_dispatch: set "Use workflow from branch" to `main` so reusable-channel-publish.yml # is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below. on: @@ -14,13 +11,15 @@ on: - "flake.lock" - "nix/**" workflow_dispatch: - workflow_run: - workflows: ["Promote main to next"] - types: [completed] + inputs: + source_only: + description: "Create/refresh tagged release only (skip platform builds and verify-nix)" + required: false + default: false + type: boolean jobs: publish-next: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} uses: ./.github/workflows/reusable-channel-publish.yml with: channel: next @@ -29,5 +28,6 @@ jobs: target_branch: next prerelease: true draft_release: true - verify_nix: true + verify_nix: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') }} + build_platform_artifacts: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') }} secrets: inherit diff --git a/.github/workflows/promote-main-to-next.yml b/.github/workflows/promote-main-to-next.yml index 44b79755..e31b5566 100644 --- a/.github/workflows/promote-main-to-next.yml +++ b/.github/workflows/promote-main-to-next.yml @@ -2,6 +2,12 @@ name: Promote main to next on: workflow_dispatch: + inputs: + source_only: + description: "Create/refresh tagged release only (skip platform builds and verify-nix)" + required: false + default: false + type: boolean jobs: validate-main: @@ -15,6 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + actions: write steps: - uses: actions/checkout@v5 with: @@ -64,3 +71,20 @@ jobs: git commit -m "chore(release): bump next channel to ${NEW_VERSION}" - name: push next run: git push --force-with-lease origin HEAD:next + - name: dispatch Next Channel workflow + uses: actions/github-script@v7 + env: + SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }} + with: + script: | + const sourceOnly = process.env.SOURCE_ONLY === "true"; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: "next.yml", + ref: "main", + inputs: { + source_only: sourceOnly ? "true" : "false", + }, + }); + core.info(`Dispatched next.yml with source_only=${sourceOnly}`); diff --git a/.github/workflows/promote-next-to-release.yml b/.github/workflows/promote-next-to-release.yml index 2fd718ba..70bce82c 100644 --- a/.github/workflows/promote-next-to-release.yml +++ b/.github/workflows/promote-next-to-release.yml @@ -2,12 +2,19 @@ name: Promote next to release on: workflow_dispatch: + inputs: + source_only: + description: "Create/refresh tagged release only (skip platform builds and verify-nix)" + required: false + default: false + type: boolean jobs: promote: runs-on: ubuntu-latest permissions: contents: write + actions: write steps: - uses: actions/checkout@v5 with: @@ -49,3 +56,20 @@ jobs: git commit -m "chore(release): finalize release version ${NEW_VERSION}" - name: push release run: git push --force-with-lease origin HEAD:release + - name: dispatch Release Channel workflow + uses: actions/github-script@v7 + env: + SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }} + with: + script: | + const sourceOnly = process.env.SOURCE_ONLY === "true"; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: "release.yml", + ref: "main", + inputs: { + source_only: sourceOnly ? "true" : "false", + }, + }); + core.info(`Dispatched release.yml with source_only=${sourceOnly}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77765d96..7523f0e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,5 @@ name: Release Channel -# Same GITHUB_TOKEN push limitation as Next Channel (see next.yml). -# # workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`. on: push: @@ -11,13 +9,15 @@ on: - "flake.lock" - "nix/**" workflow_dispatch: - workflow_run: - workflows: ["Promote next to release"] - types: [completed] + inputs: + source_only: + description: "Create/refresh tagged release only (skip platform builds and verify-nix)" + required: false + default: false + type: boolean jobs: publish-release: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} uses: ./.github/workflows/reusable-channel-publish.yml with: channel: release @@ -25,5 +25,6 @@ jobs: target_branch: release prerelease: false draft_release: true - verify_nix: true + verify_nix: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') }} + build_platform_artifacts: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') }} secrets: inherit diff --git a/.github/workflows/reusable-channel-publish.yml b/.github/workflows/reusable-channel-publish.yml index 61f0cabe..791cef54 100644 --- a/.github/workflows/reusable-channel-publish.yml +++ b/.github/workflows/reusable-channel-publish.yml @@ -28,6 +28,11 @@ on: required: false default: true type: boolean + build_platform_artifacts: + description: "Build and upload macOS/Windows/Linux artifacts + latest.json" + required: false + default: true + type: boolean jobs: create-release: @@ -35,7 +40,7 @@ jobs: contents: write runs-on: ubuntu-latest outputs: - release_id: ${{ steps.create-release.outputs.result }} + release_id: ${{ steps.create_release.outputs.release_id }} package_version: ${{ steps.get-version.outputs.version }} release_tag: ${{ steps.tag.outputs.value }} source_commit_sha: ${{ steps.source-sha.outputs.value }} @@ -121,7 +126,7 @@ jobs: echo "$BODY" >> "$GITHUB_OUTPUT" echo "$EOF_MARKER" >> "$GITHUB_OUTPUT" - name: create or update release - id: create-release + id: create_release uses: actions/github-script@v7 env: PACKAGE_VERSION: ${{ steps.get-version.outputs.version }} @@ -139,6 +144,7 @@ jobs: const version = process.env.PACKAGE_VERSION; const targetCommitish = process.env.RELEASE_COMMIT_SHA; const name = `Psysonic v${version}`; + let releaseId = null; try { const { data } = await github.rest.repos.getReleaseByTag({ owner: context.repo.owner, @@ -154,23 +160,90 @@ jobs: draft, prerelease, }); - return data.id; + releaseId = data.id; + core.info(`Updated existing release id=${releaseId} tag=${tag}`); } catch (e) { if (e.status !== 404) throw e; + const { data } = await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + target_commitish: targetCommitish, + name, + body, + draft, + prerelease, + }); + releaseId = data.id; + core.info(`Created release id=${releaseId} tag=${tag} target_commitish=${targetCommitish}`); } - const { data } = await github.rest.repos.createRelease({ + core.setOutput("release_id", String(releaseId)); + - name: validate release id output + env: + RELEASE_ID: ${{ steps.create_release.outputs.release_id }} + TAG: ${{ steps.tag.outputs.value }} + run: | + set -euo pipefail + if [ -z "${RELEASE_ID:-}" ]; then + echo "::error::create_release did not produce release_id for tag '$TAG'" + exit 1 + fi + if ! printf '%s' "$RELEASE_ID" | grep -qE '^[0-9]+$'; then + echo "::error::release_id is not numeric: '$RELEASE_ID'" + exit 1 + fi + - name: verify release-tag binding + uses: actions/github-script@v7 + env: + RELEASE_ID: ${{ steps.create_release.outputs.release_id }} + EXPECTED_TAG: ${{ steps.tag.outputs.value }} + EXPECTED_SHA: ${{ steps.source-sha.outputs.value }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const expectedTag = process.env.EXPECTED_TAG; + const expectedSha = process.env.EXPECTED_SHA; + + const { data: release } = await github.rest.repos.getRelease({ owner: context.repo.owner, repo: context.repo.repo, - tag_name: tag, - target_commitish: targetCommitish, - name, - body, - draft, - prerelease, + release_id: releaseId, }); - return data.id; + + if (!release.tag_name || release.tag_name.startsWith("untagged-")) { + core.setFailed(`release_id=${releaseId} is bound to untagged release '${release.tag_name ?? ""}'`); + return; + } + if (release.tag_name !== expectedTag) { + core.setFailed(`release_id=${releaseId} has tag '${release.tag_name}', expected '${expectedTag}'`); + return; + } + + const { data: ref } = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${expectedTag}`, + }); + + let resolvedSha = ref.object.sha; + if (ref.object.type === "tag") { + const { data: tagObj } = await github.rest.git.getTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_sha: ref.object.sha, + }); + resolvedSha = tagObj.object.sha; + } + + if (resolvedSha !== expectedSha) { + core.setFailed(`tag '${expectedTag}' points to ${resolvedSha}, expected ${expectedSha}`); + return; + } + + core.info(`Verified release_id=${releaseId}, tag=${expectedTag}, commit=${resolvedSha}`); build-macos-windows: + if: ${{ inputs.build_platform_artifacts }} needs: create-release permissions: contents: write @@ -254,6 +327,7 @@ jobs: gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber generate-manifest: + if: ${{ inputs.build_platform_artifacts }} needs: [create-release, build-macos-windows] runs-on: ubuntu-24.04 permissions: @@ -275,6 +349,7 @@ jobs: gh release upload "$RELEASE_TAG" latest.json --clobber build-linux: + if: ${{ inputs.build_platform_artifacts }} needs: create-release permissions: contents: write