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.
This commit is contained in:
cucadmuh
2026-05-01 21:09:09 +03:00
committed by GitHub
parent 9ad0f8af6d
commit fef13fefd1
5 changed files with 150 additions and 26 deletions
+8 -8
View File
@@ -1,8 +1,5 @@
name: Next Channel 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 # 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. # is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below.
on: on:
@@ -14,13 +11,15 @@ on:
- "flake.lock" - "flake.lock"
- "nix/**" - "nix/**"
workflow_dispatch: workflow_dispatch:
workflow_run: inputs:
workflows: ["Promote main to next"] source_only:
types: [completed] description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs: jobs:
publish-next: publish-next:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
uses: ./.github/workflows/reusable-channel-publish.yml uses: ./.github/workflows/reusable-channel-publish.yml
with: with:
channel: next channel: next
@@ -29,5 +28,6 @@ jobs:
target_branch: next target_branch: next
prerelease: true prerelease: true
draft_release: 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 secrets: inherit
@@ -2,6 +2,12 @@ name: Promote main to next
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
source_only:
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs: jobs:
validate-main: validate-main:
@@ -15,6 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
actions: write
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@@ -64,3 +71,20 @@ jobs:
git commit -m "chore(release): bump next channel to ${NEW_VERSION}" git commit -m "chore(release): bump next channel to ${NEW_VERSION}"
- name: push next - name: push next
run: git push --force-with-lease origin HEAD: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}`);
@@ -2,12 +2,19 @@ name: Promote next to release
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
source_only:
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs: jobs:
promote: promote:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
actions: write
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@@ -49,3 +56,20 @@ jobs:
git commit -m "chore(release): finalize release version ${NEW_VERSION}" git commit -m "chore(release): finalize release version ${NEW_VERSION}"
- name: push release - name: push release
run: git push --force-with-lease origin HEAD: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}`);
+8 -7
View File
@@ -1,7 +1,5 @@
name: Release Channel 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`. # workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`.
on: on:
push: push:
@@ -11,13 +9,15 @@ on:
- "flake.lock" - "flake.lock"
- "nix/**" - "nix/**"
workflow_dispatch: workflow_dispatch:
workflow_run: inputs:
workflows: ["Promote next to release"] source_only:
types: [completed] description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs: jobs:
publish-release: publish-release:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
uses: ./.github/workflows/reusable-channel-publish.yml uses: ./.github/workflows/reusable-channel-publish.yml
with: with:
channel: release channel: release
@@ -25,5 +25,6 @@ jobs:
target_branch: release target_branch: release
prerelease: false prerelease: false
draft_release: 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 secrets: inherit
+80 -5
View File
@@ -28,6 +28,11 @@ on:
required: false required: false
default: true default: true
type: boolean type: boolean
build_platform_artifacts:
description: "Build and upload macOS/Windows/Linux artifacts + latest.json"
required: false
default: true
type: boolean
jobs: jobs:
create-release: create-release:
@@ -35,7 +40,7 @@ jobs:
contents: write contents: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
release_id: ${{ steps.create-release.outputs.result }} release_id: ${{ steps.create_release.outputs.release_id }}
package_version: ${{ steps.get-version.outputs.version }} package_version: ${{ steps.get-version.outputs.version }}
release_tag: ${{ steps.tag.outputs.value }} release_tag: ${{ steps.tag.outputs.value }}
source_commit_sha: ${{ steps.source-sha.outputs.value }} source_commit_sha: ${{ steps.source-sha.outputs.value }}
@@ -121,7 +126,7 @@ jobs:
echo "$BODY" >> "$GITHUB_OUTPUT" echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT" echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: create or update release - name: create or update release
id: create-release id: create_release
uses: actions/github-script@v7 uses: actions/github-script@v7
env: env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }} PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
@@ -139,6 +144,7 @@ jobs:
const version = process.env.PACKAGE_VERSION; const version = process.env.PACKAGE_VERSION;
const targetCommitish = process.env.RELEASE_COMMIT_SHA; const targetCommitish = process.env.RELEASE_COMMIT_SHA;
const name = `Psysonic v${version}`; const name = `Psysonic v${version}`;
let releaseId = null;
try { try {
const { data } = await github.rest.repos.getReleaseByTag({ const { data } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner, owner: context.repo.owner,
@@ -154,10 +160,10 @@ jobs:
draft, draft,
prerelease, prerelease,
}); });
return data.id; releaseId = data.id;
core.info(`Updated existing release id=${releaseId} tag=${tag}`);
} catch (e) { } catch (e) {
if (e.status !== 404) throw e; if (e.status !== 404) throw e;
}
const { data } = await github.rest.repos.createRelease({ const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
@@ -168,9 +174,76 @@ jobs:
draft, draft,
prerelease, prerelease,
}); });
return data.id; releaseId = data.id;
core.info(`Created release id=${releaseId} tag=${tag} target_commitish=${targetCommitish}`);
}
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,
release_id: releaseId,
});
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: build-macos-windows:
if: ${{ inputs.build_platform_artifacts }}
needs: create-release needs: create-release
permissions: permissions:
contents: write contents: write
@@ -254,6 +327,7 @@ jobs:
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
generate-manifest: generate-manifest:
if: ${{ inputs.build_platform_artifacts }}
needs: [create-release, build-macos-windows] needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
@@ -275,6 +349,7 @@ jobs:
gh release upload "$RELEASE_TAG" latest.json --clobber gh release upload "$RELEASE_TAG" latest.json --clobber
build-linux: build-linux:
if: ${{ inputs.build_platform_artifacts }}
needs: create-release needs: create-release
permissions: permissions:
contents: write contents: write